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.
13from contextlib
import contextmanager
14from pathlib
import Path
15from typing
import Any, Callable, Dict, List, Optional, Union
17from .Context
import Context, check_context_alive
18from .exceptions
import HeliosInvalidArgumentError
19from .plugins.registry
import get_plugin_registry, require_plugin
20from .wrappers
import UPlantArchitectureWrapper
as plantarch_wrapper
21from .wrappers.DataTypes
import vec3, vec2, int2, AxisRotation
23 from .validation.datatypes
import validate_vec3, validate_vec2, validate_int2
27 if hasattr(value,
'x')
and hasattr(value,
'y')
and hasattr(value,
'z'):
29 if isinstance(value, (list, tuple))
and len(value) == 3:
30 from .wrappers.DataTypes
import vec3
32 raise ValueError(f
"{name} must be vec3 or 3-element list/tuple")
35 if hasattr(value,
'x')
and hasattr(value,
'y'):
37 if isinstance(value, (list, tuple))
and len(value) == 2:
38 from .wrappers.DataTypes
import vec2
40 raise ValueError(f
"{name} must be vec2 or 2-element list/tuple")
43 if hasattr(value,
'x')
and hasattr(value,
'y'):
45 if isinstance(value, (list, tuple))
and len(value) == 2:
46 from .wrappers.DataTypes
import int2
48 raise ValueError(f
"{name} must be int2 or 2-element list/tuple")
49from .validation.core
import validate_positive_value
50from .assets
import get_asset_manager
51from .plant_architecture_params
import (
55 CarbohydrateParameters,
62logger = logging.getLogger(__name__)
66 accepted: Optional[List[str]] =
None) ->
None:
67 """Check build parameter types, and names against those the plant model accepts.
69 The native library also rejects a name the model does not accept, but raising here
70 gives callers a ValueError like every other argument check, before anything is built.
73 build_parameters: Mapping supplied by the caller, or None.
74 plant_model: Label of the currently loaded model, used in the error message.
75 accepted: Names the model accepts, from getPlantModelBuildParameterNames(), or None
76 to check types only (the native library still checks the names).
79 ValueError: If build_parameters is not a dict of str -> number, or contains a key
80 the model does not accept.
82 if build_parameters
is None:
85 if not isinstance(build_parameters, dict):
86 raise ValueError(
"build_parameters must be a dict or None")
88 for key, value
in build_parameters.items():
89 if not isinstance(key, str):
90 raise ValueError(
"build_parameters keys must be strings")
91 if isinstance(value, bool)
or not isinstance(value, (int, float)):
92 raise ValueError(
"build_parameters values must be numeric (int or float)")
94 if not build_parameters
or accepted
is None:
97 unknown = sorted(set(build_parameters) - set(accepted))
102 accepted_description = f
"accepts only: {', '.join(accepted)}"
104 accepted_description =
"accepts no build parameters"
107 f
"Unknown build parameter(s) {', '.join(repr(k) for k in unknown)}. "
108 f
"Plant model '{plant_model}' {accepted_description}."
114 Convert relative paths to absolute paths before changing working directory.
116 This preserves the user's intended file location when the working directory
117 is temporarily changed for C++ asset access. Absolute paths are returned unchanged.
120 filepath: File path to resolve (string or Path object)
123 Absolute path as string
125 path = Path(filepath)
126 if not path.is_absolute():
127 return str(Path.cwd() / path)
134 Context manager that temporarily changes working directory to where PlantArchitecture assets are located.
136 PlantArchitecture C++ code uses hardcoded relative paths like "plugins/plantarchitecture/assets/textures/"
137 expecting assets relative to working directory. This manager temporarily changes to the build directory
138 where assets are actually located.
141 RuntimeError: If build directory or PlantArchitecture assets are not found, indicating a build system error.
145 asset_manager = get_asset_manager()
146 working_dir = asset_manager._get_helios_build_path()
148 if working_dir
and working_dir.exists():
149 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
152 current_dir = Path(__file__).parent
153 packaged_build = current_dir /
'assets' /
'build'
155 if packaged_build.exists():
156 working_dir = packaged_build
157 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
160 repo_root = current_dir.parent
161 build_lib_dir = repo_root /
'pyhelios_build' /
'build' /
'lib'
162 working_dir = build_lib_dir.parent
163 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
165 if not build_lib_dir.exists():
167 f
"PyHelios build directory not found at {build_lib_dir}. "
168 f
"PlantArchitecture requires native libraries to be built. "
169 f
"Run: build_scripts/build_helios --plugins plantarchitecture"
172 if not plantarch_assets.exists():
174 f
"PlantArchitecture assets not found at {plantarch_assets}. "
175 f
"Build system failed to copy PlantArchitecture assets. "
176 f
"Run: build_scripts/build_helios --clean --plugins plantarchitecture"
180 assets_dir = plantarch_assets /
'assets'
181 if not assets_dir.exists():
183 f
"PlantArchitecture assets directory not found: {assets_dir}. "
184 f
"Essential assets missing. Rebuild with: "
185 f
"build_scripts/build_helios --clean --plugins plantarchitecture"
189 original_dir = os.getcwd()
191 os.chdir(working_dir)
192 logger.debug(f
"Changed working directory to {working_dir} for PlantArchitecture asset access")
195 os.chdir(original_dir)
196 logger.debug(f
"Restored working directory to {original_dir}")
200 """Raised when PlantArchitecture operations fail."""
206 Check if PlantArchitecture plugin is available for use.
209 bool: True if PlantArchitecture can be used, False otherwise
213 plugin_registry = get_plugin_registry()
214 if not plugin_registry.is_plugin_available(
'plantarchitecture'):
218 if not plantarch_wrapper._PLANTARCHITECTURE_FUNCTIONS_AVAILABLE:
227 """Re-raise an exception from a Python phytomer creation or callback function out of the call that ran it.
229 ctypes cannot propagate an exception out of a callback, so the trampolines built by
230 :meth:`PlantArchitecture.setPhytomerCreationFunction` and :meth:`PlantArchitecture.setPhytomerCallbackFunction`
231 store it and make the native call fail; this decorator then raises the stored exception in place of the
232 resulting native error.
234 @functools.wraps(method)
235 def wrapper(self, *args, **kwargs):
236 self._pending_callback_error =
None
238 result = method(self, *args, **kwargs)
239 except Exception
as native_error:
240 self._raise_pending_callback_error(native_error)
242 self._raise_pending_callback_error()
249 High-level interface for plant architecture modeling and procedural plant generation.
251 PlantArchitecture provides access to the comprehensive plant library with 25+ plant models
252 including trees (almond, apple, olive, walnut), crops (bean, cowpea, maize, rice, soybean),
253 and other plants. This class enables procedural plant generation, time-based growth
254 simulation, and plant community modeling.
256 This class requires the native Helios library built with PlantArchitecture support.
257 Use context managers for proper resource cleanup.
260 >>> with Context() as context:
261 ... with PlantArchitecture(context) as plantarch:
262 ... plantarch.loadPlantModelFromLibrary("bean")
263 ... plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(0, 0, 0), age=30)
264 ... plantarch.advanceTime(10.0) # Grow for 10 days
267 def __new__(cls, context=None):
269 Create PlantArchitecture instance.
270 Explicit __new__ to prevent ctypes contamination on Windows.
272 return object.__new__(cls)
274 def __init__(self, context: Context):
276 Initialize PlantArchitecture with a Helios context.
279 context: Active Helios Context instance
282 PlantArchitectureError: If plugin not available in current build
283 RuntimeError: If plugin initialization fails
286 registry = get_plugin_registry()
287 if not registry.is_plugin_available(
'plantarchitecture'):
289 "PlantArchitecture not available in current Helios library. "
290 "Rebuild PyHelios with PlantArchitecture support:\n"
291 " build_scripts/build_helios --plugins plantarchitecture\n"
293 "System requirements:\n"
294 f
" - Platforms: Windows, Linux, macOS\n"
295 " - Dependencies: Extensive asset library (textures, OBJ models)\n"
296 " - GPU: Not required\n"
298 "Plant library includes 25+ models: almond, apple, bean, cowpea, maize, "
299 "rice, soybean, tomato, wheat, and many others."
310 self.
_plantarch_ptr = plantarch_wrapper.createPlantArchitecture(context.getNativePtr())
316 """Raise if the owning Context has been destroyed (see Context.check_context_alive)."""
317 check_context_alive(getattr(self,
"context",
None),
"PlantArchitecture")
320 """Context manager entry"""
323 def __exit__(self, exc_type, exc_val, exc_tb):
324 """Context manager exit - cleanup resources"""
330 """Destructor to ensure C++ resources freed even without 'with' statement."""
331 if hasattr(self,
'_plantarch_ptr')
and self.
_plantarch_ptr is not None:
335 except Exception
as e:
337 warnings.warn(f
"Error in PlantArchitecture.__del__: {e}")
341 Load a plant model from the built-in library.
344 plant_label: Plant model identifier from library. Available models include:
345 "almond", "apple", "bean", "bindweed", "butterlettuce", "capsicum",
346 "cheeseweed", "cowpea", "easternredbud", "grapevine_VSP", "maize",
347 "olive", "pistachio", "puncturevine", "rice", "sorghum", "soybean",
348 "strawberry", "sugarbeet", "tomato", "cherrytomato", "walnut", "wheat"
351 ValueError: If plant_label is empty or invalid
352 PlantArchitectureError: If model loading fails
355 >>> plantarch.loadPlantModelFromLibrary("bean")
356 >>> plantarch.loadPlantModelFromLibrary("almond")
359 raise ValueError(
"Plant label cannot be empty")
361 if not plant_label.strip():
362 raise ValueError(
"Plant label cannot be only whitespace")
368 plantarch_wrapper.loadPlantModelFromLibrary(self.
_plantarch_ptr, plant_label.strip())
369 except Exception
as e:
375 """Validate build parameters against the names the loaded model declares natively."""
377 if isinstance(build_parameters, dict)
and build_parameters
and self.
_current_plant_model is not None:
381 @_surfaces_phytomer_callback_errors
383 build_parameters: Optional[dict] =
None) -> int:
385 Build a plant instance from the currently loaded library model.
388 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
389 age: Age of the plant in days (must be >= 0)
390 build_parameters: Optional dict of species-specific parameter overrides, such
391 as trellis dimensions, trunk height and scaffold count, grape
392 cluster size, or sorghum tillers and panicle form. Only some models
393 accept them, and a key the model does not accept raises ValueError:
394 - almond, almond_independence, apple, pistachio, walnut:
395 trunk_height, num_scaffolds, scaffold_angle
396 - grapevine_VSP: vine_spacing, trunk_height, berry_diameter,
398 - grapevine_Wye: trunk_height, cordon_spacing, vine_spacing,
399 catch_wire_height, berry_diameter, cluster_berry_count
400 - sorghum: tiller_count, panicle_type, and the panicle form,
402 All other models accept none. getPlantModelBuildParameterNames()
403 returns the full list for a model. Out-of-range or non-finite
404 values, and fractional values for parameters that count something
405 (e.g. num_scaffolds, tiller_count), raise PlantArchitectureError.
406 The plant keeps its build parameters (see getPlantBuildParameters()).
409 Plant ID for the created plant instance
412 ValueError: If age is negative or build_parameters is invalid
413 PlantArchitectureError: If plant building fails
414 RuntimeError: If no model has been loaded
417 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(2.0, 3.0, 0.0), age=45.0)
418 >>> # With custom parameters
419 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(
420 ... base_position=vec3(0, 0, 0),
422 ... build_parameters={'trunk_height': 2.0}
426 if not isinstance(base_position, vec3):
427 raise ValueError(f
"base_position must be a vec3, got {type(base_position).__name__}")
430 position_list = [base_position.x, base_position.y, base_position.z]
434 raise ValueError(f
"Age must be non-negative, got {age}")
441 return plantarch_wrapper.buildPlantInstanceFromLibrary(
444 except Exception
as e:
447 @_surfaces_phytomer_callback_errors
450 plant_count: int2, age: float,
451 germination_rate: float = 1.0,
452 build_parameters: Optional[dict] =
None) -> List[int]:
454 Build a canopy of regularly spaced plants from the currently loaded library model.
457 canopy_center: Cartesian (x,y,z) coordinates of canopy center as vec3
458 plant_spacing: Spacing between plants in x- and y-directions (meters) as vec2
459 plant_count: Number of plants in x- and y-directions as int2
460 age: Age of all plants in days (must be >= 0)
461 germination_rate: Probability that each plant position will be occupied (0 to 1).
462 A value of 1.0 means all positions are filled; 0.5 means roughly
463 half the positions will have plants. Default is 1.0.
464 build_parameters: Optional dict of species-specific parameter overrides,
465 applied to every plant in the canopy. Only some models
466 accept them, and a key the model does not accept raises ValueError.
467 See buildPlantInstanceFromLibrary() for the per-model list.
470 List of plant IDs for the created plant instances
473 ValueError: If age is negative, germination_rate is not in [0, 1],
474 plant count values are not positive, or build_parameters is invalid
475 PlantArchitectureError: If canopy building fails
478 >>> # 3x3 canopy with 0.5m spacing, 30-day-old plants
479 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
480 ... canopy_center=vec3(0, 0, 0),
481 ... plant_spacing=vec2(0.5, 0.5),
482 ... plant_count=int2(3, 3),
485 >>> # With 80% germination rate and custom parameters
486 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
487 ... canopy_center=vec3(0, 0, 0),
488 ... plant_spacing=vec2(1.5, 2.0),
489 ... plant_count=int2(5, 3),
491 ... germination_rate=0.8,
492 ... build_parameters={'trunk_height': 1.8}
496 if not isinstance(canopy_center, vec3):
497 raise ValueError(f
"canopy_center must be a vec3, got {type(canopy_center).__name__}")
498 if not isinstance(plant_spacing, vec2):
499 raise ValueError(f
"plant_spacing must be a vec2, got {type(plant_spacing).__name__}")
500 if not isinstance(plant_count, int2):
501 raise ValueError(f
"plant_count must be an int2, got {type(plant_count).__name__}")
505 raise ValueError(f
"Age must be non-negative, got {age}")
508 if not isinstance(germination_rate, (int, float)):
509 raise ValueError(f
"germination_rate must be a number, got {type(germination_rate).__name__}")
510 if germination_rate < 0
or germination_rate > 1:
511 raise ValueError(f
"germination_rate must be between 0 and 1, got {germination_rate}")
514 if plant_count.x <= 0
or plant_count.y <= 0:
515 raise ValueError(
"Plant count values must be positive integers")
520 center_list = [canopy_center.x, canopy_center.y, canopy_center.z]
521 spacing_list = [plant_spacing.x, plant_spacing.y]
522 count_list = [plant_count.x, plant_count.y]
527 return plantarch_wrapper.buildPlantCanopyFromLibrary(
529 germination_rate, build_parameters
531 except Exception
as e:
534 @_surfaces_phytomer_callback_errors
535 def advanceTime(self, dt: float, plant_id: Optional[int] =
None,
536 plant_ids: Optional[List[int]] =
None,
537 years: Optional[int] =
None) ->
None:
539 Advance time for plant growth and development.
541 Updates plants in the simulation, potentially adding new phytomers, growing
542 existing organs, transitioning phenological stages, and updating plant geometry.
544 By default every plant advances together. Pass plant_id or plant_ids to advance a
545 subset, which is what staggered planting dates and mixed-age stands require.
548 dt: Time step to advance in days (must be >= 0)
549 plant_id: Advance only this plant. Mutually exclusive with plant_ids.
550 plant_ids: Advance only these plants. Mutually exclusive with plant_id.
551 years: Advance this many whole years in addition to dt days. Applies to all
552 plants and cannot be combined with plant_id or plant_ids.
555 ValueError: If dt or years is negative, or selectors are combined
556 PlantArchitectureError: If time advancement fails
559 Large time steps are more efficient than many small steps. The timestep value
560 can be larger than the phyllochron, allowing multiple phytomers to be produced
564 >>> plantarch.advanceTime(10.0) # all plants, 10 days
565 >>> plantarch.advanceTime(10.0, plant_id=early) # one plant only
566 >>> plantarch.advanceTime(10.0, plant_ids=[a, b]) # a subset
567 >>> plantarch.advanceTime(0.0, years=4) # all plants, 4 years
570 raise ValueError(f
"Time step must be non-negative, got {dt}")
572 selectors = sum(x
is not None for x
in (plant_id, plant_ids, years))
574 raise ValueError(
"Pass at most one of plant_id, plant_ids, or years")
576 if plant_id
is not None and plant_id < 0:
577 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
578 if plant_ids
is not None and any(pid < 0
for pid
in plant_ids):
579 raise ValueError(
"plant_ids must all be non-negative")
580 if years
is not None and years < 0:
581 raise ValueError(f
"years must be non-negative, got {years}")
587 if plant_id
is not None:
588 plantarch_wrapper.advanceTimeForPlant(self.
_plantarch_ptr, plant_id, dt)
589 elif plant_ids
is not None:
592 plantarch_wrapper.advanceTimeForPlants(self.
_plantarch_ptr, plant_ids, dt)
593 elif years
is not None:
594 plantarch_wrapper.advanceTimeYears(self.
_plantarch_ptr, years, dt)
597 except Exception
as e:
601 plant_id: Optional[int] =
None,
602 view_half_angle_deg: Optional[float] =
None,
603 look_ahead_distance: float = 0.1,
604 attraction_weight: float = 0.6) ->
None:
606 Steer shoot growth toward a set of target points.
608 Attraction points are the counterpart to collision avoidance: collision tells a
609 plant what to grow around, attraction tells it what to grow toward. This is how
610 trellis wires, espalier targets and greenhouse supports are modelled.
612 Steering applies to growth that happens after this call, since the direction is
613 chosen as each phytomer is constructed. Enable the points before advanceTime().
616 points: Target locations as a list of vec3
617 plant_id: Apply to this plant only. Applies to every plant when None.
618 view_half_angle_deg: Half-angle of the search cone in degrees. Defaults to
619 45 for the global form and 80 for the per-plant form, matching the
620 native defaults, which differ between the two.
621 look_ahead_distance: How far ahead a shoot tip looks, in meters
622 attraction_weight: Strength of the steering, 0 to 1
625 ValueError: If points is empty or contains a non-vec3, or plant_id is negative
626 PlantArchitectureError: If the operation fails
629 >>> wires = [vec3(x, 0, 2.1) for x in range(0, 10)]
630 >>> plantarch.enableAttractionPoints(wires)
633 if plant_id
is not None and plant_id < 0:
634 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
636 if view_half_angle_deg
is None:
637 view_half_angle_deg = 45.0
if plant_id
is None else 80.0
642 plantarch_wrapper.enableAttractionPoints(
644 view_half_angle_deg, look_ahead_distance, attraction_weight
646 except Exception
as e:
651 Stop steering growth toward attraction points.
654 plant_id: Disable for this plant only. Disables globally when None.
657 ValueError: If plant_id is negative
658 PlantArchitectureError: If the operation fails
660 if plant_id
is not None and plant_id < 0:
661 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
666 plantarch_wrapper.disableAttractionPoints(self.
_plantarch_ptr, plant_id)
667 except Exception
as e:
671 plant_id: Optional[int] =
None) ->
None:
673 Replace the current attraction point set.
676 points: Replacement target locations as a list of vec3
677 plant_id: Update this plant only. Updates globally when None.
680 ValueError: If points is empty or contains a non-vec3, or plant_id is negative
681 PlantArchitectureError: If the operation fails
684 if plant_id
is not None and plant_id < 0:
685 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
690 plantarch_wrapper.updateAttractionPoints(self.
_plantarch_ptr, plant_id, points)
691 except Exception
as e:
695 plant_id: Optional[int] =
None) ->
None:
697 Add to the current attraction point set.
700 points: Additional target locations as a list of vec3
701 plant_id: Append for this plant only. Appends globally when None.
704 ValueError: If points is empty or contains a non-vec3, or plant_id is negative
705 PlantArchitectureError: If the operation fails
708 if plant_id
is not None and plant_id < 0:
709 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
714 plantarch_wrapper.appendAttractionPoints(self.
_plantarch_ptr, plant_id, points)
715 except Exception
as e:
719 look_ahead_distance: float,
720 attraction_weight: float,
721 obstacle_reduction_factor: float = 0.75,
722 plant_id: Optional[int] =
None) ->
None:
724 Tune how strongly attraction points steer growth.
727 view_half_angle_deg: Half-angle of the search cone in degrees
728 look_ahead_distance: How far ahead a shoot tip looks, in meters
729 attraction_weight: Strength of the steering, 0 to 1
730 obstacle_reduction_factor: Scales attraction where an obstacle intervenes
731 plant_id: Apply to this plant only. Applies globally when None.
734 ValueError: If plant_id is negative
735 PlantArchitectureError: If the operation fails
737 if plant_id
is not None and plant_id < 0:
738 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
743 plantarch_wrapper.setAttractionParameters(
745 look_ahead_distance, attraction_weight, obstacle_reduction_factor
747 except Exception
as e:
752 """Reject point sets the native layer would misread or silently ignore."""
753 if not isinstance(points, (list, tuple)):
755 f
"points must be a list of vec3, got {type(points).__name__}"
758 raise ValueError(
"points cannot be empty")
759 for index, point
in enumerate(points):
760 if not isinstance(point, vec3):
762 f
"points[{index}] must be a vec3, got {type(point).__name__}"
766 """Set a callback to receive progress updates during long-running operations.
768 The callback fires during advanceTime() and adjustFruitForObstacleCollision()
769 as the underlying ProgressBar updates.
772 callback: A callable(progress: float, message: str) where progress is
773 in [0, 1], or None to clear the callback.
776 ValueError: If callback is not callable and not None.
778 if callback
is not None:
779 if not callable(callback):
781 f
"callback must be callable or None, got {type(callback).__name__}"
784 def _c_callback(progress, message_bytes):
785 msg = message_bytes.decode(
'utf-8')
if isinstance(message_bytes, bytes)
else str(message_bytes)
786 callback(progress, msg)
798 """Register an external cancellation flag polled during long plant builds.
800 ``cancel_flag`` is a ctypes.c_int that, when set non-zero from another
801 thread, stops the canopy build loop and the advanceTime() growth loop
802 between plants/timesteps — so a long generation can be aborted mid-build
803 (returning whatever was built so far). Set it before the build call; pass
804 None to clear. The flag is caller-owned and must outlive the build.
811 Get current shoot parameters for a shoot type.
813 Returns the full nested shoot and phytomer parameter set, including the
814 internode/petiole/leaf/peduncle/inflorescence sub-structures and the leaf
815 prototype. Every numeric field is a RandomParameter spec with a
816 'distribution' and 'parameters'.
819 shoot_type_label: Label for the shoot type. Labels are species-specific,
820 e.g. "trifoliate" (bean), "trunk"/"scaffold" (almond).
821 return_typed: If True, return a typed
822 :class:`pyhelios.plant_architecture_params.ShootParameters`
823 object instead of a plain nested dict.
826 A nested ``dict`` (default) or a ``ShootParameters`` object containing:
827 - Geometric parameters (max_nodes, insertion_angle_tip, etc.)
828 - Growth parameters (phyllochron_min, elongation_rate_max, etc.)
829 - Boolean flags (flowers_require_dormancy, etc.)
830 - ``phytomer_parameters`` with nested internode/petiole/leaf/peduncle/
831 inflorescence parameters and the leaf prototype
834 ValueError: If shoot_type_label is empty
835 PlantArchitectureError: If parameter retrieval fails
838 >>> plantarch.loadPlantModelFromLibrary("bean")
839 >>> params = plantarch.getCurrentShootParameters("trifoliate")
840 >>> print(params['max_nodes'])
841 {'distribution': 'constant', 'parameters': [25.0]}
842 >>> print(params['phytomer_parameters']['leaf']['pitch'])
843 {'distribution': 'normal', 'parameters': [0.0, 20.0]}
845 if not shoot_type_label:
846 raise ValueError(
"Shoot type label cannot be empty")
848 if not shoot_type_label.strip():
849 raise ValueError(
"Shoot type label cannot be only whitespace")
854 params = plantarch_wrapper.getCurrentShootParameters(
857 except Exception
as e:
864 available = f
" Available shoot types: {', '.join(sorted(labels))}."
869 return ShootParameters.from_dict(params)
if return_typed
else params
871 def defineShootType(self, shoot_type_label: str, parameters: Union[dict, ShootParameters]) ->
None:
873 Define a custom shoot type with specified parameters.
875 Allows creating new shoot types or modifying existing ones. Pass either a
876 nested parameter ``dict`` (use :meth:`getCurrentShootParameters` as a
878 :class:`pyhelios.plant_architecture_params.ShootParameters` object.
880 Redefining an existing library shoot type preserves that species' built-in
881 phytomer creation and callback functions, so species-specific organ behavior
882 (such as maize forming ears rather than a tassel at every node) is retained.
885 shoot_type_label: Unique name for this shoot type
886 parameters: A nested dict matching the ShootParameters structure, or a
887 ShootParameters object.
890 ValueError: If shoot_type_label is empty, or parameters is not a dict
892 PlantArchitectureError: If shoot type definition fails
895 >>> from pyhelios.plant_architecture_params import ShootParameters, RandomParameterFloat
896 >>> plantarch.loadPlantModelFromLibrary("bean")
897 >>> sp = plantarch.getCurrentShootParameters("trifoliate", return_typed=True)
898 >>> sp.max_nodes = RandomParameterFloat.constant(20)
899 >>> sp.phytomer_parameters.leaf.pitch = RandomParameterFloat.uniform(40, 50)
900 >>> plantarch.defineShootType("TallStem", sp)
902 if not shoot_type_label:
903 raise ValueError(
"Shoot type label cannot be empty")
905 if not shoot_type_label.strip():
906 raise ValueError(
"Shoot type label cannot be only whitespace")
908 if isinstance(parameters, ShootParameters):
909 parameters = parameters.to_dict()
910 elif not isinstance(parameters, dict):
912 f
"Parameters must be a dict or ShootParameters, got {type(parameters).__name__}"
918 plantarch_wrapper.defineShootType(
921 except Exception
as e:
926 Get a default-constructed set of carbohydrate-model parameters.
928 The native API exposes no per-plant getter for carbohydrate parameters, so
929 this returns the C++ defaults as a template to modify and apply via
930 :meth:`setPlantCarbohydrateParameters`.
933 return_typed: If True, return a typed
934 :class:`pyhelios.plant_architecture_params.CarbohydrateParameters`.
937 A flat ``dict`` (default) or ``CarbohydrateParameters`` object.
942 params = plantarch_wrapper.getDefaultCarbohydrateParameters()
943 except Exception
as e:
945 return CarbohydrateParameters.from_dict(params)
if return_typed
else params
949 Set carbohydrate-model parameters for a plant.
952 plant_id: Target plant instance ID
953 parameters: A flat dict or a CarbohydrateParameters object.
956 ValueError: If parameters is not a dict or CarbohydrateParameters
957 PlantArchitectureError: If the operation fails
959 if isinstance(parameters, CarbohydrateParameters):
960 parameters = parameters.to_dict()
961 elif not isinstance(parameters, dict):
963 f
"Parameters must be a dict or CarbohydrateParameters, got {type(parameters).__name__}"
968 plantarch_wrapper.setPlantCarbohydrateParameters(self.
_plantarch_ptr, plant_id, parameters)
969 except Exception
as e:
974 Get a default-constructed set of nitrogen-model parameters.
976 The native API exposes no per-plant getter for nitrogen parameters, so this
977 returns the C++ defaults as a template to modify and apply via
978 :meth:`setPlantNitrogenParameters`.
981 return_typed: If True, return a typed
982 :class:`pyhelios.plant_architecture_params.NitrogenParameters`.
985 A flat ``dict`` (default) or ``NitrogenParameters`` object.
990 params = plantarch_wrapper.getDefaultNitrogenParameters()
991 except Exception
as e:
993 return NitrogenParameters.from_dict(params)
if return_typed
else params
997 Set nitrogen-model parameters for a plant.
1000 plant_id: Target plant instance ID
1001 parameters: A flat dict or a NitrogenParameters object.
1004 ValueError: If parameters is not a dict or NitrogenParameters, or the dict
1005 contains a key that is not a nitrogen parameter
1006 PlantArchitectureError: If the operation fails
1008 if isinstance(parameters, NitrogenParameters):
1009 parameters = parameters.to_dict()
1010 elif not isinstance(parameters, dict):
1012 f
"Parameters must be a dict or NitrogenParameters, got {type(parameters).__name__}"
1017 f
"Unknown nitrogen parameter(s) {unknown}; valid keys are "
1018 f
"{sorted(NitrogenParameters().to_dict())}. remobilization_age_threshold was removed "
1019 f
"in helios-core 1.3.88 (see leaf_remobilization_rate and leaf_senescence_duration_fraction)."
1024 plantarch_wrapper.setPlantNitrogenParameters(self.
_plantarch_ptr, plant_id, parameters)
1025 except Exception
as e:
1030 Get list of all available plant models in the library.
1033 List of plant model names available for loading
1036 PlantArchitectureError: If retrieval fails
1039 >>> models = plantarch.getAvailablePlantModels()
1040 >>> print(f"Available models: {', '.join(models)}")
1041 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
1046 return plantarch_wrapper.getAvailablePlantModels(self.
_plantarch_ptr)
1047 except Exception
as e:
1052 Get the names of the build parameters a library plant model accepts.
1054 These are the keys that may be passed as ``build_parameters`` to
1055 :meth:`buildPlantInstanceFromLibrary` and :meth:`buildPlantCanopyFromLibrary` for the
1056 model; any other key is rejected. The model does not need to be loaded.
1059 plant_model: Name of the plant model (see :meth:`getAvailablePlantModels`)
1062 Names of the model's structure parameters (training system, tillers) followed by
1063 its organ parameters (flower and fruit form); empty if the model accepts none
1066 ValueError: If ``plant_model`` is not a non-empty string
1067 PlantArchitectureError: If the model does not exist in the library
1068 RuntimeError: If the native library predates helios-core v1.3.93
1071 >>> plantarch.getPlantModelBuildParameterNames("grapevine_VSP")
1072 ['vine_spacing', 'trunk_height', 'berry_diameter', 'cluster_berry_count']
1074 if not isinstance(plant_model, str)
or not plant_model.strip():
1075 raise ValueError(
"Plant model must be a non-empty string")
1078 return plantarch_wrapper.getPlantModelBuildParameterNames(self.
_plantarch_ptr, plant_model)
1079 except Exception
as e:
1081 f
"Failed to get the build parameter names of plant model '{plant_model}': {e}")
1084 plant_id: Optional[int] =
None) -> List[str]:
1086 Get the shoot type labels defined for a plant model.
1088 Shoot type labels are species-specific strings such as "trunk" or "scaffold", and
1089 every shoot-parameter call takes one. Use this to discover the valid labels rather
1093 plant_model: Query this library model without changing the currently loaded
1094 one. Use getAvailablePlantModels() for valid names. Mutually exclusive
1096 plant_id: Query the shoot types captured by this plant instance when it was
1097 created. Mutually exclusive with plant_model.
1099 With neither argument, queries the currently loaded model, which requires a prior
1100 call to loadPlantModelFromLibrary().
1103 List of shoot type label strings.
1106 ValueError: If both plant_model and plant_id are given, or plant_id is negative
1107 PlantArchitectureError: If no model is loaded, or the model or plant is unknown
1110 >>> plantarch.loadPlantModelFromLibrary("almond")
1111 >>> plantarch.listShootTypeLabels()
1112 ['proleptic', 'scaffold', 'sylleptic', 'trunk']
1113 >>> plantarch.listShootTypeLabels(plant_model="bean")
1114 ['trifoliate', 'unifoliate']
1116 if plant_model
is not None and plant_id
is not None:
1117 raise ValueError(
"Pass either plant_model or plant_id, not both")
1118 if plant_id
is not None and plant_id < 0:
1119 raise ValueError(f
"plant_id must be non-negative, got {plant_id}")
1120 if plant_model
is not None and not plant_model.strip():
1121 raise ValueError(
"plant_model cannot be empty or only whitespace")
1126 return plantarch_wrapper.listShootTypeLabels(
1128 plant_model.strip()
if plant_model
is not None else None,
1131 except Exception
as e:
1136 Get UUIDs of every plant primitive in the model.
1138 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1139 such as assigning optical properties or reading flux by organ type needs.
1142 List of primitive UUIDs
1145 PlantArchitectureError: If retrieval fails
1148 >>> ids = plantarch.getAllUUIDs()
1154 except Exception
as e:
1159 Get UUIDs of every leaf primitive in the model.
1161 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1162 such as assigning optical properties or reading flux by organ type needs.
1165 List of leaf primitive UUIDs
1168 PlantArchitectureError: If retrieval fails
1171 >>> ids = plantarch.getAllLeafUUIDs()
1177 except Exception
as e:
1182 Get UUIDs of every internode primitive in the model.
1184 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1185 such as assigning optical properties or reading flux by organ type needs.
1188 List of internode primitive UUIDs
1191 PlantArchitectureError: If retrieval fails
1194 >>> ids = plantarch.getAllInternodeUUIDs()
1199 return plantarch_wrapper.getAllInternodeUUIDs(self.
_plantarch_ptr)
1200 except Exception
as e:
1205 Get UUIDs of every petiole primitive in the model.
1207 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1208 such as assigning optical properties or reading flux by organ type needs.
1211 List of petiole primitive UUIDs
1214 PlantArchitectureError: If retrieval fails
1217 >>> ids = plantarch.getAllPetioleUUIDs()
1223 except Exception
as e:
1228 Get UUIDs of every peduncle primitive in the model.
1230 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1231 such as assigning optical properties or reading flux by organ type needs.
1233 An empty list means no plant has reached the corresponding growth stage,
1234 which is a legitimate result rather than a failure.
1237 List of peduncle primitive UUIDs
1240 PlantArchitectureError: If retrieval fails
1243 >>> ids = plantarch.getAllPeduncleUUIDs()
1248 return plantarch_wrapper.getAllPeduncleUUIDs(self.
_plantarch_ptr)
1249 except Exception
as e:
1254 Get UUIDs of every flower primitive in the model.
1256 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1257 such as assigning optical properties or reading flux by organ type needs.
1259 An empty list means no plant has reached the corresponding growth stage,
1260 which is a legitimate result rather than a failure.
1263 List of flower primitive UUIDs
1266 PlantArchitectureError: If retrieval fails
1269 >>> ids = plantarch.getAllFlowerUUIDs()
1275 except Exception
as e:
1280 Get UUIDs of every fruit primitive in the model.
1282 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1283 such as assigning optical properties or reading flux by organ type needs.
1285 An empty list means no plant has reached the corresponding growth stage,
1286 which is a legitimate result rather than a failure.
1289 List of fruit primitive UUIDs
1292 PlantArchitectureError: If retrieval fails
1295 >>> ids = plantarch.getAllFruitUUIDs()
1301 except Exception
as e:
1306 Get object IDs of every plant compound object in the model.
1308 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1309 such as assigning optical properties or reading flux by organ type needs.
1315 PlantArchitectureError: If retrieval fails
1318 >>> ids = plantarch.getAllObjectIDs()
1324 except Exception
as e:
1329 Get IDs of every plant instance in the model.
1331 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1332 such as assigning optical properties or reading flux by organ type needs.
1338 PlantArchitectureError: If retrieval fails
1341 >>> ids = plantarch.getAllPlantIDs()
1347 except Exception
as e:
1352 Get all object IDs for a specific plant.
1355 plant_id: ID of the plant instance
1358 List of object IDs comprising the plant
1361 ValueError: If plant_id is negative
1362 PlantArchitectureError: If retrieval fails
1365 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
1366 >>> print(f"Plant has {len(object_ids)} objects")
1369 raise ValueError(
"Plant ID must be non-negative")
1373 return plantarch_wrapper.getAllPlantObjectIDs(self.
_plantarch_ptr, plant_id)
1374 except Exception
as e:
1379 Get object IDs for all leaf objects on a specific plant.
1382 plant_id: ID of the plant instance
1385 List of object IDs, one per leaf
1388 ValueError: If plant_id is negative
1389 PlantArchitectureError: If retrieval fails
1392 Do **not** pair this result positionally with :meth:`getPlantLeafBases`.
1393 The two are built by independent traversals of the shoot tree, so their
1394 index correspondence is not guaranteed by the native API.
1397 >>> leaf_ids = plantarch.getPlantLeafObjectIDs(plant_id)
1398 >>> print(f"Plant has {len(leaf_ids)} leaves")
1401 raise ValueError(
"Plant ID must be non-negative")
1405 return plantarch_wrapper.getPlantLeafObjectIDs(self.
_plantarch_ptr, plant_id)
1406 except Exception
as e:
1411 Get object IDs for all petiole objects on a specific plant.
1413 Petioles are the stalks attaching leaves to the stem, so this is the
1414 structural counterpart to :meth:`getPlantLeafObjectIDs`.
1417 plant_id: ID of the plant instance
1420 List of object IDs, one per petiole
1423 ValueError: If plant_id is negative
1424 PlantArchitectureError: If retrieval fails
1427 >>> petiole_ids = plantarch.getPlantPetioleObjectIDs(plant_id)
1428 >>> print(f"Plant has {len(petiole_ids)} petioles")
1431 raise ValueError(
"Plant ID must be non-negative")
1435 return plantarch_wrapper.getPlantPetioleObjectIDs(self.
_plantarch_ptr, plant_id)
1436 except Exception
as e:
1441 Get object IDs for all peduncle objects on a specific plant.
1443 Peduncles are the stalks bearing flowers and fruit.
1446 plant_id: ID of the plant instance
1449 List of object IDs, one per peduncle. Empty if the plant has not
1450 reached its reproductive stage, which is a normal result rather than
1454 ValueError: If plant_id is negative
1455 PlantArchitectureError: If retrieval fails
1458 >>> peduncle_ids = plantarch.getPlantPeduncleObjectIDs(plant_id)
1459 >>> print(f"Plant has {len(peduncle_ids)} peduncles")
1462 raise ValueError(
"Plant ID must be non-negative")
1466 return plantarch_wrapper.getPlantPeduncleObjectIDs(self.
_plantarch_ptr, plant_id)
1467 except Exception
as e:
1472 Get object IDs for all flower (inflorescence) objects on a specific plant.
1475 plant_id: ID of the plant instance
1478 List of object IDs, one per flower. Empty if the plant has not
1479 flowered -- or has already flowered and set fruit, since flowers are
1480 replaced by fruit as growth proceeds. Both are normal results rather
1484 ValueError: If plant_id is negative
1485 PlantArchitectureError: If retrieval fails
1488 >>> flower_ids = plantarch.getPlantFlowerObjectIDs(plant_id)
1489 >>> print(f"Plant has {len(flower_ids)} flowers")
1492 raise ValueError(
"Plant ID must be non-negative")
1496 return plantarch_wrapper.getPlantFlowerObjectIDs(self.
_plantarch_ptr, plant_id)
1497 except Exception
as e:
1502 Get object IDs for all fruit objects on a specific plant.
1505 plant_id: ID of the plant instance
1508 List of object IDs, one per fruit. Empty if the plant has not
1509 fruited, which is a normal result rather than an error -- fruit
1510 appear only once a plant reaches the reproductive stage, so a plant
1511 built at a young age or from a model with no fruit yields ``[]``.
1514 ValueError: If plant_id is negative
1515 PlantArchitectureError: If retrieval fails
1518 >>> fruit_ids = plantarch.getPlantFruitObjectIDs(plant_id)
1519 >>> print(f"Plant has {len(fruit_ids)} fruit")
1520 >>> # Object IDs are Context object IDs, so the usual queries apply:
1521 >>> uuids = context.getObjectPrimitiveUUIDs(fruit_ids[0])
1524 raise ValueError(
"Plant ID must be non-negative")
1528 return plantarch_wrapper.getPlantFruitObjectIDs(self.
_plantarch_ptr, plant_id)
1529 except Exception
as e:
1534 Get the attachment base position of every leaf on a specific plant.
1536 The base is where the leaf attaches to its petiole, not the leaf centroid.
1539 plant_id: ID of the plant instance
1542 List of vec3 base positions, one per leaf
1545 ValueError: If plant_id is negative
1546 PlantArchitectureError: If retrieval fails
1549 Do **not** pair this result positionally with
1550 :meth:`getPlantLeafObjectIDs`. The two are built by independent
1551 traversals of the shoot tree, so their index correspondence is not
1552 guaranteed by the native API.
1555 >>> bases = plantarch.getPlantLeafBases(plant_id)
1556 >>> print(f"First leaf attaches at {bases[0]}")
1559 raise ValueError(
"Plant ID must be non-negative")
1563 flat = plantarch_wrapper.getPlantLeafBases(self.
_plantarch_ptr, plant_id)
1564 except Exception
as e:
1567 return [
vec3(float(flat[i]), float(flat[i + 1]), float(flat[i + 2]))
1568 for i
in range(0, len(flat), 3)]
1570 def getAllPlantUUIDs(self, plant_id: int, include_hidden: bool =
False) -> List[int]:
1572 Get all primitive UUIDs for a specific plant.
1575 plant_id: ID of the plant instance
1576 include_hidden: If True, also include UUIDs of hidden prototype
1577 primitives managed by this PlantArchitecture instance.
1580 List of primitive UUIDs comprising the plant (and optionally hidden prototypes)
1583 ValueError: If plant_id is negative
1584 PlantArchitectureError: If retrieval fails
1587 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
1588 >>> print(f"Plant has {len(uuids)} primitives")
1591 raise ValueError(
"Plant ID must be non-negative")
1595 return plantarch_wrapper.getAllPlantUUIDs(self.
_plantarch_ptr, plant_id, include_hidden)
1596 except Exception
as e:
1601 Get the IDs of all shoots belonging to a plant.
1603 Shoot IDs are contiguous 0-based indices into the plant's shoot tree, in creation
1604 order; shoot 0 is always the base stem. The returned IDs can be passed to
1605 :meth:`getShoot`, :meth:`getShootChildIDs`, etc.
1608 plant_id: ID of the plant instance
1611 List of shoot IDs for the plant
1614 raise ValueError(
"Plant ID must be non-negative")
1617 return plantarch_wrapper.getAllPlantShootIDs(self.
_plantarch_ptr, plant_id)
1618 except Exception
as e:
1621 def getShoot(self, plant_id: int, shoot_id: int) -> Dict[str, Any]:
1623 Get a read-only view of a shoot's topology.
1626 plant_id: ID of the plant instance
1627 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
1630 A dict with keys ``rank``, ``parent_shoot_id`` (-1 for the base stem),
1631 ``parent_node_index``, and ``node_count``.
1633 if plant_id < 0
or shoot_id < 0:
1634 raise ValueError(
"Plant ID and shoot ID must be non-negative")
1637 return plantarch_wrapper.getPlantShootTopology(self.
_plantarch_ptr, plant_id, shoot_id)
1638 except Exception
as e:
1640 f
"Failed to get shoot {shoot_id} of plant {plant_id}: {e}")
1643 """Get the child shoot IDs of a shoot (flattened across parent node indices)."""
1644 if plant_id < 0
or shoot_id < 0:
1645 raise ValueError(
"Plant ID and shoot ID must be non-negative")
1648 return plantarch_wrapper.getPlantShootChildIDs(self.
_plantarch_ptr, plant_id, shoot_id)
1649 except Exception
as e:
1651 f
"Failed to get child shoots of shoot {shoot_id}, plant {plant_id}: {e}")
1655 Get the ID of the shoot a shoot grew from.
1658 plant_id: ID of the plant instance
1659 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
1662 ID of the parent shoot, or -1 if this is the base stem shoot.
1665 A pruned shoot still reports the parent it grew from, even though it is no
1666 longer listed among that parent's children.
1669 >>> parent = plantarch.getParentShootID(plant_id, shoot_id=3)
1671 return self.
_shootScalarQuery(
"getParentShootID", plant_id, shoot_id,
"parent shoot ID")
1673 def getShootRank(self, plant_id: int, shoot_id: int) -> int:
1675 Get the branching rank of a shoot.
1677 Rank is the botanical branching order: the base stem is rank 0, a branch off it
1678 is rank 1, and so on. A shoot created by :meth:`appendShoot` continues its
1679 parent's axis rather than branching from it, so it keeps the parent's rank.
1680 Rank is therefore not the same as :meth:`getShootDepth`.
1683 plant_id: ID of the plant instance
1684 shoot_id: Shoot index within the plant
1687 Branching rank of the shoot.
1690 >>> rank = plantarch.getShootRank(plant_id, shoot_id=3)
1694 def getShootDepth(self, plant_id: int, shoot_id: int) -> int:
1696 Get the number of shoots between a shoot and the base stem shoot.
1698 The base stem has depth 0, its children depth 1, and so on. Unlike
1699 :meth:`getShootRank` this counts every step in the shoot tree, including axis
1700 continuations created by :meth:`appendShoot`.
1703 plant_id: ID of the plant instance
1704 shoot_id: Shoot index within the plant
1707 Number of steps from this shoot to the base stem shoot.
1711 def isShootPruned(self, plant_id: int, shoot_id: int) -> bool:
1713 Report whether a shoot has been pruned away entirely.
1715 :meth:`pruneBranch` called with ``node_index=0`` removes all of a shoot's
1716 phytomers and geometry but keeps the shoot in the plant's tree so that shoot IDs
1717 stay stable. Such a shoot is still returned by :meth:`getAllShootIDs` but is
1718 inert: it has zero nodes, contributes no leaf area, and cannot be queried for
1719 geometry. Use this to skip those shoots when walking :meth:`getAllShootIDs`.
1722 plant_id: ID of the plant instance
1723 shoot_id: Shoot index within the plant
1726 True if the shoot was pruned away and no longer forms part of the plant.
1729 >>> live = [s for s in plantarch.getAllShootIDs(plant_id)
1730 ... if not plantarch.isShootPruned(plant_id, s)]
1732 return self.
_shootScalarQuery(
"isShootPruned", plant_id, shoot_id,
"pruned state")
1734 def getPathToRoot(self, plant_id: int, shoot_id: int) -> List[int]:
1736 Get the chain of shoots connecting a shoot to the base stem shoot.
1739 plant_id: ID of the plant instance
1740 shoot_id: Shoot index within the plant
1743 Shoot IDs ordered from the given shoot to the base stem shoot, including
1744 both. For the base stem shoot this is a single element.
1747 >>> path = plantarch.getPathToRoot(plant_id, shoot_id=5)
1753 Get the shoots that grew directly out of a shoot.
1755 Ordered by the node they attach to. This includes shoots created by
1756 :meth:`appendShoot`, which continue the parent's axis rather than branching from
1757 it; compare their :meth:`getShootRank` with the parent's to tell the two apart.
1758 Pruned shoots are not included.
1761 plant_id: ID of the plant instance
1762 shoot_id: Shoot index within the plant
1765 IDs of the direct children of the shoot, empty if it has none.
1767 return self.
_shootScalarQuery(
"getChildShootIDs", plant_id, shoot_id,
"child shoot IDs")
1771 Get every shoot descending from a shoot.
1773 Collected depth-first, so a shoot is always listed before its own descendants.
1774 The shoot itself is not included, and pruned shoots are omitted.
1777 plant_id: ID of the plant instance
1778 shoot_id: Shoot whose descendants to collect
1781 IDs of all descendants of the shoot, empty if it has none.
1784 >>> descendants = plantarch.getAllDescendantShootIDs(plant_id, shoot_id=1)
1785 >>> print(f"Branch carries {len(descendants)} sub-shoots")
1788 "descendant shoot IDs")
1792 Get the parent-to-children structure of a plant.
1794 Only shoots that actually have children appear as keys. Pruned shoots appear
1795 neither as keys nor among the children.
1798 plant_id: ID of the plant instance
1801 Dict mapping shoot ID to the IDs of its direct children.
1804 >>> hierarchy = plantarch.getShootHierarchyMap(plant_id)
1805 >>> print(f"{len(hierarchy)} shoots carry branches")
1808 raise ValueError(
"Plant ID must be non-negative")
1811 return plantarch_wrapper.getShootHierarchyMap(self.
_plantarch_ptr, plant_id)
1812 except Exception
as e:
1814 f
"Failed to get shoot hierarchy of plant {plant_id}: {e}")
1818 """Shared body for the per-shoot hierarchy accessors."""
1819 if plant_id < 0
or shoot_id < 0:
1820 raise ValueError(
"Plant ID and shoot ID must be non-negative")
1823 return getattr(plantarch_wrapper, wrapper_fn_name)(
1825 except Exception
as e:
1827 f
"Failed to get {description} of shoot {shoot_id}, plant {plant_id}: {e}")
1830 """Get the woody internode polyline vertices of a shoot as a list of (x, y, z) tuples."""
1831 if plant_id < 0
or shoot_id < 0:
1832 raise ValueError(
"Plant ID and shoot ID must be non-negative")
1835 return plantarch_wrapper.getPlantShootInternodeVertices(self.
_plantarch_ptr, plant_id, shoot_id)
1836 except Exception
as e:
1838 f
"Failed to get internode vertices of shoot {shoot_id}, plant {plant_id}: {e}")
1841 """Get the per-vertex woody internode radii of a shoot."""
1842 if plant_id < 0
or shoot_id < 0:
1843 raise ValueError(
"Plant ID and shoot ID must be non-negative")
1846 return plantarch_wrapper.getPlantShootInternodeRadii(self.
_plantarch_ptr, plant_id, shoot_id)
1847 except Exception
as e:
1849 f
"Failed to get internode radii of shoot {shoot_id}, plant {plant_id}: {e}")
1855 def _plantFloatVector(self, wrapper_fn_name: str, plant_id: int, description: str) -> List[float]:
1856 """Shared body for the per-organ built-geometry queries."""
1858 raise ValueError(
"Plant ID must be non-negative")
1862 except Exception
as e:
1867 Get the built one-sided surface area of every leaf on a plant.
1869 Measured from the geometry that was actually built, rather than reported from
1870 the shoot type's parameters. The two answer different questions: the shoot type
1871 gives the distribution a parameter was drawn from, while this gives what the
1872 plant ended up with.
1874 This reports present area, so a leaf part-way through its growth is counted at
1875 its current size. Leaves are visited shoot by shoot and then phytomer by phytomer,
1876 the same order as :meth:`getPlantLeafObjectIDs`. Leaves whose geometry does not
1877 exist (removed, senesced, or never built) are omitted rather than reported as
1878 zero, so the result can be shorter than the list from :meth:`getPlantLeafObjectIDs`.
1879 Each area is blade area: a leaflet's petiolule belongs to its leaf object but is not
1880 counted (helios-core 1.3.89+).
1882 Requires helios-core v1.3.85 or newer.
1885 plant_id: ID of the plant instance
1888 One-sided surface area (m^2) of each leaf on the plant
1891 ValueError: If plant_id is negative
1892 PlantArchitectureError: If the query fails or the library predates v1.3.85
1895 >>> areas = plantarch.getPlantLeafAreas(plant_id)
1896 >>> print(f"{len(areas)} leaves, mean {sum(areas)/len(areas):.4f} m^2")
1902 Get the built length of every internode on a plant.
1904 Measured along the internode's node positions as they were built, so a shoot
1905 whose geometry was prescribed by :meth:`addShootFromNodePositions` reports its
1906 measured lengths and a grown shoot reports what growth produced. See
1907 :meth:`getPlantLeafAreas` for why this differs from reading the shoot type's
1908 ``internode_length_max``.
1910 Internodes are visited shoot by shoot and then phytomer by phytomer, so the
1911 result has one entry per phytomer on the plant.
1913 Requires helios-core v1.3.85 or newer.
1916 plant_id: ID of the plant instance
1919 Length (m) of each internode on the plant
1922 ValueError: If plant_id is negative
1923 PlantArchitectureError: If the query fails or the library predates v1.3.85
1925 return self.
_plantFloatVector(
"getPlantInternodeLengths", plant_id,
"internode lengths")
1929 Get the inclination angle of every leaf on a plant.
1931 The angle between each leaf blade and the horizontal, computed from its
1932 area-weighted normal so that a curved or folded blade is summarized by the
1933 direction it mostly faces. 0 degrees is a horizontal blade and 90 degrees a
1934 vertical one; because a blade is a surface, a normal pointing down describes the
1935 same inclination as its opposite pointing up, so the angle is folded about the
1936 horizontal and never exceeds 90 degrees.
1938 Ordering and the treatment of missing geometry match :meth:`getPlantLeafAreas`.
1939 A blade whose facet normals cancel exactly is additionally omitted, since it
1940 faces no single direction.
1942 Requires helios-core v1.3.85 or newer.
1945 plant_id: ID of the plant instance
1948 Inclination angle (degrees, in [0, 90]) of each leaf on the plant
1951 ValueError: If plant_id is negative
1952 PlantArchitectureError: If the query fails or the library predates v1.3.85
1954 return self.
_plantFloatVector(
"getPlantLeafInclinations", plant_id,
"leaf inclinations")
1958 Report whether a shoot's existing geometry was prescribed by the caller rather than generated.
1960 True for a shoot built by :meth:`addShootFromNodePositions`, whose internode path
1961 follows measured node positions. Such a shoot's existing phytomers are exempt
1962 from the re-scaling and re-curving performed by :meth:`advanceTime`, so a caller
1963 reading geometry back can tell which parts of a plant are measurement and which
1966 Requires helios-core v1.3.85 or newer.
1969 plant_id: ID of the plant instance
1970 shoot_id: Shoot index within the plant
1973 True if the shoot was built from prescribed node positions
1976 ValueError: If either ID is negative
1977 PlantArchitectureError: If the query fails or the library predates v1.3.85
1980 "prescribed-geometry state")
1984 Get the current age of a plant in days.
1987 plant_id: ID of the plant instance
1993 ValueError: If plant_id is negative
1994 PlantArchitectureError: If retrieval fails
1997 >>> age = plantarch.getPlantAge(plant_id)
1998 >>> print(f"Plant is {age} days old")
2001 raise ValueError(
"Plant ID must be non-negative")
2006 return plantarch_wrapper.getPlantAge(self.
_plantarch_ptr, plant_id)
2007 except Exception
as e:
2012 Get the maximum age of a plant, beyond which it stops growing.
2015 plant_id: ID of the plant instance
2018 Maximum plant age in days. See :meth:`setPlantMaxAge`.
2021 ValueError: If plant_id is negative
2022 PlantArchitectureError: If retrieval fails
2025 >>> max_age = plantarch.getPlantMaxAge(plant_id)
2028 raise ValueError(
"Plant ID must be non-negative")
2031 return plantarch_wrapper.getPlantMaxAge(self.
_plantarch_ptr, plant_id)
2032 except Exception
as e:
2034 f
"Failed to get maximum age of plant {plant_id}: {e}")
2038 Set the maximum age of a plant, beyond which it stops growing.
2040 Once a plant's age reaches this value, :meth:`advanceTime` stops advancing it and
2041 its geometry becomes static. The default is 999 days. Every plant model in the
2042 library sets its own value as part of its builder (an apple tree, for example,
2043 uses 1460 days), but a plant assembled manually with :meth:`addPlantInstance`
2044 keeps the default and so silently stops growing after 999 days.
2046 Setting a maximum age below the plant's current age is permitted, and freezes the
2047 plant at its current form.
2050 plant_id: ID of the plant instance
2051 max_age: Maximum age of the plant in days. Must be non-negative.
2054 ValueError: If plant_id is negative or max_age is negative
2055 PlantArchitectureError: If the plant does not exist
2058 >>> plantarch.setPlantMaxAge(plant_id, 1460.0)
2061 raise ValueError(
"Plant ID must be non-negative")
2063 raise ValueError(f
"Maximum age must be non-negative, got {max_age}")
2066 plantarch_wrapper.setPlantMaxAge(self.
_plantarch_ptr, plant_id, max_age)
2067 except Exception
as e:
2069 f
"Failed to set maximum age of plant {plant_id}: {e}")
2073 Get the height of a plant in meters.
2076 plant_id: ID of the plant instance
2079 Plant height in meters (vertical extent)
2082 ValueError: If plant_id is negative
2083 PlantArchitectureError: If retrieval fails
2086 >>> height = plantarch.getPlantHeight(plant_id)
2087 >>> print(f"Plant is {height:.2f}m tall")
2090 raise ValueError(
"Plant ID must be non-negative")
2095 return plantarch_wrapper.getPlantHeight(self.
_plantarch_ptr, plant_id)
2096 except Exception
as e:
2101 Get the total leaf area of a plant in m².
2103 Leaf area is blade area: the petiolules (leaflet stalks) of compound leaves are
2104 excluded (helios-core 1.3.89+; earlier versions counted them as leaf).
2107 plant_id: ID of the plant instance
2110 Total leaf area in square meters
2113 ValueError: If plant_id is negative
2114 PlantArchitectureError: If retrieval fails
2117 >>> leaf_area = plantarch.getPlantLeafArea(plant_id)
2118 >>> print(f"Total leaf area: {leaf_area:.3f} m²")
2121 raise ValueError(
"Plant ID must be non-negative")
2126 return plantarch_wrapper.sumPlantLeafArea(self.
_plantarch_ptr, plant_id)
2127 except Exception
as e:
2132 Enable optional output object data to be written to the Context.
2134 By default, the plant architecture model only writes a minimal set of
2135 object data. This method enables additional object data fields so that
2136 they are available on the Context's compound objects after building.
2139 object_data_labels: A single label or a list of labels to enable.
2140 Valid labels include: "age", "rank", "plantID", "plant_name",
2141 "plant_height", "plant_type", "phenology_stage", "leafID",
2142 "peduncleID", "closedflowerID", "openflowerID", "fruitID",
2143 "carbohydrate_concentration". The special label "all" enables
2144 every available field.
2147 ValueError: If a label is empty or not a string
2148 PlantArchitectureError: If an invalid label is supplied or the
2149 operation otherwise fails
2152 >>> plantarch.optionalOutputObjectData("age")
2153 >>> plantarch.optionalOutputObjectData(["rank", "plant_height"])
2154 >>> plantarch.optionalOutputObjectData("all")
2156 if isinstance(object_data_labels, str):
2157 labels = [object_data_labels]
2159 labels = list(object_data_labels)
2164 for label
in labels:
2165 plantarch_wrapper.optionalOutputObjectData(self.
_plantarch_ptr, label)
2168 except Exception
as e:
2174 time_to_dormancy_break: float,
2175 time_to_flower_initiation: float,
2176 time_to_flower_opening: float,
2177 time_to_fruit_set: float,
2178 time_to_fruit_maturity: float,
2179 time_to_dormancy: float,
2180 max_leaf_lifespan: float = 1e6,
2181 is_evergreen: bool =
False
2184 Set phenological timing thresholds for plant developmental stages.
2186 Controls the timing of key phenological events based on thermal time
2187 or calendar time depending on the plant model.
2190 plant_id: ID of the plant instance
2191 time_to_dormancy_break: Degree-days or days until dormancy ends
2192 time_to_flower_initiation: Time until flower buds are initiated
2193 time_to_flower_opening: Time until flowers open
2194 time_to_fruit_set: Time until fruit begins developing
2195 time_to_fruit_maturity: Time until fruit reaches maturity
2196 time_to_dormancy: Time until plant enters dormancy
2197 max_leaf_lifespan: Maximum leaf lifespan in days (default: 1e6)
2198 is_evergreen: If True, the plant retains leaves through dormancy
2199 instead of shedding them at senescence (default: False)
2202 ValueError: If plant_id is negative
2203 PlantArchitectureError: If phenology setting fails
2206 >>> # Set phenology for perennial fruit tree
2207 >>> plantarch.setPlantPhenologicalThresholds(
2208 ... plant_id=plant_id,
2209 ... time_to_dormancy_break=60, # Spring: 60 degree-days
2210 ... time_to_flower_initiation=90, # Early spring flowering
2211 ... time_to_flower_opening=105, # Bloom period
2212 ... time_to_fruit_set=120, # Fruit set after pollination
2213 ... time_to_fruit_maturity=200, # Summer fruit maturation
2214 ... time_to_dormancy=280, # Fall dormancy
2215 ... max_leaf_lifespan=180 # Deciduous - 6 month leaf life
2219 raise ValueError(
"Plant ID must be non-negative")
2224 plantarch_wrapper.setPlantPhenologicalThresholds(
2227 time_to_dormancy_break,
2228 time_to_flower_initiation,
2229 time_to_flower_opening,
2231 time_to_fruit_maturity,
2236 except Exception
as e:
2241 Disable phenological progression for a plant.
2243 The plant continues to grow, but no phenological stage is ever scheduled: it does not
2244 enter dormancy, and flower and fruit stages are skipped. This is the explicit form of the
2245 state a plant is already in when :meth:`setPlantPhenologicalThresholds` has never been
2246 called on it, so it is mainly useful for turning phenology back off on a plant that had
2247 thresholds set earlier.
2250 plant_id: Identifier of the plant whose phenology is to be disabled
2253 Avoid calling this on a plant that already has fruiting buds: a later
2254 :meth:`advanceTime` can compute a negative fruit scale factor. A plant that never had
2255 thresholds set is already in the no-phenology state and does not need it.
2258 ValueError: If plant_id is negative
2259 PlantArchitectureError: If disabling phenology fails
2262 >>> plantarch.setPlantPhenologicalThresholds(plant_id, 60, 90, 105, 120, 200, 280)
2263 >>> plantarch.disablePlantPhenology(plant_id) # growth only, no dormancy or fruiting
2266 raise ValueError(
"Plant ID must be non-negative")
2271 plantarch_wrapper.disablePlantPhenology(self.
_plantarch_ptr, plant_id)
2272 except Exception
as e:
2278 Force a plant into a dormant state immediately.
2280 It is the counterpart to scheduling dormancy
2281 through :meth:`setPlantPhenologicalThresholds`: this forces the state now, rather than
2282 waiting for a degree-day threshold to be crossed.
2284 Dormancy strips the plant's leaves and marks its non-dormant buds dormant, so a
2285 custom-built plant can be put into the same over-winter state that a library-built
2286 perennial reaches through phenology.
2289 plant_id: Identifier of the plant to make dormant
2292 ValueError: If plant_id is negative
2293 PlantArchitectureError: If the plant does not exist or the call fails
2296 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
2297 >>> plantarch.addBaseStemShoot(plant_id, 3, AxisRotation(0, 0, 0),
2298 ... 0.01, 0.1, 1.0, 1.0, 0.9, "trifoliate")
2299 >>> plantarch.makePlantDormant(plant_id)
2302 raise ValueError(
"Plant ID must be non-negative")
2307 plantarch_wrapper.makePlantDormant(self.
_plantarch_ptr, plant_id)
2308 except Exception
as e:
2313 Break dormancy for all shoots on a plant, returning it to an active state.
2315 This is the counterpart to :meth:`makePlantDormant`. Note that it only revives buds that
2316 are not dead, so a plant that was repeatedly made dormant may not recover every bud.
2319 plant_id: Identifier of the plant whose dormancy should be broken
2322 ValueError: If plant_id is negative
2323 PlantArchitectureError: If the plant does not exist or the call fails
2326 >>> plantarch.makePlantDormant(plant_id)
2327 >>> plantarch.breakPlantDormancy(plant_id) # resume growth in spring
2330 raise ValueError(
"Plant ID must be non-negative")
2335 plantarch_wrapper.breakPlantDormancy(self.
_plantarch_ptr, plant_id)
2336 except Exception
as e:
2341 Check whether a plant is dormant.
2344 plant_id: Identifier of the plant to check
2347 True if all shoots on the plant are dormant, False otherwise
2350 ValueError: If plant_id is negative
2351 PlantArchitectureError: If the plant does not exist or the query fails
2354 >>> plantarch.makePlantDormant(plant_id)
2355 >>> plantarch.isPlantDormant(plant_id)
2359 raise ValueError(
"Plant ID must be non-negative")
2364 return plantarch_wrapper.isPlantDormant(self.
_plantarch_ptr, plant_id)
2365 except Exception
as e:
2369 def pruneBranch(self, plant_id: int, shoot_id: int, node_index: int) ->
None:
2371 Prune a shoot at a node, removing that node and everything distal to it.
2373 The phytomer at ``node_index`` is deleted along with every phytomer above it
2374 on the same shoot, and the cut recurses into every child shoot attached at or
2375 above that node. The shoot's woody internode tube is trimmed back to the cut
2376 and its apical bud is terminated, so the pruned axis will not resume growing.
2377 Pruning at ``node_index=0`` therefore removes the entire shoot and its whole
2381 plant_id: ID of the plant instance
2382 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2383 node_index: Node on the shoot to cut at, in ``[0, node_count)``
2386 ValueError: If any identifier is negative
2387 PlantArchitectureError: If the plant or shoot does not exist, if
2388 ``node_index`` is beyond the shoot's current node count, or if the
2392 A pruned shoot currently keeps its ID in :meth:`getAllShootIDs` with a
2393 ``node_count`` of 0 rather than disappearing. Do not rely on either
2394 behavior; traverse with :meth:`getShoot` and treat ``node_count == 0``
2395 as "nothing left here".
2397 Called from a phytomer creation or callback function (see
2398 :meth:`setPhytomerCreationFunction`, :meth:`setPhytomerCallbackFunction`), the
2399 arguments are checked immediately but the cut is deferred: it is applied at the end
2400 of the current time step of :meth:`advanceTime`, or before the build call that ran
2401 the function returns. Until then the shoot still looks uncut. Deferred cuts are
2402 applied in the order requested, and one whose shoot or node an earlier cut already
2403 removed does nothing.
2406 >>> # Remove a whole branch and everything growing off it
2407 >>> plantarch.pruneBranch(plant_id, shoot_id=3, node_index=0)
2408 >>> # Head back a leader, keeping its lowest 5 nodes
2409 >>> plantarch.pruneBranch(plant_id, shoot_id=0, node_index=5)
2411 if plant_id < 0
or shoot_id < 0
or node_index < 0:
2412 raise ValueError(
"Plant ID, shoot ID and node index must be non-negative")
2416 plantarch_wrapper.pruneBranch(self.
_plantarch_ptr, plant_id, shoot_id, node_index)
2417 except Exception
as e:
2419 f
"Failed to prune shoot {shoot_id} of plant {plant_id} at node {node_index}: {e}")
2423 Harvest a plant by removing its flowers and fruit.
2425 Every non-dormant floral bud on the plant is killed, which deletes the
2426 associated flower, fruit and peduncle geometry from the Context. Vegetative
2427 structure is untouched and the plant continues to grow afterwards.
2430 plant_id: ID of the plant instance to harvest
2433 ValueError: If plant_id is negative
2434 PlantArchitectureError: If the plant does not exist or the call fails
2437 Leaves are **not** removed. Use :meth:`removePlantLeaves` to defoliate.
2440 >>> before = len(plantarch.getPlantFruitObjectIDs(plant_id))
2441 >>> plantarch.harvestPlant(plant_id)
2442 >>> len(plantarch.getPlantFruitObjectIDs(plant_id)) < before
2446 raise ValueError(
"Plant ID must be non-negative")
2451 except Exception
as e:
2456 Remove all leaves from every shoot on a plant.
2458 Leaf and petiole geometry is deleted from the Context. Buds are left alive,
2459 so the plant can produce new leaves as it continues to grow.
2462 plant_id: ID of the plant instance to defoliate
2465 ValueError: If plant_id is negative
2466 PlantArchitectureError: If the plant does not exist or the call fails
2469 >>> plantarch.removePlantLeaves(plant_id)
2470 >>> plantarch.getPlantLeafObjectIDs(plant_id)
2474 raise ValueError(
"Plant ID must be non-negative")
2478 plantarch_wrapper.removePlantLeaves(self.
_plantarch_ptr, plant_id)
2479 except Exception
as e:
2484 Remove all leaves from a single shoot.
2487 plant_id: ID of the plant instance
2488 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2491 ValueError: If either identifier is negative
2492 PlantArchitectureError: If the plant or shoot does not exist
2495 >>> # Strip the leaves off a grapevine trunk, as in a trained architecture
2496 >>> plantarch.removeShootLeaves(plant_id, shoot_id=0)
2502 Mark every vegetative bud on a single shoot as dead.
2504 Despite the name, nothing is removed: each axillary vegetative bud on the shoot
2505 is set to ``BudState.DEAD`` and the bud entries themselves stay in place, so
2506 :meth:`getShootVegetativeBudCount` still sees them and the unfiltered count is
2507 unchanged. Dead buds are skipped when dormancy breaks, so the shoot keeps its
2508 existing structure but produces no new lateral shoots -- the standard way to stop
2509 a trained axis from throwing new canes, and to stop the old wood of a
2510 reconstructed tree re-growing.
2512 This is exactly equivalent to setting every bud on the shoot to
2513 ``BudState.DEAD``; the shoot's own apex is unaffected, so pair it with
2514 :meth:`terminateApicalBud` to stop the shoot extending as well.
2517 plant_id: ID of the plant instance
2518 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2521 ValueError: If either identifier is negative
2522 PlantArchitectureError: If the plant or shoot does not exist
2525 :meth:`getShootVegetativeBudCount`, to confirm the buds are dead rather than
2526 gone, and :meth:`terminateApicalBud`, for the shoot's apex.
2529 >>> plantarch.removeShootVegetativeBuds(plant_id, shoot_id=1)
2536 Kill all floral buds on a single shoot.
2538 Existing flower, fruit and peduncle geometry on the shoot is deleted and no
2539 new flowers will form there.
2542 plant_id: ID of the plant instance
2543 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2546 ValueError: If either identifier is negative
2547 PlantArchitectureError: If the plant or shoot does not exist
2550 >>> plantarch.removeShootFloralBuds(plant_id, shoot_id=1)
2555 organ_description: str) ->
None:
2556 """Shared body for the three shoot-level organ removal methods."""
2557 if plant_id < 0
or shoot_id < 0:
2558 raise ValueError(
"Plant ID and shoot ID must be non-negative")
2562 getattr(plantarch_wrapper, wrapper_fn_name)(self.
_plantarch_ptr, plant_id, shoot_id)
2563 except Exception
as e:
2565 f
"Failed to remove {organ_description} from shoot {shoot_id} "
2566 f
"of plant {plant_id}: {e}")
2571 Group a plant's shoot IDs by branching rank.
2573 Rank 0 is the base stem, rank 1 its direct branches, and so on. Shoots that have
2574 been pruned away are not included.
2577 plant_id: ID of the plant instance
2580 Dict mapping rank to the list of shoot IDs at that rank. Ranks with no live
2581 shoots are omitted from the dict.
2584 ValueError: If plant_id is negative
2585 PlantArchitectureError: If the plant does not exist
2588 >>> by_rank = plantarch.getShootIDsByRank(plant_id)
2589 >>> print(f"{len(by_rank.get(1, []))} primary branches")
2592 raise ValueError(
"Plant ID must be non-negative")
2595 groups = plantarch_wrapper.getShootIDsByRank(self.
_plantarch_ptr, plant_id)
2596 except Exception
as e:
2598 f
"Failed to get shoot IDs by rank for plant {plant_id}: {e}")
2602 return {rank: shoot_ids
for rank, shoot_ids
in enumerate(groups)
if shoot_ids}
2606 Get the plant's terminal shoots -- those carrying no child shoots.
2608 These are the tips of the shoot tree. Note that this is a topological test rather
2609 than a botanical one: a shoot whose axis is continued by :meth:`appendShoot` has
2610 that continuation as a child and so is not terminal. Pruned shoots are omitted.
2613 plant_id: ID of the plant instance
2616 List of terminal shoot IDs.
2619 ValueError: If plant_id is negative
2620 PlantArchitectureError: If the plant does not exist
2623 >>> tips = plantarch.getTerminalShootIDs(plant_id)
2626 raise ValueError(
"Plant ID must be non-negative")
2629 return plantarch_wrapper.getTerminalShootIDs(self.
_plantarch_ptr, plant_id)
2630 except Exception
as e:
2632 f
"Failed to get terminal shoots for plant {plant_id}: {e}")
2637 Prune every shoot at or above a given branching rank.
2639 This is the "remove higher-order branches" thinning operation: passing
2640 ``min_rank=3`` leaves the base stem and its first two orders of branching
2641 intact and cuts everything finer. Because :meth:`pruneBranch` already
2642 recurses into child shoots, only the shallowest shoot on each pruned axis is
2643 cut and the rest follow.
2646 plant_id: ID of the plant instance
2647 min_rank: Lowest rank to prune. Must be at least 1 -- rank 0 is the base
2648 stem, and pruning it would destroy the plant.
2651 Ascending list of the shoot IDs actually cut. Shoots removed as a side
2652 effect of a shallower cut are not listed.
2655 ValueError: If plant_id is negative or min_rank is less than 1
2656 PlantArchitectureError: If the plant does not exist
2659 To remove a whole plant use :meth:`deletePlantInstance`; to cut the base
2660 stem itself call :meth:`pruneBranch` directly.
2663 >>> pruned = plantarch.pruneShootsByRank(plant_id, min_rank=3)
2664 >>> print(f"Cut {len(pruned)} higher-order branches")
2667 raise ValueError(
"Plant ID must be non-negative")
2670 f
"min_rank must be at least 1, got {min_rank}. Rank 0 is the base stem; "
2671 "use deletePlantInstance() to remove the whole plant, or pruneBranch() "
2672 "to cut the base stem explicitly.")
2676 for rank, shoot_ids
in by_rank.items()
if rank >= min_rank
2677 for shoot_id
in shoot_ids}
2681 include_self: bool =
True) -> List[int]:
2683 Prune a shoot and everything growing off it.
2686 plant_id: ID of the plant instance
2687 shoot_id: Root of the branch system to remove
2688 include_self: If True (default) the shoot itself is cut at node 0. If
2689 False the shoot is kept and only its child shoots are cut.
2692 Ascending list of the shoot IDs actually cut. Shoots removed as a side
2693 effect of a shallower cut are not listed.
2696 ValueError: If either identifier is negative
2697 PlantArchitectureError: If the plant or shoot does not exist
2700 >>> # Remove a whole branch system
2701 >>> plantarch.pruneShootSubtree(plant_id, shoot_id=2)
2702 >>> # Keep the cane but strip everything growing off it
2703 >>> plantarch.pruneShootSubtree(plant_id, shoot_id=2, include_self=False)
2705 if plant_id < 0
or shoot_id < 0:
2706 raise ValueError(
"Plant ID and shoot ID must be non-negative")
2714 Thin a plant by pruning every *stride*-th terminal shoot.
2716 Terminal shoots are taken in ascending ID order and every ``stride``-th one
2717 starting from the first is cut, so ``stride=2`` removes about half the tips
2718 and ``stride=3`` about a third. The base stem is never cut.
2721 plant_id: ID of the plant instance
2722 stride: Spacing between pruned tips. Must be at least 1; ``stride=1``
2723 prunes every terminal shoot.
2726 Ascending list of the shoot IDs actually cut.
2729 ValueError: If plant_id is negative or stride is less than 1
2730 PlantArchitectureError: If the plant does not exist
2733 >>> pruned = plantarch.pruneTerminalShoots(plant_id, stride=2)
2734 >>> print(f"Thinned {len(pruned)} tips")
2737 raise ValueError(
"Plant ID must be non-negative")
2739 raise ValueError(f
"stride must be at least 1, got {stride}")
2743 if index % stride != 0:
2747 targets.add(shoot_id)
2751 """Return a shoot's child IDs, or an empty list if it no longer resolves."""
2754 except PlantArchitectureError:
2758 """Child shoot IDs that have not been pruned away, ascending."""
2763 """Prune every target that something shallower has not already removed.
2765 pruneBranch() recurses into child shoots, so cutting a shoot also empties
2766 every shoot descended from it. Targets are therefore visited in ascending
2767 shoot ID order -- a child shoot is always created after its parent and so
2768 always has the higher ID -- which puts each shoot after its ancestors. By
2769 the time a descendant of an already-cut shoot comes up it has nothing left
2770 on it and is skipped, so no shoot is cut twice and the returned list holds
2771 only the cuts that actually did something.
2774 for shoot_id
in sorted(set(target_shoot_ids)):
2778 pruned.append(shoot_id)
2782 """Whether a shoot has been pruned away or no longer resolves at all."""
2785 except PlantArchitectureError:
2790 target_object_UUIDs: Optional[List[int]] =
None,
2791 target_object_IDs: Optional[List[int]] =
None,
2792 enable_petiole_collision: bool =
False,
2793 enable_fruit_collision: bool =
False) ->
None:
2795 Enable soft collision avoidance for procedural plant growth.
2797 This method enables the collision detection system that guides plant growth away from
2798 obstacles and other plants. The system uses cone-based gap detection to find optimal
2799 growth directions that minimize collisions while maintaining natural plant architecture.
2802 target_object_UUIDs: List of primitive UUIDs to avoid collisions with. If empty,
2803 avoids all geometry in the context.
2804 target_object_IDs: List of compound object IDs to avoid collisions with.
2805 enable_petiole_collision: Enable collision detection for leaf petioles
2806 enable_fruit_collision: Enable collision detection for fruit organs
2809 PlantArchitectureError: If collision detection activation fails
2812 Collision detection adds computational overhead. Use setStaticObstacles() to mark
2813 static geometry for BVH optimization and improved performance.
2816 >>> # Avoid all geometry
2817 >>> plantarch.enableSoftCollisionAvoidance()
2819 >>> # Avoid specific obstacles
2820 >>> obstacle_uuids = context.getAllUUIDs()
2821 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
2823 >>> # Enable collision detection for petioles and fruit
2824 >>> plantarch.enableSoftCollisionAvoidance(
2825 ... enable_petiole_collision=True,
2826 ... enable_fruit_collision=True
2832 plantarch_wrapper.enableSoftCollisionAvoidance(
2834 target_UUIDs=target_object_UUIDs,
2835 target_IDs=target_object_IDs,
2836 enable_petiole=enable_petiole_collision,
2837 enable_fruit=enable_fruit_collision
2839 except Exception
as e:
2844 Enable automatic removal of plant organs that fall below the ground plane.
2846 Organ vertices below `ground_height` are clipped as plant geometry is
2847 built, which prevents drooping leaves and low branches from poking
2848 through a ground tile.
2851 ground_height: Height of the ground plane (default 0.0)
2854 ValueError: If ground_height is not a number
2855 PlantArchitectureError: If the call fails
2858 >>> plantarch.enableGroundClipping(0.0)
2859 >>> plantarch.advanceTime(30.0)
2863 if isinstance(ground_height, bool)
or not isinstance(ground_height, (int, float)):
2864 raise ValueError(f
"Ground height must be a number, got {type(ground_height).__name__}")
2867 plantarch_wrapper.enableGroundClipping(self.
_plantarch_ptr, float(ground_height))
2868 except Exception
as e:
2873 Suppress standard output from the plantarchitecture plugin.
2875 This silences progress bars and informational messages the C++ plugin
2876 writes to stdout, including the "BVH not cached" warning emitted during
2877 the first growth steps of a collision-enabled canopy (before any plant
2878 geometry exists for the BVH to contain).
2881 PlantArchitectureError: If the call fails
2884 >>> plantarch.disableMessages()
2885 >>> plantarch.advanceTime(30.0) # runs quietly
2890 except Exception
as e:
2895 Re-enable standard output from the plantarchitecture plugin.
2898 PlantArchitectureError: If the call fails
2901 >>> plantarch.enableMessages()
2906 except Exception
as e:
2911 Disable collision detection for plant growth.
2913 This method turns off the collision detection system, allowing plants to grow
2914 without checking for obstacles. This improves performance but plants may grow
2915 through obstacles and other geometry.
2918 PlantArchitectureError: If disabling fails
2921 >>> plantarch.disableCollisionDetection()
2926 except Exception
as e:
2930 view_half_angle_deg: float = 80.0,
2931 look_ahead_distance: float = 0.1,
2932 sample_count: int = 256,
2933 inertia_weight: float = 0.4) ->
None:
2935 Configure parameters for soft collision avoidance algorithm.
2937 These parameters control the cone-based gap detection algorithm that guides
2938 plant growth away from obstacles. Adjusting these values allows fine-tuning
2939 the balance between collision avoidance and natural growth patterns.
2942 view_half_angle_deg: Half-angle of detection cone in degrees (0-180).
2943 Default 80° provides wide field of view.
2944 look_ahead_distance: Distance to look ahead for collisions in meters.
2945 Larger values detect distant obstacles. Default 0.1m.
2946 sample_count: Number of ray samples within cone. More samples improve
2947 accuracy but reduce performance. Default 256.
2948 inertia_weight: Weight for previous growth direction (0-1). Higher values
2949 make growth smoother but less responsive. Default 0.4.
2952 ValueError: If parameters are outside valid ranges
2953 PlantArchitectureError: If parameter setting fails
2956 >>> # Use default parameters (recommended)
2957 >>> plantarch.setSoftCollisionAvoidanceParameters()
2959 >>> # Tune for dense canopy with close obstacles
2960 >>> plantarch.setSoftCollisionAvoidanceParameters(
2961 ... view_half_angle_deg=60.0, # Narrower detection cone
2962 ... look_ahead_distance=0.05, # Shorter look-ahead
2963 ... sample_count=512, # More accurate detection
2964 ... inertia_weight=0.3 # More responsive to obstacles
2968 if not (0 <= view_half_angle_deg <= 180):
2969 raise ValueError(f
"view_half_angle_deg must be between 0 and 180, got {view_half_angle_deg}")
2970 if look_ahead_distance <= 0:
2971 raise ValueError(f
"look_ahead_distance must be positive, got {look_ahead_distance}")
2972 if sample_count <= 0:
2973 raise ValueError(f
"sample_count must be positive, got {sample_count}")
2974 if not (0 <= inertia_weight <= 1):
2975 raise ValueError(f
"inertia_weight must be between 0 and 1, got {inertia_weight}")
2979 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
2981 view_half_angle_deg,
2982 look_ahead_distance,
2986 except Exception
as e:
2990 include_internodes: bool =
False,
2991 include_leaves: bool =
True,
2992 include_petioles: bool =
False,
2993 include_flowers: bool =
False,
2994 include_fruit: bool =
False) ->
None:
2996 Specify which plant organs participate in collision detection.
2998 This method allows filtering which organs are considered during collision detection,
2999 enabling optimization by excluding organs unlikely to cause problematic collisions.
3002 include_internodes: Include stem internodes in collision detection
3003 include_leaves: Include leaf blades in collision detection
3004 include_petioles: Include leaf petioles in collision detection
3005 include_flowers: Include flowers in collision detection
3006 include_fruit: Include fruit in collision detection
3009 PlantArchitectureError: If organ filtering fails
3012 >>> # Only detect collisions for stems and leaves (default behavior)
3013 >>> plantarch.setCollisionRelevantOrgans(
3014 ... include_internodes=True,
3015 ... include_leaves=True
3018 >>> # Include all organs
3019 >>> plantarch.setCollisionRelevantOrgans(
3020 ... include_internodes=True,
3021 ... include_leaves=True,
3022 ... include_petioles=True,
3023 ... include_flowers=True,
3024 ... include_fruit=True
3029 plantarch_wrapper.setCollisionRelevantOrgans(
3037 except Exception
as e:
3041 obstacle_UUIDs: List[int],
3042 avoidance_distance: float = 0.5,
3043 enable_fruit_adjustment: bool =
False,
3044 enable_obstacle_pruning: bool =
False) ->
None:
3046 Enable hard obstacle avoidance for specified geometry.
3048 This method configures solid obstacles that plants cannot grow through. Unlike soft
3049 collision avoidance (which guides growth), solid obstacles cause complete growth
3050 termination when encountered within the avoidance distance.
3053 obstacle_UUIDs: List of primitive UUIDs representing solid obstacles
3054 avoidance_distance: Minimum distance to maintain from obstacles (meters).
3055 Growth stops if obstacles are closer. Default 0.5m.
3056 enable_fruit_adjustment: Adjust fruit positions away from obstacles
3057 enable_obstacle_pruning: Remove plant organs that penetrate obstacles
3060 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
3061 PlantArchitectureError: If solid obstacle configuration fails
3064 >>> # Simple solid obstacle avoidance
3065 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
3066 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
3068 >>> # Close avoidance with fruit adjustment
3069 >>> plantarch.enableSolidObstacleAvoidance(
3070 ... obstacle_UUIDs=wall_uuids,
3071 ... avoidance_distance=0.1,
3072 ... enable_fruit_adjustment=True
3075 if not obstacle_UUIDs:
3076 raise ValueError(
"Obstacle UUIDs list cannot be empty")
3077 if avoidance_distance <= 0:
3078 raise ValueError(f
"avoidance_distance must be positive, got {avoidance_distance}")
3083 plantarch_wrapper.enableSolidObstacleAvoidance(
3087 enable_fruit_adjustment,
3088 enable_obstacle_pruning
3090 except Exception
as e:
3095 Mark geometry as static obstacles for collision detection optimization.
3097 This method tells the collision detection system that certain geometry will not
3098 move during the simulation. The system can then build an optimized Bounding Volume
3099 Hierarchy (BVH) for these obstacles, significantly improving collision detection
3100 performance in scenes with many static obstacles.
3103 target_UUIDs: List of primitive UUIDs representing static obstacles
3106 ValueError: If target_UUIDs is empty
3107 PlantArchitectureError: If static obstacle configuration fails
3110 Collision avoidance must be enabled BEFORE calling this method -- the
3111 native call raises "Collision detection must be enabled before setting
3112 static obstacles" otherwise.
3113 Static obstacles cannot be modified or moved after being marked static.
3116 >>> # Enable collision avoidance first
3117 >>> plantarch.enableSoftCollisionAvoidance()
3118 >>> # Then mark ground and building geometry as static
3119 >>> static_uuids = ground_uuids + building_uuids
3120 >>> plantarch.setStaticObstacles(static_uuids)
3122 if not target_UUIDs:
3123 raise ValueError(
"target_UUIDs list cannot be empty")
3128 plantarch_wrapper.setStaticObstacles(self.
_plantarch_ptr, target_UUIDs)
3129 except Exception
as e:
3134 Get object IDs of collision-relevant geometry for a specific plant.
3136 This method returns the subset of plant geometry that participates in collision
3137 detection, as filtered by setCollisionRelevantOrgans(). Useful for visualization
3138 and debugging collision detection behavior.
3141 plant_id: ID of the plant instance
3144 List of object IDs for collision-relevant plant geometry
3147 ValueError: If plant_id is negative
3148 PlantArchitectureError: If retrieval fails
3151 >>> # Get collision-relevant geometry
3152 >>> collision_obj_ids = plantarch.getPlantCollisionRelevantObjectIDs(plant_id)
3153 >>> print(f"Plant has {len(collision_obj_ids)} collision-relevant objects")
3155 >>> # Highlight collision geometry in visualization
3156 >>> for obj_id in collision_obj_ids:
3157 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
3160 raise ValueError(
"Plant ID must be non-negative")
3164 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self.
_plantarch_ptr, plant_id)
3165 except Exception
as e:
3171 Write all plant mesh vertices to file for external processing.
3173 This method exports all vertex coordinates (x,y,z) for every primitive in the plant,
3174 writing one vertex per line. Useful for external processing such as computing bounding
3175 volumes, convex hulls, or performing custom geometric analysis.
3178 plant_id: ID of the plant instance to export
3179 filename: Path to output file (absolute or relative to current working directory)
3182 ValueError: If plant_id is negative or filename is empty
3183 PlantArchitectureError: If plant doesn't exist or file cannot be written
3186 >>> # Export vertices for convex hull analysis
3187 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
3189 >>> # Use with Path object
3190 >>> from pathlib import Path
3191 >>> output_dir = Path("output")
3192 >>> output_dir.mkdir(exist_ok=True)
3193 >>> plantarch.writePlantMeshVertices(plant_id, output_dir / "vertices.txt")
3196 raise ValueError(
"Plant ID must be non-negative")
3198 raise ValueError(
"Filename cannot be empty")
3206 plantarch_wrapper.writePlantMeshVertices(
3209 except Exception
as e:
3214 Save plant structure to XML file for later loading.
3216 This method exports the complete plant architecture to an XML file, including
3217 all shoots, phytomers, organs, and their properties. The saved plant can be
3218 reloaded later using readPlantStructureXML().
3221 plant_id: ID of the plant instance to save
3222 filename: Path to output XML file (absolute or relative to current working directory)
3225 ValueError: If plant_id is negative or filename is empty
3226 PlantArchitectureError: If plant doesn't exist or file cannot be written
3229 The XML format preserves the complete plant state including:
3230 - Shoot structure and hierarchy
3231 - Phytomer properties and development stage
3232 - Organ geometry and attributes
3233 - Growth parameters and phenological state
3236 >>> # Save plant at current growth stage
3237 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
3239 >>> # Later, reload the saved plant
3240 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
3241 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
3244 raise ValueError(
"Plant ID must be non-negative")
3246 raise ValueError(
"Filename cannot be empty")
3254 plantarch_wrapper.writePlantStructureXML(
3257 except Exception
as e:
3262 Export plant structure in TreeQSM cylinder format.
3264 This method writes the plant structure as a series of cylinders following the
3265 TreeQSM format (Raumonen et al., 2013). Each row represents one cylinder with
3266 columns for radius, length, start position, axis direction, branch topology,
3267 and other structural properties. Useful for biomechanical analysis and
3268 quantitative structure modeling.
3271 plant_id: ID of the plant instance to export
3272 filename: Path to output file (absolute or relative, typically .txt extension)
3275 ValueError: If plant_id is negative or filename is empty
3276 PlantArchitectureError: If plant doesn't exist or file cannot be written
3279 The TreeQSM format includes columns for:
3280 - Cylinder dimensions (radius, length)
3281 - Spatial position and orientation
3282 - Branch topology (parent ID, extension ID, branch ID)
3283 - Branch hierarchy (branch order, position in branch)
3284 - Quality metrics (mean absolute distance, surface coverage)
3287 >>> # Export for biomechanical analysis
3288 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
3290 >>> # Use with external QSM tools
3291 >>> import pandas as pd
3292 >>> qsm_data = pd.read_csv("tree_structure_qsm.txt", sep="\\t")
3293 >>> print(f"Tree has {len(qsm_data)} cylinders")
3296 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
3297 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
3300 raise ValueError(
"Plant ID must be non-negative")
3302 raise ValueError(
"Filename cannot be empty")
3310 plantarch_wrapper.writeQSMCylinderFile(
3313 except Exception
as e:
3317 elastic_modulus: float = 5e9,
3318 wood_density: float = 800.0,
3320 damping_time_constant: float = 0.02,
3321 static_friction: float = 0.5,
3322 dynamic_friction: float = 0.3,
3323 restitution: float = 0.1,
3324 organ_spring_stiffness: float = 10.0,
3325 organ_spring_damping: float = 1.0,
3326 leaf_mass_per_area: float = 0.05,
3327 fruit_mass: float = 0.01,
3328 flower_mass: float = 0.002,
3329 solver_position_iterations: int = 32,
3330 min_segment_length: float = 0.001,
3331 armature_stability_ratio: float = 8.0,
3332 physics_steps_per_second: float = 60.0,
3333 damping_ratio: Optional[float] =
None) ->
None:
3335 Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
3337 Each tube segment becomes a capsule-shaped rigid link connected to its parent by a joint
3338 that locks translation, limits bending to +/-60 degrees and carries a spring on each
3339 rotation axis, with stiffness derived from beam bending stiffness (E*I/L). Leaves, fruits,
3340 and flowers are represented as mass bodies attached by spring links.
3342 Beam stiffness on a short, light segment is stiffer than a physics solver can integrate at
3343 a typical step rate, so each joint is given armature (an artificial inertia) that caps its
3344 natural frequency at armature_stability_ratio * physics_steps_per_second. Armature adds no
3345 weight, so deflection under gravity is unchanged. If the simulation runs at a coarser step
3346 than physics_steps_per_second, set that parameter to match or the plant may be unstable.
3349 plant_id: ID of the plant instance to export
3350 filename: Output file path (should have .usda extension)
3351 elastic_modulus: Young's modulus (Pa) for joint stiffness, K = E*I/L
3352 wood_density: Wood density (kg/m^3) used to compute mass from capsule volume
3353 damping_time_constant: Damping time constant (s) of the joints between stem, petiole
3354 and peduncle segments; joint damping is this value times the joint stiffness.
3355 Must be non-negative.
3356 static_friction: Static friction coefficient for collision material
3357 dynamic_friction: Dynamic friction coefficient for collision material
3358 restitution: Restitution (bounciness) for collision material
3359 organ_spring_stiffness: Spring stiffness (N*m/rad) for organ attachment joints
3360 organ_spring_damping: Damping (N*m*s/rad) for organ attachment joints
3361 leaf_mass_per_area: Leaf mass per unit area (kg/m^2)
3362 fruit_mass: Mass per fruit (kg)
3363 flower_mass: Mass per flower (kg)
3364 solver_position_iterations: PhysX articulation solver position iteration count
3365 min_segment_length: Minimum segment length (m); shorter segments are skipped
3366 armature_stability_ratio: Cap on each joint's own natural frequency, as a multiple of
3367 the physics step rate. Lower is more stable. Must be positive.
3368 physics_steps_per_second: Physics step rate (Hz) the simulation is expected to run at,
3369 used to size the joint armature. Must be positive.
3370 damping_ratio: Removed; raises ValueError if given. See the note below.
3373 ValueError: If plant_id is negative, filename is empty, a damping or armature
3374 parameter is out of range, or the removed damping_ratio parameter is given
3375 PlantArchitectureError: If plant doesn't exist or file cannot be written
3378 ``damping_ratio`` was removed in helios-core 1.3.90, where joint damping became
3379 proportional to stiffness. Use ``damping_time_constant`` (seconds) instead; the two
3380 are not interchangeable.
3383 >>> plantarch.writePlantStructureUSD(plant_id, "plant.usda")
3385 if damping_ratio
is not None:
3387 "writePlantStructureUSD() no longer accepts damping_ratio: joint damping is now "
3388 "proportional to stiffness and is set with damping_time_constant (seconds, default 0.02). "
3389 "The two are not interchangeable, so the old value cannot be converted."
3392 raise ValueError(
"Plant ID must be non-negative")
3394 raise ValueError(
"Filename cannot be empty")
3395 if damping_time_constant < 0:
3396 raise ValueError(f
"damping_time_constant must be non-negative, got {damping_time_constant}")
3397 if armature_stability_ratio <= 0:
3398 raise ValueError(f
"armature_stability_ratio must be positive, got {armature_stability_ratio}")
3399 if physics_steps_per_second <= 0:
3400 raise ValueError(f
"physics_steps_per_second must be positive, got {physics_steps_per_second}")
3407 plantarch_wrapper.writePlantStructureUSD(
3409 elastic_modulus=elastic_modulus, wood_density=wood_density,
3410 damping_time_constant=damping_time_constant,
3411 armature_stability_ratio=armature_stability_ratio,
3412 physics_steps_per_second=physics_steps_per_second,
3413 static_friction=static_friction, dynamic_friction=dynamic_friction,
3414 restitution=restitution,
3415 organ_spring_stiffness=organ_spring_stiffness,
3416 organ_spring_damping=organ_spring_damping,
3417 leaf_mass_per_area=leaf_mass_per_area, fruit_mass=fruit_mass,
3418 flower_mass=flower_mass,
3419 solver_position_iterations=solver_position_iterations,
3420 min_segment_length=min_segment_length
3422 except Exception
as e:
3427 Capture a snapshot of the plant's geometry as a growth animation frame.
3429 Call this after each :meth:`advanceTime` step to record the plant state for later
3430 animation export via :meth:`writePlantGrowthUSD`.
3433 plant_id: ID of the plant instance to capture
3434 min_segment_length: Minimum segment length (m); shorter segments are skipped
3437 ValueError: If plant_id is negative
3438 PlantArchitectureError: If plant doesn't exist
3441 raise ValueError(
"Plant ID must be non-negative")
3445 plantarch_wrapper.registerGrowthFrame(self.
_plantarch_ptr, plant_id, min_segment_length)
3446 except Exception
as e:
3450 seconds_per_frame: float = 1.0) ->
None:
3452 Export all registered growth frames as a time-sampled USD animation file.
3454 The resulting file can be imported directly into Blender. This is a visual-only
3455 export — no physics prims, joints, or collision shapes are written.
3458 plant_id: ID of the plant instance to export
3459 filename: Output file path (should have .usda extension)
3460 seconds_per_frame: Duration in seconds each growth frame occupies (default: 1.0)
3463 ValueError: If plant_id is negative or filename is empty
3464 PlantArchitectureError: If plant doesn't exist or file cannot be written
3467 raise ValueError(
"Plant ID must be non-negative")
3469 raise ValueError(
"Filename cannot be empty")
3476 plantarch_wrapper.writePlantGrowthUSD(
3479 except Exception
as e:
3484 Clear stored growth animation frames for a plant.
3487 plant_id: ID of the plant instance whose frames should be cleared
3490 ValueError: If plant_id is negative
3493 raise ValueError(
"Plant ID must be non-negative")
3497 plantarch_wrapper.clearGrowthFrames(self.
_plantarch_ptr, plant_id)
3498 except Exception
as e:
3503 Get the number of registered growth frames for a plant.
3506 plant_id: ID of the plant instance to query
3509 Number of frames registered via :meth:`registerGrowthFrame`
3512 ValueError: If plant_id is negative
3515 raise ValueError(
"Plant ID must be non-negative")
3519 return plantarch_wrapper.getGrowthFrameCount(self.
_plantarch_ptr, plant_id)
3520 except Exception
as e:
3523 @_surfaces_phytomer_callback_errors
3526 Load plant structure from XML file.
3528 This method reads plant architecture data from an XML file previously saved with
3529 writePlantStructureXML(). The loaded plants are added to the current context
3530 and can be grown, modified, or analyzed like any other plants.
3533 filename: Path to XML file to load (absolute or relative to current working directory)
3534 quiet: If True, suppress console output during loading (default: False)
3537 List of plant IDs for the loaded plant instances
3540 ValueError: If filename is empty
3541 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
3544 The XML file can contain multiple plant instances. All plants in the file
3545 will be loaded and their IDs returned in a list. Plant models referenced
3546 in the XML must be available in the plant library.
3549 >>> # Load previously saved plants
3550 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
3551 >>> print(f"Loaded {len(plant_ids)} plants")
3553 >>> # Continue growing the loaded plants
3554 >>> plantarch.advanceTime(10.0)
3556 >>> # Load quietly without console messages
3557 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
3560 raise ValueError(
"Filename cannot be empty")
3568 return plantarch_wrapper.readPlantStructureXML(
3571 except Exception
as e:
3575 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
3577 Create an empty plant instance for custom plant building.
3579 This method creates a new plant instance at the specified location without any
3580 shoots or organs. Use addBaseStemShoot(), appendShoot(), and addChildShoot() to
3581 manually construct the plant structure. This provides low-level control over
3582 plant architecture, enabling custom morphologies not available in the plant library.
3585 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
3586 current_age: Current age of the plant in days (must be >= 0)
3589 Plant ID for the created plant instance
3592 ValueError: If age is negative
3593 PlantArchitectureError: If plant creation fails
3596 >>> # Create empty plant at origin
3597 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
3599 >>> # Now add shoots to build custom plant structure
3600 >>> shoot_id = plantarch.addBaseStemShoot(
3601 ... plant_id, 1, AxisRotation(0, 0, 0), 0.01, 0.1, 1.0, 1.0, 0.8, "mainstem"
3605 if not isinstance(base_position, vec3):
3606 raise ValueError(f
"base_position must be a vec3, got {type(base_position).__name__}")
3609 position_list = [base_position.x, base_position.y, base_position.z]
3613 raise ValueError(f
"Age must be non-negative, got {current_age}")
3618 return plantarch_wrapper.addPlantInstance(
3621 except Exception
as e:
3626 Delete a plant instance and all associated geometry.
3628 This method removes a plant from the simulation, deleting all shoots, organs,
3629 and associated primitives from the context. The plant ID becomes invalid after
3630 deletion and should not be used in subsequent operations.
3633 plant_id: ID of the plant instance to delete
3636 ValueError: If plant_id is negative
3637 PlantArchitectureError: If plant deletion fails or plant doesn't exist
3640 >>> # Delete a plant
3641 >>> plantarch.deletePlantInstance(plant_id)
3643 >>> # Delete multiple plants
3644 >>> for pid in plant_ids_to_remove:
3645 ... plantarch.deletePlantInstance(pid)
3648 raise ValueError(
"Plant ID must be non-negative")
3654 plantarch_wrapper.deletePlantInstance(self.
_plantarch_ptr, plant_id)
3655 except Exception
as e:
3658 @_surfaces_phytomer_callback_errors
3661 current_node_number: int,
3662 base_rotation: AxisRotation,
3663 internode_radius: float,
3664 internode_length_max: float,
3665 internode_length_scale_factor_fraction: float,
3666 leaf_scale_factor_fraction: float,
3667 radius_taper: float,
3668 shoot_type_label: str) -> int:
3670 Add a base stem shoot to a plant instance (main trunk/stem).
3672 This method creates the primary shoot originating from the plant base. The base stem
3673 is typically the main trunk or primary stem from which all other shoots branch.
3674 Specify growth parameters to control the shoot's morphology and development.
3676 **IMPORTANT - Shoot Type Requirement**: Shoot types must be defined before use. The standard
3677 workflow is to load a plant model first using loadPlantModelFromLibrary(), which defines
3678 shoot types that can then be used for custom building. The shoot_type_label must match a
3679 shoot type defined in the loaded model.
3682 plant_id: ID of the plant instance
3683 current_node_number: Starting node number for this shoot (typically 1)
3684 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in radians (use math.radians() to convert)
3685 internode_radius: Base radius of internodes in meters (must be > 0)
3686 internode_length_max: Maximum internode length in meters (must be > 0)
3687 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
3688 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
3689 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
3690 shoot_type_label: Label identifying shoot type - must match a type from loaded model
3693 Shoot ID for the created shoot
3696 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
3697 PlantArchitectureError: If shoot creation fails or shoot type doesn't exist
3700 >>> from pyhelios.types import vec3, AxisRotation
3702 >>> # REQUIRED: Load a plant model to define shoot types
3703 >>> plantarch.loadPlantModelFromLibrary("bean")
3705 >>> # Create empty plant for custom building
3706 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
3708 >>> # Add base stem using a shoot type from the loaded model. Labels are
3709 >>> # species-specific: bean defines "unifoliate"/"trifoliate", almond
3710 >>> # defines "trunk"/"scaffold"/"proleptic"/"sylleptic". There is no
3711 >>> # generic "stem" type.
3712 >>> shoot_id = plantarch.addBaseStemShoot(
3713 ... plant_id=plant_id,
3714 ... current_node_number=1,
3715 ... base_rotation=AxisRotation(0, 0, 0), # Upright
3716 ... internode_radius=0.01, # 1cm radius
3717 ... internode_length_max=0.1, # 10cm max length
3718 ... internode_length_scale_factor_fraction=1.0,
3719 ... leaf_scale_factor_fraction=1.0,
3720 ... radius_taper=0.9, # Gradual taper
3721 ... shoot_type_label="trifoliate" # Must match loaded model
3725 raise ValueError(
"Plant ID must be non-negative")
3726 if current_node_number < 0:
3727 raise ValueError(
"Current node number must be non-negative")
3728 if internode_radius <= 0:
3729 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
3730 if internode_length_max <= 0:
3731 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
3732 if not shoot_type_label
or not shoot_type_label.strip():
3733 raise ValueError(
"Shoot type label cannot be empty")
3736 rotation_list = base_rotation.to_list()
3741 return plantarch_wrapper.addBaseStemShoot(
3742 self.
_plantarch_ptr, plant_id, current_node_number, rotation_list,
3743 internode_radius, internode_length_max,
3744 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
3745 radius_taper, shoot_type_label.strip()
3747 except Exception
as e:
3749 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
3751 f
"Shoot type '{shoot_type_label}' not defined. "
3752 f
"Load a plant model first to define shoot types:\n"
3753 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
3754 f
"Original error: {e}"
3758 @_surfaces_phytomer_callback_errors
3761 parent_shoot_id: int,
3762 current_node_number: int,
3763 base_rotation: AxisRotation,
3764 internode_radius: float,
3765 internode_length_max: float,
3766 internode_length_scale_factor_fraction: float,
3767 leaf_scale_factor_fraction: float,
3768 radius_taper: float,
3769 shoot_type_label: str) -> int:
3771 Append a shoot to the end of an existing shoot.
3773 This method extends an existing shoot by appending a new shoot at its terminal bud.
3774 Useful for creating multi-segmented shoots with varying properties along their length,
3775 such as shoots with different growth phases or developmental stages.
3777 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
3778 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
3779 calling this method.
3782 plant_id: ID of the plant instance
3783 parent_shoot_id: ID of the parent shoot to extend
3784 current_node_number: Starting node number for this shoot
3785 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in radians (use math.radians() to convert)
3786 internode_radius: Base radius of internodes in meters (must be > 0)
3787 internode_length_max: Maximum internode length in meters (must be > 0)
3788 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
3789 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
3790 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
3791 shoot_type_label: Label identifying shoot type - must match loaded model
3794 Shoot ID for the appended shoot
3797 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
3798 PlantArchitectureError: If shoot appending fails, parent doesn't exist, or shoot type not defined
3801 >>> # Load model to define shoot types
3802 >>> plantarch.loadPlantModelFromLibrary("bean")
3804 >>> # Append shoot with reduced size to simulate apical growth
3805 >>> new_shoot_id = plantarch.appendShoot(
3806 ... plant_id=plant_id,
3807 ... parent_shoot_id=base_shoot_id,
3808 ... current_node_number=10,
3809 ... base_rotation=AxisRotation(0, 0, 0),
3810 ... internode_radius=0.008, # Smaller than base
3811 ... internode_length_max=0.08, # Shorter internodes
3812 ... internode_length_scale_factor_fraction=1.0,
3813 ... leaf_scale_factor_fraction=0.8, # Smaller leaves
3814 ... radius_taper=0.85,
3815 ... shoot_type_label="trifoliate"
3819 raise ValueError(
"Plant ID must be non-negative")
3820 if parent_shoot_id < 0:
3821 raise ValueError(
"Parent shoot ID must be non-negative")
3822 if current_node_number < 0:
3823 raise ValueError(
"Current node number must be non-negative")
3824 if internode_radius <= 0:
3825 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
3826 if internode_length_max <= 0:
3827 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
3828 if not shoot_type_label
or not shoot_type_label.strip():
3829 raise ValueError(
"Shoot type label cannot be empty")
3832 rotation_list = base_rotation.to_list()
3837 return plantarch_wrapper.appendShoot(
3838 self.
_plantarch_ptr, plant_id, parent_shoot_id, current_node_number,
3839 rotation_list, internode_radius, internode_length_max,
3840 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
3841 radius_taper, shoot_type_label.strip()
3843 except Exception
as e:
3845 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
3847 f
"Shoot type '{shoot_type_label}' not defined. "
3848 f
"Load a plant model first to define shoot types:\n"
3849 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
3850 f
"Original error: {e}"
3854 @_surfaces_phytomer_callback_errors
3857 parent_shoot_id: int,
3858 parent_node_index: int,
3859 current_node_number: int,
3860 shoot_base_rotation: AxisRotation,
3861 internode_radius: float,
3862 internode_length_max: float,
3863 internode_length_scale_factor_fraction: float,
3864 leaf_scale_factor_fraction: float,
3865 radius_taper: float,
3866 shoot_type_label: str,
3867 petiole_index: int = 0) -> int:
3869 Add a child shoot at an axillary bud position on a parent shoot.
3871 This method creates a lateral branch shoot emerging from a specific node on the
3872 parent shoot. Child shoots enable creation of branching architectures, with control
3873 over branch angle, size, and which petiole position the branch emerges from (for
3874 plants with multiple petioles per node).
3876 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
3877 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
3878 calling this method.
3881 plant_id: ID of the plant instance
3882 parent_shoot_id: ID of the parent shoot
3883 parent_node_index: Index of the parent node where child emerges (0-based)
3884 current_node_number: Starting node number for this child shoot
3885 shoot_base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in radians (use math.radians() to convert)
3886 internode_radius: Base radius of child shoot internodes in meters (must be > 0)
3887 internode_length_max: Maximum internode length in meters (must be > 0)
3888 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
3889 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
3890 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
3891 shoot_type_label: Label identifying shoot type - must match loaded model
3892 petiole_index: Which petiole at the node to branch from (default: 0)
3895 Shoot ID for the created child shoot
3898 ValueError: If parameters are invalid (negative values, non-positive dimensions, empty label)
3899 PlantArchitectureError: If child shoot creation fails, parent doesn't exist, or shoot type not defined
3903 >>> # Load model to define shoot types
3904 >>> plantarch.loadPlantModelFromLibrary("bean")
3906 >>> # Add lateral branch at 45-degree angle from node 3
3907 >>> branch_id = plantarch.addChildShoot(
3908 ... plant_id=plant_id,
3909 ... parent_shoot_id=main_shoot_id,
3910 ... parent_node_index=3,
3911 ... current_node_number=1,
3912 ... shoot_base_rotation=AxisRotation(math.radians(45), math.radians(90), 0), # 45° out, 90° around
3913 ... internode_radius=0.005, # Thinner than main stem
3914 ... internode_length_max=0.06, # Shorter internodes
3915 ... internode_length_scale_factor_fraction=1.0,
3916 ... leaf_scale_factor_fraction=0.9,
3917 ... radius_taper=0.8,
3918 ... shoot_type_label="trifoliate"
3921 >>> # Add second branch from opposite petiole
3922 >>> branch_id2 = plantarch.addChildShoot(
3923 ... plant_id, main_shoot_id, 3, 1, AxisRotation(math.radians(45), math.radians(270), 0),
3924 ... 0.005, 0.06, 1.0, 0.9, 0.8, "trifoliate", petiole_index=1
3928 raise ValueError(
"Plant ID must be non-negative")
3929 if parent_shoot_id < 0:
3930 raise ValueError(
"Parent shoot ID must be non-negative")
3931 if parent_node_index < 0:
3932 raise ValueError(
"Parent node index must be non-negative")
3933 if current_node_number < 0:
3934 raise ValueError(
"Current node number must be non-negative")
3935 if internode_radius <= 0:
3936 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
3937 if internode_length_max <= 0:
3938 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
3939 if not shoot_type_label
or not shoot_type_label.strip():
3940 raise ValueError(
"Shoot type label cannot be empty")
3941 if petiole_index < 0:
3942 raise ValueError(f
"Petiole index must be non-negative, got {petiole_index}")
3945 rotation_list = shoot_base_rotation.to_list()
3950 return plantarch_wrapper.addChildShoot(
3951 self.
_plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
3952 current_node_number, rotation_list, internode_radius,
3953 internode_length_max, internode_length_scale_factor_fraction,
3954 leaf_scale_factor_fraction, radius_taper, shoot_type_label.strip(),
3957 except Exception
as e:
3959 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
3961 f
"Shoot type '{shoot_type_label}' not defined. "
3962 f
"Load a plant model first to define shoot types:\n"
3963 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
3964 f
"Original error: {e}"
3974 """Validate a measured node path and return it as plain lists for the ctypes layer."""
3975 if not isinstance(node_positions, (list, tuple)):
3976 raise ValueError(f
"{positions_name} must be a list of vec3, got {type(node_positions).__name__}")
3977 if not isinstance(node_radii, (list, tuple)):
3978 raise ValueError(f
"{radii_name} must be a list of floats, got {type(node_radii).__name__}")
3979 if len(node_positions) < 2:
3980 raise ValueError(f
"{positions_name} must contain at least two positions, got {len(node_positions)}")
3981 if len(node_radii) != len(node_positions):
3983 f
"{radii_name} must have one entry per position: got {len(node_radii)} radii "
3984 f
"for {len(node_positions)} positions")
3986 for i, pt
in enumerate(node_positions):
3987 if not isinstance(pt, vec3):
3988 raise ValueError(f
"{positions_name}[{i}] must be a vec3, got {type(pt).__name__}")
3989 positions.append([pt.x, pt.y, pt.z])
3991 for i, r
in enumerate(node_radii):
3992 if isinstance(r, bool)
or not isinstance(r, (int, float)):
3993 raise ValueError(f
"{radii_name}[{i}] must be a number, got {type(r).__name__}")
3995 raise ValueError(f
"{radii_name}[{i}] must be positive, got {r}")
3996 radii.append(float(r))
3997 return positions, radii
3999 @_surfaces_phytomer_callback_errors
4002 parent_shoot_id: int,
4003 parent_node_index: int,
4004 node_positions: List[vec3],
4005 node_radii: List[float],
4006 shoot_type_label: str,
4007 growth_shoot_type_label: Optional[str] =
None,
4008 petiole_index: int = 0) -> int:
4010 Add a shoot whose internode geometry is prescribed by measured node positions.
4012 This builds a single shoot, rendered as one continuous internode tube, that
4013 follows a path given by the caller rather than one generated from the shoot
4014 type's curvature and tortuosity parameters. It is intended for reconstructing a
4015 plant from measured geometry such as a QSM, a digitized skeleton or
4016 photogrammetry. A shoot built through :meth:`addBaseStemShoot`,
4017 :meth:`appendShoot` or :meth:`addChildShoot` is an extrapolation from its base
4018 rotation and cannot follow a measured curve; approximating one by chaining many
4019 short shoots produces a separate tube object per link, which leaves visible gaps
4022 The supplied positions are the phytomer endpoints: N+1 positions define N
4023 internodes and therefore N phytomers. The shoot type's ``internode.length_segments``
4024 still controls how finely each internode is subdivided, with the intermediate
4025 nodes interpolated along the straight segment between the two prescribed
4026 endpoints. The caller controls internode length by choosing how many nodes to
4029 The prescribed phytomers are created fully elongated and are therefore not
4030 re-scaled or re-curved by subsequent calls to :meth:`advanceTime`. New phytomers
4031 added at the shoot apex as the plant grows are generated normally, continuing from
4032 the direction of the final prescribed internode, and use the mean of the
4033 prescribed internode lengths as their target length. Prescribed radii act as a
4034 lower bound: a shoot type with a non-zero ``girth_area_factor`` may thicken an
4035 internode during growth but never thins one, so a girth area factor of zero
4036 preserves the prescribed radii exactly.
4038 When ``parent_shoot_id`` is non-negative the base of the shoot is seated on the
4039 parent as :meth:`addChildShoot` does (offset from the attachment node to the
4040 surface of the parent internode) and the whole path is translated onto that
4041 point. All relative geometry is preserved; only the absolute position changes,
4042 and an error is raised if the discrepancy is large enough that the shoot would
4043 not be connected to its parent.
4045 **Separate growth type.** Building measured wood calls for curvature and
4046 tortuosity of zero so the measured path is not fought, a node cap at least as
4047 large as the longest measured branch, and often a girth area factor of zero so
4048 the measured radii are preserved. None of those describe how the plant should
4049 grow: a shoot inheriting them extends perfectly straight and never reaches its
4050 node cap. Pass ``growth_shoot_type_label`` to take the node caps, the gravitropic
4051 curvature of phytomers added at the apex, and the type of the shoots this shoot's
4052 vegetative buds produce from a different shoot type. ``girth_area_factor`` and
4053 bud-break probability are deliberately still taken from the build type, and the
4054 build type remains the label reported by the shoot. A measured branch longer
4055 than the growth type's ``max_nodes`` is accepted and simply stops extending.
4057 Requires helios-core v1.3.85 or newer.
4060 plant_id: ID of the plant instance
4061 parent_shoot_id: ID of the shoot to attach to, or ``-1`` to create a base stem
4062 shoot at the start of a new plant
4063 parent_node_index: Node of the parent shoot at which the new shoot is added.
4064 Ignored when ``parent_shoot_id`` is ``-1``
4065 node_positions: Internode node positions in world coordinates, ordered from the
4066 base of the shoot to its tip. At least two are required, and no two
4067 consecutive positions may be coincident
4068 node_radii: Radius of the shoot at each node, one per position. All must be > 0
4069 shoot_type_label: Shoot type whose parameters build the measured geometry.
4070 Must already be defined (by :meth:`loadPlantModelFromLibrary` or
4071 :meth:`defineShootType`)
4072 growth_shoot_type_label: Optional shoot type whose parameters govern the
4073 shoot's future growth. ``None`` grows the shoot with ``shoot_type_label``
4074 petiole_index: Petiole within the parent node to attach to (default 0)
4077 ID of the newly created shoot
4080 ValueError: If any ID is out of range, a position is not a vec3, a radius is
4081 not positive, fewer than two nodes are given, or the counts differ
4082 PlantArchitectureError: If the native build fails (undefined shoot type,
4083 coincident consecutive nodes, base too far from the parent, ...) or the
4084 library predates v1.3.85
4087 Like every other manually added shoot, the new shoot is created dormant.
4088 Call :meth:`breakPlantDormancy` before :meth:`advanceTime` if it is to grow.
4091 >>> plantarch.loadPlantModelFromLibrary("bean")
4092 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
4093 >>> path = [vec3(0, 0, 0), vec3(0.01, 0, 0.1), vec3(0.03, 0.01, 0.2), vec3(0.04, 0.01, 0.3)]
4094 >>> radii = [0.006, 0.005, 0.004, 0.003]
4095 >>> stem = plantarch.addShootFromNodePositions(plant_id, -1, 0, path, radii, "unifoliate")
4096 >>> assert plantarch.isShootGeometryPrescribed(plant_id, stem)
4099 raise ValueError(
"Plant ID must be non-negative")
4100 if parent_shoot_id < -1:
4101 raise ValueError(
"Parent shoot ID must be -1 (base stem) or a non-negative shoot ID")
4102 if parent_node_index < 0:
4103 raise ValueError(
"Parent node index must be non-negative")
4104 if petiole_index < 0:
4105 raise ValueError(f
"Petiole index must be non-negative, got {petiole_index}")
4106 if not isinstance(shoot_type_label, str)
or not shoot_type_label.strip():
4107 raise ValueError(
"Shoot type label cannot be empty")
4108 if growth_shoot_type_label
is not None:
4109 if not isinstance(growth_shoot_type_label, str)
or not growth_shoot_type_label.strip():
4110 raise ValueError(
"Growth shoot type label cannot be empty when given")
4111 growth_shoot_type_label = growth_shoot_type_label.strip()
4112 positions, radii = self.
_validateNodesAndRadii(node_positions, node_radii,
"node_positions",
"node_radii")
4117 return plantarch_wrapper.addShootFromNodePositions(
4118 self.
_plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
4119 positions, radii, shoot_type_label.strip(), growth_shoot_type_label, petiole_index)
4120 except Exception
as e:
4122 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
4124 f
"Shoot type not defined ('{shoot_type_label}'"
4125 f
"{', ' + repr(growth_shoot_type_label) if growth_shoot_type_label else ''}). "
4126 f
"Load a plant model or define the shoot type first:\n"
4127 f
" plantarch.loadPlantModelFromLibrary('bean') # or defineShootType(...)\n"
4128 f
"Original error: {e}")
4136 node_positions: List[vec3],
4137 node_radii: List[float]) ->
None:
4139 Prescribe the path of a petiole on an existing phytomer from measured node positions.
4141 This is the organ-level counterpart of :meth:`addShootFromNodePositions`. Where
4142 that method prescribes the internode skeleton of a shoot, this one prescribes the
4143 centerline of a single petiole hanging off it, so that a reconstruction from
4144 labelled measurements (a segmented point cloud, a digitized plant) can follow the
4145 measured petiole rather than the path the shoot type's petiole pitch and
4146 curvature would generate.
4148 The supplied positions are the nodes of the petiole tube, ordered from the base
4149 outward. Their number is free and need not match the shoot type's
4150 ``petiole.length_segments``; the petiole tube is rebuilt to match. The first
4151 position is snapped onto the tip of the internode the petiole grows from and the
4152 rest of the path is translated by the same amount, so all relative geometry is
4153 preserved exactly. An error is raised if that discrepancy is large enough that
4154 the petiole would not be attached to the stem.
4156 The prescribed petiole is not re-scaled by subsequent calls to
4157 :meth:`advanceTime`, and its radii are held as given.
4159 Requires helios-core v1.3.85 or newer.
4162 plant_id: ID of the plant instance
4163 shoot_id: ID of the shoot carrying the phytomer
4164 node_index: Index of the phytomer within the shoot, counted from the base
4165 petiole_index: Index of the petiole within the phytomer
4166 node_positions: Petiole node positions in world coordinates, base to tip. At
4167 least two are required, and no two consecutive positions may be coincident
4168 node_radii: Radius of the petiole at each node, one per position. All must be > 0
4171 ValueError: If any index is negative, a position is not a vec3, a radius is
4172 not positive, fewer than two nodes are given, or the counts differ
4173 PlantArchitectureError: If the native call fails or the library predates v1.3.85
4176 Call this **before** :meth:`setPetioleLeafGeometry` for the same petiole, since
4177 leaf placement is oriented from the petiole axis.
4179 for name, v
in ((
"Plant ID", plant_id), (
"Shoot ID", shoot_id),
4180 (
"Node index", node_index), (
"Petiole index", petiole_index)):
4182 raise ValueError(f
"{name} must be non-negative")
4183 positions, radii = self.
_validateNodesAndRadii(node_positions, node_radii,
"node_positions",
"node_radii")
4188 plantarch_wrapper.setPetioleNodePositions(
4189 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index, positions, radii)
4190 except Exception
as e:
4192 f
"Failed to set petiole node positions (plant {plant_id}, shoot {shoot_id}, "
4193 f
"node {node_index}, petiole {petiole_index}): {e}")
4200 leaf_bases: List[vec3],
4201 leaf_rotations: List[AxisRotation],
4202 leaf_sizes: List[float]) ->
None:
4204 Prescribe the base position, orientation and size of every leaf on a petiole.
4206 This is the leaf-level counterpart of :meth:`setPetioleNodePositions`, intended
4207 for the same reconstruction workflow. Every leaf on the petiole is prescribed in
4208 one call: for a compound leaf the leaflets are not independent, since a
4209 leaflet's roll and yaw signs and the prototype it is a copy of all follow from
4210 its position along the petiole. A species with one leaf per petiole passes
4213 Each leaf is rebuilt from its prototype and re-oriented through the same
4214 rotation chain used when a leaf is grown. The prescribed base, orientation and
4215 size are held exactly and are not changed by :meth:`advanceTime`; prescribed
4216 leaves are additionally exempt from the self-weight droop.
4218 **Rotation units and frame.** ``leaf_rotations`` are given in **radians**, as
4219 pitch, yaw and roll relative to the petiole and internode axes, not to world
4220 axes (the same convention as the native ``Phytomer::leaf_rotation``). The full
4221 chain that places a leaf includes the petiole's own azimuth and a
4222 size-dependent correction and is not invertible, so there is no exact
4223 conversion from a world-frame blade orientation; a caller fitting to measured
4224 data should iterate by forward evaluation, reading the resulting geometry back
4227 Requires helios-core v1.3.85 or newer.
4230 plant_id: ID of the plant instance
4231 shoot_id: ID of the shoot carrying the phytomer
4232 node_index: Index of the phytomer within the shoot, counted from the base
4233 petiole_index: Index of the petiole within the phytomer
4234 leaf_bases: Base position of each leaf in world coordinates, one per leaf on
4235 the petiole, in the petiole's existing leaf order
4236 leaf_rotations: ``AxisRotation(pitch, yaw, roll)`` of each leaf in **radians**
4237 leaf_sizes: Fully elongated size of each leaf in meters. All must be > 0
4240 ValueError: If any index is negative, a base is not a vec3, a rotation is not
4241 an AxisRotation, a size is not positive, or the three lists differ in length
4242 PlantArchitectureError: If the number of leaves does not match the petiole
4243 (the count is fixed when the phytomer is created), the native call
4244 fails, or the library predates v1.3.85
4247 Rebuilding each leaf discards primitive data a caller has attached to it.
4248 The object label and material are restored; other primitive data is not.
4250 for name, v
in ((
"Plant ID", plant_id), (
"Shoot ID", shoot_id),
4251 (
"Node index", node_index), (
"Petiole index", petiole_index)):
4253 raise ValueError(f
"{name} must be non-negative")
4254 for name, seq
in ((
"leaf_bases", leaf_bases), (
"leaf_rotations", leaf_rotations), (
"leaf_sizes", leaf_sizes)):
4255 if not isinstance(seq, (list, tuple)):
4256 raise ValueError(f
"{name} must be a list, got {type(seq).__name__}")
4259 raise ValueError(
"leaf_bases must contain at least one leaf")
4260 if len(leaf_rotations) != n
or len(leaf_sizes) != n:
4262 f
"leaf_bases, leaf_rotations and leaf_sizes must have the same length: "
4263 f
"got {n}, {len(leaf_rotations)} and {len(leaf_sizes)}")
4265 for i, b
in enumerate(leaf_bases):
4266 if not isinstance(b, vec3):
4267 raise ValueError(f
"leaf_bases[{i}] must be a vec3, got {type(b).__name__}")
4268 bases.append([b.x, b.y, b.z])
4270 for i, r
in enumerate(leaf_rotations):
4271 if not isinstance(r, AxisRotation):
4272 raise ValueError(f
"leaf_rotations[{i}] must be an AxisRotation, got {type(r).__name__}")
4273 rotations.append([r.pitch, r.yaw, r.roll])
4275 for i, sz
in enumerate(leaf_sizes):
4276 if isinstance(sz, bool)
or not isinstance(sz, (int, float)):
4277 raise ValueError(f
"leaf_sizes[{i}] must be a number, got {type(sz).__name__}")
4279 raise ValueError(f
"leaf_sizes[{i}] must be positive, got {sz}")
4280 sizes.append(float(sz))
4285 plantarch_wrapper.setPetioleLeafGeometry(
4286 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4287 bases, rotations, sizes)
4288 except Exception
as e:
4290 f
"Failed to set petiole leaf geometry (plant {plant_id}, shoot {shoot_id}, "
4291 f
"node {node_index}, petiole {petiole_index}): {e}")
4294 petiole_index: int, leaf_count: int) ->
None:
4296 Change the number of leaves (leaflets) on one petiole of an existing phytomer.
4298 The leaves are rebuilt procedurally. By default the count is set by the shoot type's
4299 ``leaf.leaves_per_petiole``; use this to match a measured compound leaf with a
4300 different number of leaflets before calling :meth:`setPetioleLeafGeometry`.
4303 plant_id: Plant identifier.
4304 shoot_id: Shoot identifier.
4305 node_index: Index of the phytomer along the shoot.
4306 petiole_index: Index of the petiole on that phytomer.
4307 leaf_count: Number of leaves to place on the petiole. Must be at least 1.
4310 ValueError: If any index is negative or ``leaf_count`` is less than 1.
4311 PlantArchitectureError: If the operation fails.
4314 Call this **before** :meth:`setPetioleLeafGeometry` for the same petiole, whose
4315 ``leaf_count`` must match the number of leaves on the petiole.
4317 for name, v
in ((
"Plant ID", plant_id), (
"Shoot ID", shoot_id),
4318 (
"Node index", node_index), (
"Petiole index", petiole_index)):
4320 raise ValueError(f
"{name} must be non-negative")
4322 raise ValueError(f
"Leaf count must be at least 1, got {leaf_count}")
4327 plantarch_wrapper.setPetioleLeafCount(
4328 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index, leaf_count)
4329 except Exception
as e:
4331 f
"Failed to set petiole leaf count (plant {plant_id}, shoot {shoot_id}, "
4332 f
"node {node_index}, petiole {petiole_index}): {e}")
4335 internode_length_max: float) ->
None:
4337 Set the target length of internodes grown at the apex of an existing shoot.
4339 A shoot built by :meth:`addShootFromNodePositions` otherwise grows toward the mean
4340 of its prescribed internode lengths; use this to give newly grown internodes a
4341 different target length.
4344 plant_id: Plant identifier.
4345 shoot_id: Shoot identifier.
4346 internode_length_max: Target internode length in meters. Must be positive.
4349 ValueError: If an identifier is negative or the length is not positive.
4350 PlantArchitectureError: If the operation fails.
4353 This value is **not** saved by :meth:`writePlantStructureXML`, so it must be
4354 set again after :meth:`readPlantStructureXML`.
4356 if plant_id < 0
or shoot_id < 0:
4357 raise ValueError(
"Plant ID and shoot ID must be non-negative")
4358 if internode_length_max <= 0:
4359 raise ValueError(f
"Internode length must be positive, got {internode_length_max}")
4364 plantarch_wrapper.setShootInternodeLengthMax(
4366 except Exception
as e:
4368 f
"Failed to set shoot internode length max (plant {plant_id}, "
4369 f
"shoot {shoot_id}): {e}")
4374 Stop a shoot's apex from adding any further phytomers.
4376 The shoot keeps everything it already has, and its vegetative buds keep whatever
4377 state they are in -- this kills only the apical meristem. The shoot therefore stops
4378 extending at its tip but can still throw laterals; to stop those as well, pair this
4379 with :meth:`removeShootVegetativeBuds`.
4381 This is the standard way to freeze the old wood of a reconstructed tree before
4382 growing it forward with :meth:`advanceTime`.
4385 plant_id: ID of the plant instance
4386 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
4389 ValueError: If either identifier is not a non-negative int
4390 PlantArchitectureError: If the plant or shoot does not exist
4393 >>> # Freeze the measured scaffold so only last year's growth extends
4394 >>> for shoot_id in plantarch.getTerminalShootIDs(plant_id):
4395 ... plantarch.terminateApicalBud(plant_id, shoot_id)
4401 plantarch_wrapper.terminateShootApicalBud(self.
_plantarch_ptr, plant_id, shoot_id)
4402 except Exception
as e:
4404 f
"Failed to terminate the apical bud of shoot {shoot_id} "
4405 f
"of plant {plant_id}: {e}")
4408 state: Optional[BudState] =
None) -> int:
4410 Count a shoot's axillary vegetative buds, summed over all phytomers and petioles.
4412 Buds are never removed from a shoot -- only their state changes -- so the
4413 unfiltered count is stable over the shoot's life and makes a useful denominator.
4416 plant_id: ID of the plant instance
4417 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
4418 state: Count only buds in this :class:`~pyhelios.BudState`. ``None``
4419 (the default) counts buds in every state.
4422 The number of matching vegetative buds.
4425 ValueError: If an identifier is negative, or ``state`` is not a BudState
4426 PlantArchitectureError: If the plant or shoot does not exist
4429 ``BudState.DEAD`` means "will produce nothing further", which covers both buds
4430 that were killed and buds that have **already broken into a child shoot**. A
4431 dead-bud count is therefore not a count of killed buds. To test whether a shoot
4432 can still grow, count the live states instead -- for example the unfiltered
4433 total minus the dead count.
4436 >>> from pyhelios import BudState
4437 >>> total = plantarch.getShootVegetativeBudCount(plant_id, 0)
4438 >>> dead = plantarch.getShootVegetativeBudCount(plant_id, 0, BudState.DEAD)
4439 >>> print(f"{total - dead} buds can still break")
4446 return plantarch_wrapper.getShootVegetativeBudCount(
4448 except Exception
as e:
4450 f
"Failed to count the vegetative buds of shoot {shoot_id} "
4451 f
"of plant {plant_id}: {e}")
4455 Get the number of leaf objects on a plant.
4457 Counts compound leaf objects, not primitives -- a leaf built from many triangles
4458 counts once, and a compound leaf contributes one per leaflet. Equivalent to
4459 ``len(getPlantLeafObjectIDs(plant_id))`` without materializing the ID list.
4462 plant_id: ID of the plant instance
4465 The number of leaf objects.
4468 ValueError: If plant_id is not a non-negative int
4469 PlantArchitectureError: If the plant does not exist
4472 >>> print(f"{plantarch.getPlantLeafCount(plant_id)} leaves")
4474 if isinstance(plant_id, bool)
or not isinstance(plant_id, int):
4475 raise ValueError(f
"Plant ID must be a non-negative int, got {type(plant_id).__name__}")
4477 raise ValueError(
"Plant ID must be non-negative")
4481 return plantarch_wrapper.getPlantLeafCount(self.
_plantarch_ptr, plant_id)
4482 except Exception
as e:
4484 f
"Failed to get the leaf count of plant {plant_id}: {e}")
4488 """Reject non-int and negative plant/shoot identifiers.
4490 bool is excluded explicitly: it is an int subclass, so True would otherwise pass
4493 for name, value
in ((
"Plant ID", plant_id), (
"Shoot ID", shoot_id)):
4494 if isinstance(value, bool)
or not isinstance(value, int):
4496 f
"{name} must be a non-negative int, got {type(value).__name__}")
4497 if plant_id < 0
or shoot_id < 0:
4498 raise ValueError(
"Plant ID and shoot ID must be non-negative")
4502 """Coerce a BudState (or its int value) and reject anything else.
4504 A bare int is accepted because BudState is an IntEnum, but it still has to name a
4505 real state -- an out-of-range value would be cast onto the C++ enum, which is
4508 if isinstance(state, bool)
or not isinstance(state, int):
4509 raise ValueError(f
"State must be a BudState, got {type(state).__name__}")
4514 f
"State must be a BudState value in 0..5, got {int(state)}")
4517 beta_nu_inclination: float, eccentricity: float,
4518 ellipse_rotation_degrees: float,
4519 lambda_degrees: float) ->
None:
4521 Steer leaf inclination and azimuth toward a prescribed distribution as the plant grows.
4523 Each leaf is given a target angle as it emerges and turns onto it while it expands,
4524 so a fully grown leaf never moves again: the plant matches the distribution at every
4525 stage without leaves shifting from one timestep to the next. Targets are not drawn
4526 independently per leaf, so the arrangement the model generated is preserved: each emerging leaf takes the bin that best trades
4527 closeness to the angle the model gave it against how far that bin is below its share
4528 of the plant's leaf area.
4530 Pass a list of plant IDs to realize the distribution over a canopy as a whole, in
4531 which case an individual plant need not follow the distribution on its own.
4533 Enabling tracking on an already-tracked plant replaces its target, so the target may
4534 be varied over the plant's life.
4537 plant_ids: A single plant ID, or a sequence of plant IDs to steer together
4538 beta_mu_inclination: Mean parameter of the Beta inclination distribution
4539 beta_nu_inclination: Shape parameter of the Beta inclination distribution
4540 eccentricity: Eccentricity of the ellipse defining the azimuth distribution
4541 ellipse_rotation_degrees: Rotation of that ellipse (degrees)
4542 lambda_degrees: How strongly to favour filling the distribution over keeping each
4543 leaf near the angle the model gave it. Zero leaves the plant unchanged; values
4544 of order 180 match the distribution as closely as the growing plant allows.
4547 ValueError: If any plant ID is not a non-negative int, or the list is empty
4548 PlantArchitectureError: If a plant does not exist
4549 RuntimeError: If the native library predates helios-core v1.3.87
4552 >>> plantarch.enableLeafAngleDistributionTracking(
4553 ... plant_id, 2.0, 1.5, 0.5, 0.0, 180.0)
4554 >>> plantarch.advanceTime(plant_id, 20)
4556 multi =
not isinstance(plant_ids, int)
or isinstance(plant_ids, bool)
4563 plantarch_wrapper.enablePlantLeafAngleDistributionTrackingMulti(
4564 self.
_plantarch_ptr, ids, beta_mu_inclination, beta_nu_inclination,
4565 eccentricity, ellipse_rotation_degrees, lambda_degrees)
4567 plantarch_wrapper.enablePlantLeafAngleDistributionTracking(
4568 self.
_plantarch_ptr, ids[0], beta_mu_inclination, beta_nu_inclination,
4569 eccentricity, ellipse_rotation_degrees, lambda_degrees)
4570 except Exception
as e:
4572 f
"Failed to enable leaf angle distribution tracking for {ids}: {e}")
4575 beta_mu_inclination: float,
4576 beta_nu_inclination: float,
4577 lambda_degrees: float) ->
None:
4579 Steer leaf inclination toward a Beta distribution as the plant grows, leaving azimuth
4580 to the procedural model.
4582 The inclination-only counterpart of :meth:`enableLeafAngleDistributionTracking`.
4585 plant_id: ID of the plant instance
4586 beta_mu_inclination: Mean parameter of the Beta inclination distribution
4587 beta_nu_inclination: Shape parameter of the Beta inclination distribution
4588 lambda_degrees: How strongly to favour filling the distribution over keeping each
4589 leaf near the angle the model gave it
4592 ValueError: If ``plant_id`` is not a non-negative int
4593 PlantArchitectureError: If the plant does not exist
4594 RuntimeError: If the native library predates helios-core v1.3.87
4600 plantarch_wrapper.enablePlantLeafElevationAngleDistributionTracking(
4601 self.
_plantarch_ptr, plant_id, beta_mu_inclination, beta_nu_inclination,
4603 except Exception
as e:
4605 f
"Failed to enable leaf elevation angle distribution tracking for "
4606 f
"plant {plant_id}: {e}")
4609 ellipse_rotation_degrees: float,
4610 lambda_degrees: float) ->
None:
4612 Steer leaf azimuth toward an ellipsoidal distribution as the plant grows, leaving
4613 inclination to the procedural model.
4615 The azimuth-only counterpart of :meth:`enableLeafAngleDistributionTracking`.
4618 plant_id: ID of the plant instance
4619 eccentricity: Eccentricity of the ellipse defining the azimuth distribution
4620 ellipse_rotation_degrees: Rotation of that ellipse (degrees)
4621 lambda_degrees: How strongly to favour filling the distribution over keeping each
4622 leaf near the angle the model gave it
4625 ValueError: If ``plant_id`` is not a non-negative int
4626 PlantArchitectureError: If the plant does not exist
4627 RuntimeError: If the native library predates helios-core v1.3.87
4633 plantarch_wrapper.enablePlantLeafAzimuthAngleDistributionTracking(
4634 self.
_plantarch_ptr, plant_id, eccentricity, ellipse_rotation_degrees,
4636 except Exception
as e:
4638 f
"Failed to enable leaf azimuth angle distribution tracking for "
4639 f
"plant {plant_id}: {e}")
4643 Stop steering a plant's leaf angles toward a prescribed distribution.
4645 Leaves already steered keep the orientation they have reached; leaves emerging
4646 afterward are left where the procedural model puts them.
4649 plant_id: ID of the plant instance
4652 ValueError: If ``plant_id`` is not a non-negative int
4653 PlantArchitectureError: If the plant does not exist
4654 RuntimeError: If the native library predates helios-core v1.3.87
4660 plantarch_wrapper.disablePlantLeafAngleDistributionTracking(
4662 except Exception
as e:
4664 f
"Failed to disable leaf angle distribution tracking for "
4665 f
"plant {plant_id}: {e}")
4669 Whether a plant's leaf angles are being steered toward a prescribed distribution.
4672 plant_id: ID of the plant instance
4675 True if tracking is in effect for this plant
4678 ValueError: If ``plant_id`` is not a non-negative int
4679 PlantArchitectureError: If the plant does not exist
4680 RuntimeError: If the native library predates helios-core v1.3.87
4686 return plantarch_wrapper.isPlantLeafAngleDistributionTrackingEnabled(
4688 except Exception
as e:
4690 f
"Failed to query leaf angle distribution tracking for "
4691 f
"plant {plant_id}: {e}")
4694 petiole_index: Optional[int] =
None) -> float:
4696 Current length of a phytomer's petioles, measured along the centerline.
4698 This is the length right now, not the mature length the petiole is growing toward,
4699 so it rises as the petiole elongates. Contrast the leaf readers, which report the
4700 size a leaf is expanding toward. The length is an arclength rather than a
4701 base-to-tip distance, so a petiole drooping under its leaves reports the same
4702 length as a rigid one of the same age.
4704 With ``petiole_index`` omitted, returns the mean over every petiole on the
4705 phytomer. Petioles at one node are parallel structures rather than segments in
4706 series, so their lengths are not additive and the mean is the meaningful summary.
4707 A phytomer with no petiole -- a leafless woody type, or one whose leaf has been
4708 shed -- reports 0.0.
4711 plant_id: ID of the plant instance
4712 shoot_id: Shoot index within the plant
4713 node_index: Phytomer index within the shoot
4714 petiole_index: Petiole within the phytomer; ``None`` for the phytomer mean
4717 Current petiole arclength in meters
4720 ValueError: If any identifier is not a non-negative int
4721 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4722 RuntimeError: If the native library predates helios-core v1.3.87
4726 if petiole_index
is not None:
4731 return plantarch_wrapper.getPetioleLength(
4732 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index)
4733 except Exception
as e:
4735 f
"Failed to get the petiole length of node {node_index} of shoot "
4736 f
"{shoot_id} of plant {plant_id}: {e}")
4739 scale_factor: float) ->
None:
4741 Scale the fully-elongated length every petiole on a phytomer is growing toward.
4743 The petiole counterpart of internode max-length scaling. The petiole's present
4744 length is left where it is and only its target changes, so a phytomer creation
4745 function can give a leaf born on a young plant a shorter final petiole without
4746 moving the petiole that is already there.
4749 plant_id: ID of the plant instance
4750 shoot_id: Shoot index within the plant
4751 node_index: Phytomer index within the shoot
4752 scale_factor: Factor to scale the fully-elongated length by; must be positive
4755 ValueError: If an identifier is invalid or ``scale_factor`` is not positive
4756 PlantArchitectureError: If the plant, shoot or node does not exist
4757 RuntimeError: If the native library predates helios-core v1.3.87
4765 plantarch_wrapper.scalePetioleMaxLength(
4766 self.
_plantarch_ptr, plant_id, shoot_id, node_index, scale_factor)
4767 except Exception
as e:
4769 f
"Failed to scale the petiole max length of node {node_index} of shoot "
4770 f
"{shoot_id} of plant {plant_id}: {e}")
4774 petiole_scale_factor_fraction: float) ->
None:
4776 Set one petiole's current length as a fraction of its fully-elongated length,
4777 leaving the leaves it carries at the size they are.
4779 A petiole is a stem segment rather than part of the blade and goes on extending
4780 after the blade has finished expanding, which is why its growth is driven by the
4781 shoot's internode rate rather than the leaf expansion rate. The leaves ride out
4782 along the petiole as it lengthens without changing size.
4785 plant_id: ID of the plant instance
4786 shoot_id: Shoot index within the plant
4787 node_index: Phytomer index within the shoot
4788 petiole_index: Petiole within the phytomer
4789 petiole_scale_factor_fraction: Fraction of the fully-elongated length
4790 (1.0 for a fully-elongated petiole)
4793 ValueError: If any identifier is not a non-negative int
4794 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4795 RuntimeError: If the native library predates helios-core v1.3.87
4803 plantarch_wrapper.setPetioleScaleFraction(
4804 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4805 petiole_scale_factor_fraction)
4806 except Exception
as e:
4808 f
"Failed to set the petiole scale fraction of node {node_index} of shoot "
4809 f
"{shoot_id} of plant {plant_id}: {e}")
4813 petiole_scale_factor_fraction: float,
4814 leaf_scale_factor_fraction: float) ->
None:
4816 Set a petiole's length and its leaves' size together, each as a fraction of its own
4817 fully-elongated value.
4819 The two fractions are applied in one pass, so the leaves are scaled, re-seated
4820 along the rescaled petiole and bent under their new weight once rather than twice.
4821 Use this rather than the two single-fraction calls when advancing both.
4824 plant_id: ID of the plant instance
4825 shoot_id: Shoot index within the plant
4826 node_index: Phytomer index within the shoot
4827 petiole_index: Petiole within the phytomer
4828 petiole_scale_factor_fraction: Fraction of the fully-elongated petiole length
4829 leaf_scale_factor_fraction: Fraction of the fully-elongated leaf scale factor
4832 ValueError: If any identifier is not a non-negative int
4833 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4834 RuntimeError: If the native library predates helios-core v1.3.87
4842 plantarch_wrapper.setPetioleAndLeafScaleFraction(
4843 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4844 petiole_scale_factor_fraction, leaf_scale_factor_fraction)
4845 except Exception
as e:
4847 f
"Failed to set the petiole and leaf scale fractions of node {node_index} "
4848 f
"of shoot {shoot_id} of plant {plant_id}: {e}")
4851 scale_factor: float) ->
None:
4853 Scale the size every leaf on a phytomer is expanding toward, leaving the blades
4856 The blade's present size is untouched and only its target changes, so the expansion
4857 fraction moves the other way: a fully-expanded leaf given a larger target becomes a
4858 partly-expanded leaf of the same size and goes on growing on the next
4859 :meth:`advanceTime`. This is what hands a leaf built from measured geometry back to
4860 the growth model still the size it was measured.
4862 A factor small enough to put the target below the leaf's present size is the one
4863 case in which the blade does move: the leaf is taken down to the new target, and the
4864 leaflets of a compound leaf are then re-seated along the petiole, discarding a
4865 placement prescribed by :meth:`setPetioleLeafGeometry`.
4868 plant_id: ID of the plant instance
4869 shoot_id: Shoot index within the plant
4870 node_index: Phytomer index within the shoot
4871 scale_factor: Factor to scale the mature leaf size by; must be positive
4874 ValueError: If an identifier is invalid or ``scale_factor`` is not positive
4875 PlantArchitectureError: If the plant, shoot or node does not exist
4876 RuntimeError: If the native library predates helios-core v1.3.87
4884 plantarch_wrapper.scaleLeafSizeMax(
4885 self.
_plantarch_ptr, plant_id, shoot_id, node_index, scale_factor)
4886 except Exception
as e:
4888 f
"Failed to scale the max leaf size of node {node_index} of shoot "
4889 f
"{shoot_id} of plant {plant_id}: {e}")
4891 def setLeafNormal(self, plant_id: int, shoot_id: int, node_index: int,
4892 petiole_index: int, leaf_index: int, target_normal: vec3) ->
None:
4894 Re-aim one leaf so its blade faces a given direction.
4896 The roll and pitch that carry the blade onto ``target_normal`` are applied as a
4897 single rotation about the leaf's own base, so the leaf stays attached to its petiole
4898 and keeps the azimuth of the petiole it hangs from. The angles are recorded on the
4899 phytomer, which is what makes the new orientation survive a
4900 :meth:`writePlantStructureXML` / :meth:`readPlantStructureXML` round trip --
4901 rotating the leaf object directly through the Context changes the geometry without
4902 changing the record and is silently lost on reload.
4905 plant_id: ID of the plant instance
4906 shoot_id: Shoot index within the plant
4907 node_index: Phytomer index within the shoot
4908 petiole_index: Petiole within the phytomer
4909 leaf_index: Leaf within the petiole
4910 target_normal: Direction the blade should face, in world coordinates. Need not
4914 ValueError: If an identifier is invalid, or ``target_normal`` is not a vec3
4915 PlantArchitectureError: If the leaf has no geometry, the blade's facet normals
4916 cancel, or the target cannot be reached by a roll-pitch pair
4917 RuntimeError: If the native library predates helios-core v1.3.87
4920 >>> from pyhelios.types import vec3
4921 >>> plantarch.setLeafNormal(plant_id, 0, 3, 0, 0, vec3(0, 0, 1))
4927 if not isinstance(target_normal, vec3):
4929 f
"Target normal must be a vec3, got {type(target_normal).__name__}")
4933 plantarch_wrapper.setLeafNormal(
4934 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4935 leaf_index, target_normal.x, target_normal.y, target_normal.z)
4936 except Exception
as e:
4938 f
"Failed to set the normal of leaf {leaf_index} on petiole "
4939 f
"{petiole_index} of node {node_index} of shoot {shoot_id} "
4940 f
"of plant {plant_id}: {e}")
4943 petiole_index: int, include_posed_leaves: bool =
False) ->
None:
4945 Bend one petiole, and the leaves it carries, under the weight of its leaflets.
4947 The petiole is bent as a tapered cantilever clamped at its insertion, for the leaf's
4948 current size and the petiole's age. The bent shape is always computed from the
4949 recorded undeformed rest shape rather than the current shape, so repeated calls do
4950 not accumulate and creep the petiole downward. The insertion stays clamped, so the
4951 petiole keeps leaving the stem at its generated pitch and the droop appears beyond it
4952 as curvature along the length.
4954 This is normally driven by the growth model from
4955 ``PhytomerParameters.petiole.flexibility``; call it directly only to re-bend a petiole
4956 after changing its geometry or its flexibility (:meth:`setPetioleFlexibility`) yourself.
4957 It does nothing for a rigid petiole (flexibility left at zero), a petiole whose
4958 centerline was prescribed, one carrying a prescribed leaf, or when neither the load nor
4959 the compliance has changed since the last call.
4961 A leaf counts as prescribed once it has been posed by :meth:`setLeafNormal`,
4962 :meth:`setLeafAngleDistribution` or :meth:`setPetioleLeafGeometry`, so after any of
4963 those its petiole no longer bends. Pass ``include_posed_leaves=True`` to bend it anyway:
4964 the posed leaves are then carried along the petiole and turned with it, which moves them
4965 away from the orientation they were given.
4968 plant_id: ID of the plant instance
4969 shoot_id: Shoot index within the plant
4970 node_index: Phytomer index within the shoot
4971 petiole_index: Petiole within the phytomer
4972 include_posed_leaves: Also bend a petiole that carries posed leaves
4975 ValueError: If any identifier is not a non-negative int, or
4976 ``include_posed_leaves`` is not a bool
4977 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4978 RuntimeError: If the native library predates helios-core v1.3.87
4984 if not isinstance(include_posed_leaves, bool):
4986 f
"include_posed_leaves must be a bool, got {type(include_posed_leaves).__name__}")
4990 bend = (plantarch_wrapper.bendPetioleWithPosedLeavesUnderLeafWeight
if include_posed_leaves
4991 else plantarch_wrapper.bendPetioleUnderLeafWeight)
4992 bend(self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index)
4993 except Exception
as e:
4995 f
"Failed to bend petiole {petiole_index} of node {node_index} of shoot "
4996 f
"{shoot_id} of plant {plant_id}: {e}")
4999 petiole_index: int) ->
None:
5001 Record one petiole's current centerline as its undeformed rest shape.
5003 :meth:`bendPetioleUnderLeafWeight` always bends from the recorded rest shape, so a
5004 petiole whose centerline has been replaced wholesale -- by
5005 :meth:`setPetioleNodePositions`, for instance -- must have its new shape recorded
5006 before it will droop from it. This also marks the petiole as needing to be bent
5007 again, so the next bend is not skipped as redundant.
5010 plant_id: ID of the plant instance
5011 shoot_id: Shoot index within the plant
5012 node_index: Phytomer index within the shoot
5013 petiole_index: Petiole within the phytomer
5016 ValueError: If any identifier is not a non-negative int
5017 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
5018 RuntimeError: If the native library predates helios-core v1.3.87
5026 plantarch_wrapper.recordPetioleRestShape(
5027 self.
_plantarch_ptr, plant_id, shoot_id, node_index, petiole_index)
5028 except Exception
as e:
5030 f
"Failed to record the rest shape of petiole {petiole_index} of node "
5031 f
"{node_index} of shoot {shoot_id} of plant {plant_id}: {e}")
5034 beta_nu_inclination: float, eccentricity: Optional[float] =
None,
5035 ellipse_rotation_degrees: float = 0.0) ->
None:
5037 Re-aim every leaf of a finished plant so that its leaf angles follow a prescribed distribution.
5039 Inclination is made to follow a Beta distribution. If ``eccentricity`` is given, azimuth
5040 is also made to follow an ellipsoidal distribution; otherwise each leaf keeps its azimuth.
5041 Angles are not sampled per leaf: leaves are ordered by their current angle, weighted by leaf
5042 area, and each takes the angle at its own position in the target distribution. The leaf
5043 arrangement of the plant is therefore kept, a leaf held steeper than its neighbour stays
5044 steeper, and the method can be called repeatedly with changing parameters (for example
5045 once per timestep) to move the canopy smoothly between distributions.
5047 This moves leaves that already exist. To steer leaves as they emerge on a growing plant,
5048 use :meth:`enableLeafAngleDistributionTracking`.
5050 Each leaf is re-aimed about its own base, so it stays attached to its petiole, and the
5051 angles are recorded on the phytomer so they survive an XML round trip.
5054 plant_ids: A single plant ID, or a sequence of plant IDs over which the distribution
5055 is realized as a whole
5056 beta_mu_inclination: First parameter of the Beta inclination distribution; must be
5058 beta_nu_inclination: Second parameter of the Beta inclination distribution; must be
5059 positive. The mean leaf inclination from horizontal is 90 * nu / (mu + nu)
5060 degrees, so raising nu relative to mu makes the leaves steeper.
5061 eccentricity: Eccentricity of the ellipse defining the azimuth distribution, in
5062 [0, 1], where 0 is a uniform azimuth distribution. ``None`` (the default) leaves
5063 leaf azimuths as they are.
5064 ellipse_rotation_degrees: Rotation of that ellipse (degrees); used only when
5065 ``eccentricity`` is given
5068 ValueError: If a plant ID is not a non-negative int, the list is empty, a Beta
5069 parameter is not positive, or eccentricity is outside [0, 1]
5070 PlantArchitectureError: If a plant does not exist
5071 RuntimeError: If the native library predates this binding
5074 >>> plantarch.setLeafAngleDistribution(plant_id, 2.0, 1.5)
5076 multi =
not isinstance(plant_ids, int)
or isinstance(plant_ids, bool)
5080 for name, value
in ((
"beta_mu_inclination", beta_mu_inclination),
5081 (
"beta_nu_inclination", beta_nu_inclination),
5082 (
"eccentricity", 0.0
if eccentricity
is None else eccentricity),
5083 (
"ellipse_rotation_degrees", ellipse_rotation_degrees)):
5084 if isinstance(value, bool)
or not isinstance(value, (int, float))
or not math.isfinite(value):
5085 raise ValueError(f
"{name} must be a finite number, got {value!r}")
5086 values[name] = float(value)
5087 if values[
"beta_mu_inclination"] <= 0
or values[
"beta_nu_inclination"] <= 0:
5088 raise ValueError(
"beta_mu_inclination and beta_nu_inclination must be positive")
5089 if not 0.0 <= values[
"eccentricity"] <= 1.0:
5090 raise ValueError(f
"eccentricity must be between 0 and 1, got {eccentricity}")
5094 plantarch_wrapper.setPlantLeafAngleDistribution(
5096 values[
"beta_nu_inclination"],
5097 None if eccentricity
is None else values[
"eccentricity"],
5098 values[
"ellipse_rotation_degrees"])
5099 except Exception
as e:
5101 f
"Failed to set the leaf angle distribution of {ids}: {e}")
5105 Bending flexibility of the petioles of one phytomer.
5107 The value is drawn from ``PhytomerParameters.petiole.flexibility`` when the phytomer is
5108 created and held for its life. 0 is a rigid petiole.
5111 plant_id: ID of the plant instance
5112 shoot_id: Shoot index within the plant
5113 node_index: Phytomer index within the shoot
5116 Dimensionless petiole flexibility
5119 ValueError: If any identifier is not a non-negative int
5120 PlantArchitectureError: If the plant, shoot or node does not exist
5121 RuntimeError: If the native library predates this binding
5128 return plantarch_wrapper.getPetioleFlexibility(
5130 except Exception
as e:
5132 f
"Failed to get the petiole flexibility of node {node_index} of shoot "
5133 f
"{shoot_id} of plant {plant_id}: {e}")
5136 flexibility: float) ->
None:
5138 Replace the bending flexibility of the petioles of one existing phytomer.
5140 This changes how far the petioles droop under their leaflets' weight without rebuilding
5141 the plant. The geometry does not move until :meth:`bendPetioleUnderLeafWeight` is called
5142 for each petiole (or the plant next grows), which bends from the recorded rest shape, so
5143 raising and lowering the flexibility is reversible. If the leaves have been posed, for
5144 example by :meth:`setLeafAngleDistribution`, bend with ``include_posed_leaves=True``.
5147 :meth:`bendPetioleUnderLeafWeight` does nothing at a flexibility of exactly zero, so
5148 a petiole that has been bent is returned to its rest shape with a small positive
5149 value rather than zero.
5152 plant_id: ID of the plant instance
5153 shoot_id: Shoot index within the plant
5154 node_index: Phytomer index within the shoot
5155 flexibility: Dimensionless petiole flexibility; must be finite and non-negative
5158 ValueError: If an identifier is invalid or ``flexibility`` is not a finite
5160 PlantArchitectureError: If the plant, shoot or node does not exist
5161 RuntimeError: If the native library predates this binding
5164 >>> plantarch.setPetioleFlexibility(plant_id, 0, 3, 2.0)
5165 >>> plantarch.bendPetioleUnderLeafWeight(plant_id, 0, 3, 0)
5169 if (isinstance(flexibility, bool)
or not isinstance(flexibility, (int, float))
5170 or not math.isfinite(flexibility)
or flexibility < 0):
5172 f
"Flexibility must be a finite non-negative number, got {flexibility!r}")
5176 plantarch_wrapper.setPetioleFlexibility(
5177 self.
_plantarch_ptr, plant_id, shoot_id, node_index, float(flexibility))
5178 except Exception
as e:
5180 f
"Failed to set the petiole flexibility of node {node_index} of shoot "
5181 f
"{shoot_id} of plant {plant_id}: {e}")
5188 callback: Optional[Callable[[int, int, int, int, int, int, float],
None]]) ->
None:
5190 Install a Python function that the plugin calls once for every new phytomer of a shoot type.
5192 This is the Python counterpart of assigning
5193 ``phytomer_parameters.phytomer_creation_function`` in C++. It is how a model varies a
5194 phytomer's growth targets with its position on the plant -- for example scaling leaf and
5195 petiole size with node rank -- using the per-phytomer methods such as
5196 :meth:`scaleLeafPrototypeScale`, :meth:`scalePetioleMaxLength` and
5197 :meth:`scaleInternodeMaxLength`.
5199 The callback is called as::
5201 callback(plant_id, shoot_id, node_index, shoot_node_index,
5202 parent_shoot_node_index, shoot_max_nodes, plant_age)
5204 after the plugin has attached the new phytomer to its shoot, so
5205 ``(plant_id, shoot_id, node_index)`` addresses it through any per-phytomer method.
5206 ``node_index`` is the phytomer's index on its shoot. ``shoot_node_index`` is the value the
5207 plugin passes to a C++ creation function, kept for porting C++ models: during growth it
5208 equals ``node_index``, but while a shoot's initial phytomers are built by
5209 :meth:`addBaseStemShoot`, :meth:`appendShoot` or :meth:`addChildShoot` it is the shoot's
5210 initial node count (a one-node base stem passes 1 for phytomer 0).
5211 ``parent_shoot_node_index`` is the node of the parent shoot this shoot grows from,
5212 ``shoot_max_nodes`` the shoot's node limit and ``plant_age`` the plant's age in days.
5213 The return value is ignored.
5215 The callback fires for phytomers created while building shoots as well as during
5216 :meth:`advanceTime`. If it raises, the exception propagates out of the call that created
5217 the phytomer (``advanceTime``, ``addBaseStemShoot``, ...) with its original type, and no
5218 further callbacks run during that call; the plant is left as it was when the exception was
5219 raised, partly grown. Changes the callback makes to the length of an internode prescribed
5220 by :meth:`addShootFromNodePositions` are undone by the plugin.
5222 Which shoots are affected:
5224 - The function is stored on the shoot type, and each shoot copies its type's parameters
5225 when it is created, so shoots created before this call keep whatever function they had.
5226 - Shoots created while a Python callback is installed look it up by label each time, so
5227 replacing the callback or clearing it with ``None`` also takes effect for them.
5228 - ``None`` restores the function the shoot type had before a Python callback was first
5229 installed on it, usually the library one (for example the library ``tomato`` model's
5230 ``mainstem`` function, which rescales every new phytomer's leaves and internode by plant
5231 age). To build without a library function, define a new shoot type from the parameters
5232 (see below), which carries none.
5233 - Redefining an existing label with :meth:`defineShootType` keeps its creation function.
5234 A new label defined from another type's parameters (for example
5235 ``defineShootType("my_stem", getCurrentShootParameters("mainstem"))``) starts with
5236 none, because the parameter dict carries no functions: it inherits neither a Python
5237 callback nor a library function.
5240 shoot_type_label: An existing shoot type label
5241 callback: A callable taking the seven arguments above; the name of a library creation
5242 function (see :meth:`getLibraryPhytomerCreationFunctionNames`), which installs that
5243 native function in place of whatever the type had; or ``None`` to restore the
5244 function the type had before a Python callback was installed
5247 ValueError: If the label is not a non-empty str, the callback is neither callable, a str
5248 nor None, or the name is empty
5249 PlantArchitectureError: If the shoot type does not exist or the name is not a library
5251 RuntimeError: If the native library predates helios-core v1.3.88
5253 if not isinstance(shoot_type_label, str)
or not shoot_type_label:
5255 f
"Shoot type label must be a non-empty str, got {type(shoot_type_label).__name__}")
5256 if isinstance(callback, str):
5259 if callback
is not None and not callable(callback):
5261 f
"Phytomer creation callback must be callable, a library function name or None, "
5262 f
"got {type(callback).__name__}")
5267 plantarch_wrapper.setPhytomerCreationFunction(self.
_plantarch_ptr, shoot_type_label, native_callback)
5268 except Exception
as e:
5270 f
"Failed to set the phytomer creation function of shoot type '{shoot_type_label}': {e}")
5273 callbacks = self.__dict__.setdefault(
'_phytomer_creation_callbacks', {})
5274 if native_callback
is None:
5275 callbacks.pop(shoot_type_label,
None)
5277 callbacks[shoot_type_label] = native_callback
5280 callback: Optional[Callable[[int, int, int, float],
None]]) ->
None:
5282 Install a Python function that the plugin calls for every phytomer of a shoot type on every time step.
5284 This is the Python counterpart of assigning
5285 ``phytomer_parameters.phytomer_callback_function`` in C++. Use it for per-phytomer logic that must
5286 run as the plant develops rather than once at creation (for that, see
5287 :meth:`setPhytomerCreationFunction`), for example adjusting growth targets with phytomer age.
5289 The callback is called as::
5291 callback(plant_id, shoot_id, node_index, phytomer_age)
5293 once per phytomer per :meth:`advanceTime` sub-step, after the phytomer's age has been advanced, so
5294 ``(plant_id, shoot_id, node_index)`` addresses it through any per-phytomer method and
5295 ``phytomer_age`` (days) equals :meth:`getPhytomerAge` at that moment. ``advanceTime`` divides its
5296 interval into sub-steps of at most the shortest phyllochron (and at most 1 day for intervals shorter
5297 than that), so the number of calls per phytomer is the number of sub-steps, not the number of days. It
5298 also runs inside :meth:`buildPlantInstanceFromLibrary` and :meth:`buildPlantCanopyFromLibrary` when
5299 they grow a plant to its starting age. The return value is ignored.
5301 Performance: the cost scales with (phytomers x sub-steps), so keep the callback's own work light,
5302 especially for large populations that change slowly, such as a dormant tree over many days.
5304 If the callback raises, the exception propagates out of the call that advanced time with its original
5305 type, and no further callbacks run during that call; the plant is left partly advanced.
5307 Which phytomers are affected:
5309 - The function is copied into each phytomer when the phytomer is created, so phytomers created
5310 before this call do not call a newly installed function. Install it before building the plant.
5311 - Phytomers created while a Python callback is installed look it up by label on each call, so
5312 replacing the callback or clearing it with ``None`` also takes effect for them.
5313 - ``None`` restores the function the shoot type had before a Python callback was first
5314 installed on it, usually the library one. Clearing a callback that was never installed
5316 - As with :meth:`setPhytomerCreationFunction`, redefining an existing label with
5317 :meth:`defineShootType` keeps the function, but a new label defined from another type's parameter
5318 dict starts with none.
5321 shoot_type_label: An existing shoot type label
5322 callback: A callable taking the four arguments above; the name of a library callback
5323 function (see :meth:`getLibraryPhytomerCallbackFunctionNames`), which installs that
5324 native function in place of whatever the type had; or ``None`` to restore the
5325 function the type had before a Python callback was installed
5328 ValueError: If the label is not a non-empty str, the callback is neither callable, a str
5329 nor None, or the name is empty
5330 PlantArchitectureError: If the shoot type does not exist or the name is not a library
5332 RuntimeError: If the native library predates this function
5334 if not isinstance(shoot_type_label, str)
or not shoot_type_label:
5336 f
"Shoot type label must be a non-empty str, got {type(shoot_type_label).__name__}")
5337 if isinstance(callback, str):
5340 if callback
is not None and not callable(callback):
5342 f
"Phytomer callback function must be callable, a library function name or None, "
5343 f
"got {type(callback).__name__}")
5348 plantarch_wrapper.setPhytomerCallbackFunction(self.
_plantarch_ptr, shoot_type_label, native_callback)
5349 except Exception
as e:
5351 f
"Failed to set the phytomer callback function of shoot type '{shoot_type_label}': {e}")
5354 callbacks = self.__dict__.setdefault(
'_phytomer_callbacks', {})
5355 if native_callback
is None:
5356 callbacks.pop(shoot_type_label,
None)
5358 callbacks[shoot_type_label] = native_callback
5361 kind =
"creation" if creation
else "callback"
5362 if not function_name:
5363 raise ValueError(f
"Library phytomer {kind} function name cannot be empty")
5365 setter = (plantarch_wrapper.setPhytomerCreationFunctionByName
if creation
5366 else plantarch_wrapper.setPhytomerCallbackFunctionByName)
5369 except RuntimeError:
5371 except Exception
as e:
5373 f
"Failed to set the phytomer {kind} function of shoot type '{shoot_type_label}' "
5374 f
"to library function '{function_name}': {e}")
5376 registry =
'_phytomer_creation_callbacks' if creation
else '_phytomer_callbacks'
5377 self.__dict__.setdefault(registry, {}).pop(shoot_type_label,
None)
5380 if not isinstance(shoot_type_label, str)
or not shoot_type_label:
5382 f
"Shoot type label must be a non-empty str, got {type(shoot_type_label).__name__}")
5384 getter = (plantarch_wrapper.getPhytomerCreationFunctionName
if creation
5385 else plantarch_wrapper.getPhytomerCallbackFunctionName)
5388 except RuntimeError:
5390 except Exception
as e:
5391 kind =
"creation" if creation
else "callback"
5393 f
"Failed to get the phytomer {kind} function of shoot type '{shoot_type_label}': {e}")
5397 Get the name of the library creation function a shoot type carries.
5399 Use it to carry a library model's creation function over to a new shoot type, which
5400 :meth:`defineShootType` starts without::
5402 params = plantarch.getCurrentShootParameters("proleptic")
5403 plantarch.defineShootType("my_shoot", params)
5404 name = plantarch.getPhytomerCreationFunctionName("proleptic")
5406 plantarch.setPhytomerCreationFunction("my_shoot", name)
5409 shoot_type_label: An existing shoot type label
5412 The library function's name, or ``None`` if the shoot type has no creation function
5416 ValueError: If the label is not a non-empty str
5417 PlantArchitectureError: If the shoot type does not exist
5418 RuntimeError: If the native library predates this function
5424 Get the name of the library per-timestep callback function a shoot type carries.
5427 shoot_type_label: An existing shoot type label
5430 The library function's name, or ``None`` if the shoot type has no callback function
5434 ValueError: If the label is not a non-empty str
5435 PlantArchitectureError: If the shoot type does not exist
5436 RuntimeError: If the native library predates this function
5443 List the library phytomer creation functions that :meth:`setPhytomerCreationFunction` accepts by name.
5445 These are the functions the library plant models use, for example
5446 ``"AlmondPhytomerCreationFunction"`` or ``"TomatoPhytomerCreationFunction"``.
5449 Function names, sorted
5452 RuntimeError: If the native library predates this function
5454 return plantarch_wrapper.getLibraryPhytomerCreationFunctionNames()
5459 List the library per-timestep callback functions that :meth:`setPhytomerCallbackFunction` accepts by name.
5461 These are the functions the library plant models use, for example
5462 ``"AlmondSpurPhytomerCallbackFunction"`` (grows spurs on a model that defines a ``spur``
5463 shoot type) or ``"GrapevinePhytomerCallbackFunction"`` (pulls fruit-zone leaves).
5466 Function names, sorted
5469 RuntimeError: If the native library predates this function
5471 return plantarch_wrapper.getLibraryPhytomerCallbackFunctionNames()
5474 """Wrap callback so an exception is stored for re-raising instead of being lost inside ctypes."""
5475 owner = weakref.ref(self)
5477 def trampoline(plant_id, shoot_id, node_index, phytomer_age):
5479 if plantarch
is None or getattr(plantarch,
'_pending_callback_error',
None)
is not None:
5481 plantarch._phytomer_callback_depth = getattr(plantarch,
'_phytomer_callback_depth', 0) + 1
5483 callback(plant_id, shoot_id, node_index, phytomer_age)
5484 except BaseException
as e:
5485 plantarch._pending_callback_error = e
5488 plantarch._phytomer_callback_depth -= 1
5491 return plantarch_wrapper.PHYTOMER_CALLBACK(trampoline)
5494 """Wrap callback so an exception is stored for re-raising instead of being lost inside ctypes."""
5495 owner = weakref.ref(self)
5497 def trampoline(plant_id, shoot_id, node_index, shoot_node_index, parent_shoot_node_index,
5498 shoot_max_nodes, plant_age):
5500 if plantarch
is None or getattr(plantarch,
'_pending_callback_error',
None)
is not None:
5502 plantarch._phytomer_callback_depth = getattr(plantarch,
'_phytomer_callback_depth', 0) + 1
5504 callback(int(plant_id), int(shoot_id), int(node_index), int(shoot_node_index),
5505 int(parent_shoot_node_index), int(shoot_max_nodes), float(plant_age))
5506 except BaseException
as e:
5507 plantarch._pending_callback_error = e
5510 plantarch._phytomer_callback_depth -= 1
5513 return plantarch_wrapper.PHYTOMER_CREATION_CALLBACK(trampoline)
5516 """Raise if called from a Python phytomer creation or callback function.
5518 The native plugin skips these operations inside a callback and only prints a warning, which
5519 disableMessages() suppresses, so the call would otherwise appear to succeed.
5521 if getattr(self,
'_phytomer_callback_depth', 0) > 0:
5523 f
"{method_name}() cannot be called from inside a phytomer creation or callback function, "
5524 f
"because it changes plant structure while the plant is being grown. Call it from outside "
5525 f
"the function, for example between calls to advanceTime(). pruneBranch() is the only "
5526 f
"structural edit a callback can make; it takes effect at the end of the current time step."
5530 """Raise, and clear, an exception stored by a phytomer creation or callback function."""
5531 pending = getattr(self,
'_pending_callback_error',
None)
5535 raise pending
from native_error
5541 def _phytomerCall(self, description: str, plant_id: int, shoot_id: int, node_index: int, fn, *args):
5542 """Call a per-phytomer wrapper, turning an out-of-range index into ValueError."""
5545 return fn(self.
_plantarch_ptr, plant_id, shoot_id, node_index, *args)
5546 except HeliosInvalidArgumentError
as e:
5547 raise ValueError(str(e))
from None
5548 except Exception
as e:
5550 f
"Failed to {description} of node {node_index} of shoot {shoot_id} of plant {plant_id}: {e}")
5552 def setInternodeMaxLength(self, plant_id: int, shoot_id: int, node_index: int, length: float) ->
None:
5554 Set the fully-elongated length of one existing internode.
5556 Only this phytomer's target changes; compare :meth:`setShootInternodeLengthMax`, which
5557 sets the target for internodes the shoot has yet to produce. A target below the
5558 internode's present length shortens it immediately; a larger target leaves the present
5559 length alone and the internode grows toward it on later :meth:`advanceTime` calls.
5562 plant_id: ID of the plant instance
5563 shoot_id: Shoot index within the plant
5564 node_index: Phytomer index within the shoot
5565 length: Fully-elongated internode length in meters; must be positive and finite
5568 ValueError: If an identifier is invalid, ``length`` is not positive and finite, or ``node_index`` is past the end of the shoot
5569 PlantArchitectureError: If the plant or shoot does not exist
5570 RuntimeError: If the native library predates helios-core v1.3.88
5574 if isinstance(length, bool)
or not isinstance(length, (int, float))
or not math.isfinite(length) \
5576 raise ValueError(f
"Internode max length must be a positive, finite number, got {length!r}")
5577 self.
_phytomerCall(
"set the internode max length", plant_id, shoot_id, node_index,
5578 plantarch_wrapper.setInternodeMaxLength, float(length))
5582 Scale the fully-elongated length of one existing internode.
5584 As :meth:`setInternodeMaxLength`, relative to the current target: a factor below one
5585 shortens an internode that is already longer than its new target, and a factor above one
5586 lets it grow further.
5589 plant_id: ID of the plant instance
5590 shoot_id: Shoot index within the plant
5591 node_index: Phytomer index within the shoot
5592 scale_factor: Factor to scale the fully-elongated length by; must be positive
5595 ValueError: If an identifier is invalid, ``scale_factor`` is not positive, or ``node_index`` is past the end of the shoot
5596 PlantArchitectureError: If the plant or shoot does not exist
5597 RuntimeError: If the native library predates helios-core v1.3.88
5602 self.
_phytomerCall(
"scale the internode max length", plant_id, shoot_id, node_index,
5603 plantarch_wrapper.scaleInternodeMaxLength, scale_factor)
5606 petiole_index: Optional[int] =
None) ->
None:
5608 Scale a phytomer's leaves now: both the blades' present size and the size they grow toward.
5610 The leaves are rescaled about their bases immediately and keep their expansion fraction,
5611 so a half-expanded leaf stays half-expanded toward a proportionally larger or smaller
5612 mature size. This is what a C++ phytomer creation function does to size a new leaf by its
5613 position on the plant. Contrast :meth:`scaleLeafSizeMax`, which moves only the target and
5614 leaves the blades as they are.
5617 plant_id: ID of the plant instance
5618 shoot_id: Shoot index within the plant
5619 node_index: Phytomer index within the shoot
5620 scale_factor: Factor to scale the leaves by; must be positive
5621 petiole_index: Scale only the leaves of this petiole; ``None`` for every petiole
5624 ValueError: If an identifier is invalid, ``scale_factor`` is not positive, or an index is out of range
5625 PlantArchitectureError: If the plant or shoot does not exist
5626 RuntimeError: If the native library predates helios-core v1.3.88
5631 if petiole_index
is not None:
5633 self.
_phytomerCall(
"scale the leaf prototype", plant_id, shoot_id, node_index,
5634 plantarch_wrapper.scaleLeafPrototypeScale, scale_factor, petiole_index)
5637 sd_deg: Optional[float] =
None) ->
None:
5639 Set the phyllotactic angle that one shoot's next phytomers are created with.
5641 Each new phytomer draws its angle from its shoot's own copy of the shoot parameters, so
5642 calling this before a time step sets the angle of the phytomers produced during that step
5643 without touching any phytomer already built, any other shoot, or the shoot type. Calling
5644 it before every step drives a node-by-node angle sequence.
5646 With ``sd_deg`` omitted the angle is the constant ``mean_deg``. With ``sd_deg`` given the
5647 angle is drawn from a normal distribution, which draws from the Context random generator
5648 for every new phytomer even when ``sd_deg`` is 0 -- matching a C++ caller of
5649 ``phyllotactic_angle.normalDistribution(mean, sd)``, which matters when reproducing a C++
5653 plant_id: ID of the plant instance
5654 shoot_id: Shoot index within the plant
5655 mean_deg: Phyllotactic angle, or its mean, in degrees
5656 sd_deg: Standard deviation in degrees (non-negative), or ``None`` for a constant
5659 ValueError: If an identifier is invalid or an angle is not finite, or ``sd_deg`` is negative
5660 PlantArchitectureError: If the plant or shoot does not exist
5661 RuntimeError: If the native library predates helios-core v1.3.88
5664 if isinstance(mean_deg, bool)
or not isinstance(mean_deg, (int, float))
or not math.isfinite(mean_deg):
5665 raise ValueError(f
"Mean phyllotactic angle must be a finite number, got {mean_deg!r}")
5666 if sd_deg
is not None and (isinstance(sd_deg, bool)
or not isinstance(sd_deg, (int, float))
5667 or not math.isfinite(sd_deg)
or sd_deg < 0):
5669 f
"Phyllotactic angle standard deviation must be a finite, non-negative number or None, got {sd_deg!r}")
5672 plantarch_wrapper.setShootPhyllotacticAngle(
5673 self.
_plantarch_ptr, plant_id, shoot_id, float(mean_deg),
None if sd_deg
is None else float(sd_deg))
5674 except Exception
as e:
5676 f
"Failed to set the phyllotactic angle of shoot {shoot_id} of plant {plant_id}: {e}")
5682 def getPhytomerAge(self, plant_id: int, shoot_id: int, node_index: int) -> float:
5684 Age of one phytomer in days, counted from its creation.
5687 ValueError: If an identifier is invalid or ``node_index`` is past the end of the shoot
5688 PlantArchitectureError: If the plant or shoot does not exist
5689 RuntimeError: If the native library predates helios-core v1.3.88
5693 return self.
_phytomerCall(
"get the age", plant_id, shoot_id, node_index, plantarch_wrapper.getPhytomerAge)
5695 def getInternodeLength(self, plant_id: int, shoot_id: int, node_index: int) -> float:
5697 Present length of one internode in meters, measured along its node positions.
5700 ValueError: If an identifier is invalid or ``node_index`` is past the end of the shoot
5701 PlantArchitectureError: If the plant or shoot does not exist
5702 RuntimeError: If the native library predates helios-core v1.3.88
5706 return self.
_phytomerCall(
"get the internode length", plant_id, shoot_id, node_index,
5707 plantarch_wrapper.getInternodeLength)
5711 Radius in meters at the base of one internode.
5714 ValueError: If an identifier is invalid or ``node_index`` is past the end of the shoot
5715 PlantArchitectureError: If the plant or shoot does not exist
5716 RuntimeError: If the native library predates helios-core v1.3.88
5720 return self.
_phytomerCall(
"get the internode radius", plant_id, shoot_id, node_index,
5721 plantarch_wrapper.getInternodeRadius)
5725 Positions of one internode's nodes, from its base to its tip.
5727 The first position is the tip of the previous internode on the shoot.
5730 ValueError: If an identifier is invalid or ``node_index`` is past the end of the shoot
5731 PlantArchitectureError: If the plant or shoot does not exist
5732 RuntimeError: If the native library predates helios-core v1.3.88
5736 points = self.
_phytomerCall(
"get the internode node positions", plant_id, shoot_id, node_index,
5737 plantarch_wrapper.getInternodeNodePositions)
5740 def getInternodeAxisVector(self, plant_id: int, shoot_id: int, node_index: int, stem_fraction: float) -> vec3:
5742 Unit direction of one internode at a fraction of its length.
5745 plant_id: ID of the plant instance
5746 shoot_id: Shoot index within the plant
5747 node_index: Phytomer index within the shoot
5748 stem_fraction: Position along the internode, 0 at its base and 1 at its tip
5751 ValueError: If an identifier is invalid, ``stem_fraction`` is outside [0, 1], or ``node_index`` is past the end of the shoot
5752 PlantArchitectureError: If the plant or shoot does not exist
5753 RuntimeError: If the native library predates helios-core v1.3.88
5758 return vec3(*self.
_phytomerCall(
"get the internode axis", plant_id, shoot_id, node_index,
5759 plantarch_wrapper.getInternodeAxisVector, stem_fraction))
5761 def getPetioleAxisVector(self, plant_id: int, shoot_id: int, node_index: int, stem_fraction: float,
5762 petiole_index: int) -> vec3:
5764 Unit direction of one petiole at a fraction of its length.
5767 plant_id: ID of the plant instance
5768 shoot_id: Shoot index within the plant
5769 node_index: Phytomer index within the shoot
5770 stem_fraction: Position along the petiole, 0 at its base and 1 at its tip
5771 petiole_index: Petiole within the phytomer
5774 ValueError: If an identifier is invalid, ``stem_fraction`` is outside [0, 1], or an index is out of range (a phytomer whose leaf was shed has no petioles)
5775 PlantArchitectureError: If the plant or shoot does not exist
5776 RuntimeError: If the native library predates helios-core v1.3.88
5782 return vec3(*self.
_phytomerCall(
"get the petiole axis", plant_id, shoot_id, node_index,
5783 plantarch_wrapper.getPetioleAxisVector, stem_fraction, petiole_index))
5785 def getPetioleVertices(self, plant_id: int, shoot_id: int, node_index: int, petiole_index: int) -> List[vec3]:
5787 Centerline vertices of one petiole, from its base at the node to its tip.
5790 ValueError: If an identifier is invalid or an index is out of range
5791 PlantArchitectureError: If the plant or shoot does not exist
5792 RuntimeError: If the native library predates helios-core v1.3.88
5797 points = self.
_phytomerCall(
"get the petiole vertices", plant_id, shoot_id, node_index,
5798 plantarch_wrapper.getPetioleVertices, petiole_index)
5799 return [
vec3(*p)
for p
in points]
5801 def getPetioleRadii(self, plant_id: int, shoot_id: int, node_index: int, petiole_index: int) -> List[float]:
5803 Radius in meters of one petiole at each of its centerline vertices (see :meth:`getPetioleVertices`).
5806 ValueError: If an identifier is invalid or an index is out of range
5807 PlantArchitectureError: If the plant or shoot does not exist
5808 RuntimeError: If the native library predates helios-core v1.3.88
5813 return self.
_phytomerCall(
"get the petiole radii", plant_id, shoot_id, node_index,
5814 plantarch_wrapper.getPetioleRadii, petiole_index)
5818 Object IDs of one phytomer's leaves, grouped by petiole.
5820 Returns one list per petiole with one object ID per leaf (leaflet) on that petiole, in the
5821 order :meth:`getLeafBasePosition` indexes them. A phytomer whose leaves were shed returns
5825 ValueError: If an identifier is invalid or ``node_index`` is past the end of the shoot
5826 PlantArchitectureError: If the plant or shoot does not exist
5827 RuntimeError: If the native library predates helios-core v1.3.88
5831 return self.
_phytomerCall(
"get the leaf object IDs", plant_id, shoot_id, node_index,
5832 plantarch_wrapper.getPhytomerLeafObjectIDs)
5834 def getLeafBasePosition(self, plant_id: int, shoot_id: int, node_index: int, petiole_index: int,
5835 leaf_index: int) -> vec3:
5837 Position where one leaf (leaflet) attaches to its petiole.
5840 ValueError: If an identifier is invalid or an index is out of range
5841 PlantArchitectureError: If the plant or shoot does not exist
5842 RuntimeError: If the native library predates helios-core v1.3.88
5848 return vec3(*self.
_phytomerCall(
"get the leaf base position", plant_id, shoot_id, node_index,
5849 plantarch_wrapper.getLeafBasePosition, petiole_index, leaf_index))
5857 Beta leaf inclination distribution ``(mu, nu)`` that a library plant model declares.
5859 Plants built from a model that declares a distribution have their leaf inclinations
5860 steered toward it automatically. Returns ``(0.0, 0.0)`` when the model declares none.
5863 ValueError: If ``plant_model_name`` is not a non-empty str
5864 PlantArchitectureError: If the model is not in the library
5865 RuntimeError: If the native library predates helios-core v1.3.88
5870 return plantarch_wrapper.getPlantModelLeafInclinationDistribution(self.
_plantarch_ptr, plant_model_name)
5871 except Exception
as e:
5873 f
"Failed to get the leaf inclination distribution of plant model '{plant_model_name}': {e}")
5876 beta_nu: float) ->
None:
5878 Set or clear the Beta leaf inclination distribution of a library plant model.
5880 Plants built from the model afterwards are steered toward the distribution. Two positive
5881 values set it; ``(0, 0)`` clears it, restoring the model's unsteered leaf angles.
5884 ValueError: If the name is not a non-empty str, or the Beta parameters are not both positive or both zero
5885 PlantArchitectureError: If the model is not in the library
5886 RuntimeError: If the native library predates helios-core v1.3.88
5889 for name, v
in ((
"beta_mu", beta_mu), (
"beta_nu", beta_nu)):
5890 if isinstance(v, bool)
or not isinstance(v, (int, float))
or not math.isfinite(v)
or v < 0:
5891 raise ValueError(f
"Beta parameter {name} must be a finite, non-negative number, got {v!r}")
5892 if (beta_mu > 0) != (beta_nu > 0):
5894 f
"Beta parameters must both be positive to set a distribution or both zero to clear it, "
5895 f
"got ({beta_mu}, {beta_nu})")
5898 plantarch_wrapper.setPlantModelLeafInclinationDistribution(
5899 self.
_plantarch_ptr, plant_model_name, float(beta_mu), float(beta_nu))
5900 except Exception
as e:
5902 f
"Failed to set the leaf inclination distribution of plant model '{plant_model_name}': {e}")
5906 Whether a library plant model declares a leaf inclination distribution.
5909 ValueError: If ``plant_model_name`` is not a non-empty str
5910 PlantArchitectureError: If the model is not in the library
5911 RuntimeError: If the native library predates helios-core v1.3.88
5916 return plantarch_wrapper.doesPlantModelDeclareLeafInclinationDistribution(
5918 except Exception
as e:
5920 f
"Failed to query the leaf inclination distribution of plant model '{plant_model_name}': {e}")
5924 Nitrogen (g N) in a plant's available pool, awaiting allocation to its organs.
5927 ValueError: If ``plant_id`` is not a non-negative int
5928 PlantArchitectureError: If the plant does not exist
5929 RuntimeError: If the native library predates helios-core v1.3.88
5935 except Exception
as e:
5940 One-sided blade area (m²) of a single leaf object.
5942 A leaf object can carry a petiolule -- the stalk joining a leaflet to its rachis
5943 (see :attr:`pyhelios.plant_architecture_params.LeafPrototype.build_petiolule`) --
5944 whose primitives are labelled ``"petiolule"`` rather than ``"leaf"``. This reports
5945 the area of the object without them, which is what every leaf-area quantity of the
5946 plugin (:meth:`sumPlantLeafArea`, :meth:`getPlantLeafAreas`) means as of
5947 helios-core 1.3.89. ``Context.getObjectArea()`` on the same object includes the
5951 leaf_object_id: Object ID of the leaf (e.g. from :meth:`getPlantLeafObjectIDs`)
5957 ValueError: If ``leaf_object_id`` is not a non-negative int
5958 PlantArchitectureError: If the object does not exist
5959 RuntimeError: If the native library predates helios-core v1.3.89
5961 if isinstance(leaf_object_id, bool)
or not isinstance(leaf_object_id, int):
5963 f
"Leaf object ID must be a non-negative int, got {type(leaf_object_id).__name__}")
5964 if leaf_object_id < 0:
5965 raise ValueError(
"Leaf object ID must be non-negative")
5968 return plantarch_wrapper.getLeafBladeArea(self.
_plantarch_ptr, leaf_object_id)
5969 except Exception
as e:
5974 Local light exposure at a point from a plant's shadow-propagation grid.
5976 Exposure runs from 0 (fully shaded) to 1 (unshaded), and is the quantity that drives
5977 shade-based branch shedding at each dormancy boundary in parameter-based growth
5978 (after Palubicki et al. 2009): branches deep in the crown interior see a low
5979 exposure while branches on the crown surface do not. Returns 1 when the plant has no
5980 canopy yet, or when the shadow grid is disabled because the carbohydrate model is
5981 active. Intended for inspection and testing; it is a cheap surrogate for light
5982 interception, not a radiation calculation.
5985 plant_id: ID of the plant
5986 position: Query position in world coordinates
5989 Local light exposure in [0, 1]
5992 ValueError: If ``plant_id`` is not a non-negative int or ``position`` is not a vec3
5993 PlantArchitectureError: If the plant does not exist
5994 RuntimeError: If the native library predates helios-core v1.3.89
5997 if not isinstance(position, vec3):
5998 raise ValueError(f
"Position must be a vec3, got {type(position).__name__}")
6001 return plantarch_wrapper.getShadowLightExposureAtPoint(
6002 self.
_plantarch_ptr, plant_id, (position.x, position.y, position.z))
6003 except Exception
as e:
6005 f
"Failed to get the shadow-grid light exposure of plant {plant_id}: {e}")
6009 Local light exposure at a point under the plant's most recent seasonal peak canopy.
6011 Like :meth:`getShadowLightExposureAtPoint`, but read from the shadow grid captured
6012 when the plant's leaf area last reached its seasonal maximum rather than from the
6013 current canopy. Through dormancy, when a deciduous plant has no leaves and the current
6014 exposure is 1 everywhere, this still gives the light each point stood in over the past
6015 summer. The "apple" and "apple_fruitingwall" library models use it to decide how many
6016 flowers each bud forms.
6019 plant_id: ID of the plant
6020 position: Query position in world coordinates
6023 Local light exposure in [0, 1]; 1 before the plant's first season in leaf, or when
6024 the carbohydrate model is active
6027 ValueError: If ``plant_id`` is not a non-negative int or ``position`` is not a vec3
6028 PlantArchitectureError: If the plant does not exist
6029 RuntimeError: If the native library predates helios-core v1.3.93
6032 if not isinstance(position, vec3):
6033 raise ValueError(f
"Position must be a vec3, got {type(position).__name__}")
6036 return plantarch_wrapper.getSeasonPeakLightExposureAtPoint(
6037 self.
_plantarch_ptr, plant_id, (position.x, position.y, position.z))
6038 except Exception
as e:
6040 f
"Failed to get the season-peak light exposure of plant {plant_id}: {e}")
6044 Get the build parameters a plant was built with.
6046 These are the ``build_parameters`` given to :meth:`buildPlantInstanceFromLibrary` or
6047 :meth:`buildPlantCanopyFromLibrary`. A plant keeps them for its life (some, such as the
6048 sorghum ``tiller_count``, are read as it grows), and they are saved and restored by
6049 :meth:`writePlantStructureXML` / :meth:`readPlantStructureXML`. Parameters that were not
6050 given are absent and take the model's default where they are read.
6053 plant_id: ID of the plant
6056 Dict of build parameter names to values; empty for a plant not built from the
6057 library or built without parameters
6060 ValueError: If ``plant_id`` is not a non-negative int
6061 PlantArchitectureError: If the plant does not exist
6062 RuntimeError: If the native library predates helios-core v1.3.93
6065 >>> plantarch.loadPlantModelFromLibrary("sorghum")
6066 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(
6067 ... vec3(0, 0, 0), 30.0, build_parameters={"tiller_count": 2})
6068 >>> plantarch.getPlantBuildParameters(plant_id)
6069 {'tiller_count': 2.0}
6074 return plantarch_wrapper.getPlantBuildParameters(self.
_plantarch_ptr, plant_id)
6075 except Exception
as e:
6084 Get the name of the plant model a plant was built from.
6086 For a plant built with :meth:`buildPlantInstanceFromLibrary` this is the label passed
6087 to :meth:`loadPlantModelFromLibrary` (e.g. ``"bean"``). A plant assembled by hand with
6088 :meth:`addPlantInstance` reports whatever name the native library assigned to it.
6091 plant_id: ID of the plant instance
6097 ValueError: If ``plant_id`` is not a non-negative int
6098 PlantArchitectureError: If the plant does not exist
6099 RuntimeError: If the native library predates this binding
6102 >>> plantarch.loadPlantModelFromLibrary("bean")
6103 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(vec3(0, 0, 0), 20)
6104 >>> plantarch.getPlantName(plant_id)
6110 return plantarch_wrapper.getPlantName(self.
_plantarch_ptr, plant_id)
6111 except Exception
as e:
6116 Determine the phenological stage of a plant.
6118 The stage is decided in this order: ``"dormant"`` if every shoot is dormant;
6119 ``"reproductive"`` if the plant carries any flower or fruit; ``"senescent"`` if it is
6120 past 90% of the time from dormancy break to dormancy (only when a dormancy threshold
6121 is set, see :meth:`setPlantPhenologicalThresholds`); otherwise ``"vegetative"``.
6124 plant_id: ID of the plant instance
6127 One of ``"dormant"``, ``"vegetative"``, ``"reproductive"`` or ``"senescent"``
6130 ValueError: If ``plant_id`` is not a non-negative int
6131 PlantArchitectureError: If the plant does not exist
6132 RuntimeError: If the native library predates this binding
6135 >>> plantarch.determinePhenologyStage(plant_id)
6141 return plantarch_wrapper.determinePhenologyStage(self.
_plantarch_ptr, plant_id)
6142 except Exception
as e:
6147 Get the current number of nodes (phytomers) on one shoot.
6150 plant_id: ID of the plant instance
6151 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`; 0 is the base stem)
6154 Number of nodes on the shoot
6157 ValueError: If ``plant_id`` or ``shoot_id`` is not a non-negative int
6158 PlantArchitectureError: If the plant does not exist or the shoot ID is out of range
6159 RuntimeError: If the native library predates this binding
6162 >>> plantarch.getShootNodeCount(plant_id, 0)
6168 return plantarch_wrapper.getShootNodeCount(self.
_plantarch_ptr, plant_id, shoot_id)
6169 except Exception
as e:
6171 f
"Failed to get the node count of shoot {shoot_id} on plant {plant_id}: {e}")
6175 Get the height of a plant's main stem in meters.
6177 The vertical extent of the base stem shoot, extended by any shoot of the same rank
6178 appended to its tip. Unlike :meth:`getPlantHeight`, leaves, branches and
6179 inflorescences reaching above the stem are not counted.
6182 plant_id: ID of the plant instance
6185 Vertical extent of the main stem (m)
6188 ValueError: If ``plant_id`` is not a non-negative int
6189 PlantArchitectureError: If the plant does not exist or has no stem geometry
6190 RuntimeError: If the native library predates this binding
6193 >>> plantarch.getPlantStemHeight(plant_id)
6199 return plantarch_wrapper.getPlantStemHeight(self.
_plantarch_ptr, plant_id)
6200 except Exception
as e:
6205 Get the base position of one plant or of several plants.
6207 For a plant with no shoots yet, this is the position at which its first shoot will be
6211 plant_ids: A single plant ID, or a sequence of plant IDs
6214 A vec3 for a single plant ID, or a list of vec3 (in the order given) for a sequence
6217 ValueError: If a plant ID is not a non-negative int or the sequence is empty
6218 PlantArchitectureError: If a plant does not exist
6219 RuntimeError: If the native library predates this binding
6222 >>> plantarch.getPlantBasePosition(plant_id)
6225 multi =
not isinstance(plant_ids, int)
or isinstance(plant_ids, bool)
6230 return [
vec3(*p)
for p
in plantarch_wrapper.getPlantBasePositions(self.
_plantarch_ptr, ids)]
6232 except Exception
as e:
6237 Set the base position of a plant, moving the plant if it has already been built.
6239 For a plant with no shoots, this only stores the position, and the first shoot added
6240 with :meth:`addBaseStemShoot` is placed there. For a plant that already has shoots, the
6241 whole plant is translated by the difference between the new and old base position --
6242 its geometry, stored organ positions, per-plant attraction points and cached shading
6243 state -- and later growth continues from the moved position.
6246 Do not also translate the plant's objects in the Context (e.g. with
6247 ``Context.translateObject``), or the plant will be moved twice. This cannot be
6248 called from inside a phytomer creation or callback function.
6251 plant_id: ID of the plant instance
6252 base_position: New base position (vec3)
6255 ValueError: If ``plant_id`` is not a non-negative int or ``base_position`` is not a vec3
6256 PlantArchitectureError: If the plant does not exist
6257 RuntimeError: If the native library predates this binding
6260 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0)
6261 >>> plantarch.setPlantBasePosition(plant_id, vec3(1, 2, 0))
6264 if not isinstance(base_position, vec3):
6265 raise ValueError(f
"Base position must be a vec3, got {type(base_position).__name__}")
6268 plantarch_wrapper.setPlantBasePosition(
6269 self.
_plantarch_ptr, plant_id, (base_position.x, base_position.y, base_position.z))
6270 except Exception
as e:
6275 Get the object IDs of a plant's internode (tube) objects.
6277 Each shoot is one tube object spanning all its internodes, so the list has one entry
6278 per shoot that has geometry. The structural counterpart to
6279 :meth:`getPlantLeafObjectIDs`.
6282 plant_id: ID of the plant instance
6283 shoot_type_label: If given, only internodes of shoots of this type
6284 (e.g. ``"stem"``) are returned
6287 List of internode object IDs
6290 ValueError: If ``plant_id`` is not a non-negative int or ``shoot_type_label`` is
6292 PlantArchitectureError: If the plant does not exist, or no shoot on it has the
6293 given shoot type label
6294 RuntimeError: If the native library predates this binding
6297 >>> stem_ids = plantarch.getPlantInternodeObjectIDs(plant_id)
6300 if shoot_type_label
is not None and (
not isinstance(shoot_type_label, str)
or not shoot_type_label):
6302 f
"Shoot type label must be a non-empty str, got {type(shoot_type_label).__name__}")
6305 return plantarch_wrapper.getPlantInternodeObjectIDs(self.
_plantarch_ptr, plant_id, shoot_type_label)
6306 except Exception
as e:
6310 multi =
not isinstance(plant_ids, int)
or isinstance(plant_ids, bool)
6312 if isinstance(num_bins, bool)
or not isinstance(num_bins, int)
or num_bins < 1:
6313 raise ValueError(f
"num_bins must be a positive int, got {num_bins!r}")
6314 if not isinstance(normalize, bool):
6315 raise ValueError(f
"normalize must be a bool, got {type(normalize).__name__}")
6318 return getattr(plantarch_wrapper, fn_name)(self.
_plantarch_ptr, ids, num_bins, normalize)
6319 except Exception
as e:
6323 normalize: bool =
True) -> List[float]:
6325 Get the leaf-area-weighted leaf inclination angle distribution of one or more plants.
6327 Each leaf blade facet adds its area to the bin of its inclination (the angle between
6328 the facet normal and vertical, folded into 0-90 degrees). Petiolules are excluded.
6329 Several plants are pooled into one histogram.
6332 plant_ids: A single plant ID, or a sequence of plant IDs to pool
6333 num_bins: Number of histogram bins, evenly spaced between 0 and 90 degrees
6334 normalize: If True (default), the bins sum to 1; otherwise they hold leaf area (m^2).
6335 A plant with no leaves gives all-zero bins either way.
6338 List of ``num_bins`` values
6341 ValueError: If a plant ID is invalid, the sequence is empty, ``num_bins`` is not a
6342 positive int, or ``normalize`` is not a bool
6343 PlantArchitectureError: If a plant does not exist
6344 RuntimeError: If the native library predates this binding
6347 >>> plantarch.getPlantLeafInclinationAngleDistribution(plant_id, 9)
6350 num_bins, normalize,
"inclination")
6353 normalize: bool =
True) -> List[float]:
6355 Get the leaf-area-weighted leaf azimuth angle distribution of one or more plants.
6357 Each leaf blade facet adds its area to the bin of its normal's azimuth (0-360
6358 degrees). Petiolules are excluded. Several plants are pooled into one histogram.
6361 plant_ids: A single plant ID, or a sequence of plant IDs to pool
6362 num_bins: Number of histogram bins, evenly spaced between 0 and 360 degrees
6363 normalize: If True (default), the bins sum to 1; otherwise they hold leaf area (m^2).
6364 A plant with no leaves gives all-zero bins either way.
6367 List of ``num_bins`` values
6370 ValueError: If a plant ID is invalid, the sequence is empty, ``num_bins`` is not a
6371 positive int, or ``normalize`` is not a bool
6372 PlantArchitectureError: If a plant does not exist
6373 RuntimeError: If the native library predates this binding
6376 >>> plantarch.getPlantLeafAzimuthAngleDistribution([p1, p2], 12)
6379 num_bins, normalize,
"azimuth")
6382 ) -> Union[dict, PhytomerParameters]:
6384 Draw one realization of a set of phytomer parameters.
6386 Mirrors the native ``PhytomerParameters::resample()``, which each phytomer calls
6387 once when it is created (helios-core 1.3.89+) to draw its own values of every
6388 distributed parameter. The draw uses the Context's random generator, so it follows
6389 ``Context.seedRandomGenerator()``. Every field with a distribution (uniform, normal,
6390 weibull, discrete values) comes back as a constant holding the value drawn for it;
6391 constant fields, and fields that are not random parameters, are returned unchanged
6392 and consume no random draws. The input is not modified.
6395 phytomer_parameters: A :class:`pyhelios.plant_architecture_params.PhytomerParameters`
6396 or its dict form (e.g. ``getCurrentShootParameters(label)["phytomer_parameters"]``)
6399 The realization, of the same type as the input
6402 ValueError: If ``phytomer_parameters`` is not a dict or PhytomerParameters
6403 PlantArchitectureError: If the parameters cannot be parsed natively
6404 RuntimeError: If the native library predates helios-core v1.3.89
6406 typed = isinstance(phytomer_parameters, PhytomerParameters)
6408 params_dict = phytomer_parameters.to_dict()
6409 elif isinstance(phytomer_parameters, dict):
6410 params_dict = phytomer_parameters
6413 f
"Phytomer parameters must be a dict or PhytomerParameters, got "
6414 f
"{type(phytomer_parameters).__name__}")
6417 result = plantarch_wrapper.resamplePhytomerParameters(self.
context.context, params_dict)
6418 except Exception
as e:
6420 return PhytomerParameters.from_dict(result)
if typed
else result
6424 if isinstance(stem_fraction, bool)
or not isinstance(stem_fraction, (int, float)) \
6425 or not 0.0 <= stem_fraction <= 1.0:
6426 raise ValueError(f
"Stem fraction must be a number in [0, 1], got {stem_fraction!r}")
6427 return float(stem_fraction)
6431 if not isinstance(plant_model_name, str)
or not plant_model_name:
6433 f
"Plant model name must be a non-empty str, got {type(plant_model_name).__name__}")
6437 """Reject a non-int or negative plant ID, returning it as a plain int."""
6438 if isinstance(plant_id, bool)
or not isinstance(plant_id, int):
6440 f
"Plant ID must be a non-negative int, got {type(plant_id).__name__}")
6442 raise ValueError(
"Plant ID must be non-negative")
6443 return int(plant_id)
6447 """Coerce a sequence of plant IDs, rejecting an empty or malformed one."""
6448 if isinstance(plant_ids, (str, bytes))
or not hasattr(plant_ids,
'__iter__'):
6450 f
"Plant IDs must be an int or a sequence of ints, got "
6451 f
"{type(plant_ids).__name__}")
6454 raise ValueError(
"Plant ID list must not be empty")
6459 """Reject a non-int or negative phytomer index."""
6460 if isinstance(node_index, bool)
or not isinstance(node_index, int):
6462 f
"{name} must be a non-negative int, got {type(node_index).__name__}")
6464 raise ValueError(f
"{name} must be non-negative")
6465 return int(node_index)
6469 """Reject a non-int or negative petiole/leaf index."""
6474 """Reject a non-numeric or non-positive scale factor."""
6475 if isinstance(scale_factor, bool)
or not isinstance(scale_factor, (int, float)):
6477 f
"{name} must be a positive number, got {type(scale_factor).__name__}")
6478 if not scale_factor > 0:
6479 raise ValueError(f
"{name} must be positive, got {scale_factor}")
6480 return float(scale_factor)
6484 Check if PlantArchitecture is available in current build.
6487 True if plugin is available, False otherwise
6495 Create PlantArchitecture instance with context.
6498 context: Helios Context
6501 PlantArchitecture instance
6504 >>> context = Context()
6505 >>> 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.
float getPhytomerAge(self, int plant_id, int shoot_id, int node_index)
Age of one phytomer in days, counted from its creation.
None removeShootLeaves(self, int plant_id, int shoot_id)
Remove all leaves from a single shoot.
None setCollisionRelevantOrgans(self, bool include_internodes=False, bool include_leaves=True, bool include_petioles=False, bool include_flowers=False, bool include_fruit=False)
Specify which plant organs participate in collision detection.
List[float] _plantAngleDistribution(self, str fn_name, plant_ids, num_bins, normalize, str what)
None appendAttractionPoints(self, List[vec3] points, Optional[int] plant_id=None)
Add to the current attraction point set.
None pruneBranch(self, int plant_id, int shoot_id, int node_index)
Prune a shoot at a node, removing that node and everything distal to it.
List[str] getPlantModelBuildParameterNames(self, str plant_model)
Get the names of the build parameters a library plant model accepts.
bool is_available(self)
Check if PlantArchitecture is available in current build.
None registerGrowthFrame(self, int plant_id, float min_segment_length=0.001)
Capture a snapshot of the plant's geometry as a growth animation frame.
List[int] _childShootIDsOrEmpty(self, int plant_id, int shoot_id)
Return a shoot's child IDs, or an empty list if it no longer resolves.
bool isPlantDormant(self, int plant_id)
Check whether a plant is dormant.
int addShootFromNodePositions(self, int plant_id, int parent_shoot_id, int parent_node_index, List[vec3] node_positions, List[float] node_radii, str shoot_type_label, Optional[str] growth_shoot_type_label=None, int petiole_index=0)
Add a shoot whose internode geometry is prescribed by measured node positions.
List[int] pruneShootSubtree(self, int plant_id, int shoot_id, bool include_self=True)
Prune a shoot and everything growing off it.
List[float] getPlantLeafAzimuthAngleDistribution(self, Union[int, List[int]] plant_ids, int num_bins, bool normalize=True)
Get the leaf-area-weighted leaf azimuth angle distribution of one or more plants.
None removeShootVegetativeBuds(self, int plant_id, int shoot_id)
Mark every vegetative bud on a single shoot as dead.
List[int] getPathToRoot(self, int plant_id, int shoot_id)
Get the chain of shoots connecting a shoot to the base stem shoot.
None _raise_pending_callback_error(self, Optional[BaseException] native_error=None)
Raise, and clear, an exception stored by a phytomer creation or callback function.
int getShootRank(self, int plant_id, int shoot_id)
Get the branching rank of a shoot.
int _validatePlantIdentifier(plant_id)
Reject a non-int or negative plant ID, returning it as a plain int.
Union[dict, PhytomerParameters] resamplePhytomerParameters(self, Union[dict, PhytomerParameters] phytomer_parameters)
Draw one realization of a set of phytomer parameters.
List[int] getPlantLeafObjectIDs(self, int plant_id)
Get object IDs for all leaf objects on a specific plant.
List[int] _pruneShallowest(self, int plant_id, target_shoot_ids)
Prune every target that something shallower has not already removed.
None removePlantLeaves(self, int plant_id)
Remove all leaves from every shoot on a plant.
None setPetioleNodePositions(self, int plant_id, int shoot_id, int node_index, int petiole_index, List[vec3] node_positions, List[float] node_radii)
Prescribe the path of a petiole on an existing phytomer from measured node positions.
_phytomerCall(self, str description, int plant_id, int shoot_id, int node_index, fn, *args)
Call a per-phytomer wrapper, turning an out-of-range index into ValueError.
None scalePetioleMaxLength(self, int plant_id, int shoot_id, int node_index, float scale_factor)
Scale the fully-elongated length every petiole on a phytomer is growing toward.
List[int] getAllFlowerUUIDs(self)
Get UUIDs of every flower primitive in the model.
bool isLeafAngleDistributionTrackingEnabled(self, int plant_id)
Whether a plant's leaf angles are being steered toward a prescribed distribution.
None makePlantDormant(self, int plant_id)
Force a plant into a dormant state immediately.
None disableAttractionPoints(self, Optional[int] plant_id=None)
Stop steering growth toward attraction points.
None advanceTime(self, float dt, Optional[int] plant_id=None, Optional[List[int]] plant_ids=None, Optional[int] years=None)
Advance time for plant growth and development.
List[int] pruneShootsByRank(self, int plant_id, int min_rank)
Prune every shoot at or above a given branching rank.
Dict[int, List[int]] getShootHierarchyMap(self, int plant_id)
Get the parent-to-children structure of a plant.
float _validateStemFraction(stem_fraction)
List[float] getShootInternodeRadii(self, int plant_id, int shoot_id)
Get the per-vertex woody internode radii of a shoot.
float getLeafBladeArea(self, int leaf_object_id)
One-sided blade area (m²) of a single leaf object.
None setPlantNitrogenParameters(self, int plant_id, Union[dict, NitrogenParameters] parameters)
Set nitrogen-model parameters for a plant.
List[str] listShootTypeLabels(self, Optional[str] plant_model=None, Optional[int] plant_id=None)
Get the shoot type labels defined for a plant model.
List[int] getAllPlantObjectIDs(self, int plant_id)
Get all object IDs for a specific plant.
None _setLibraryPhytomerFunction(self, str shoot_type_label, str function_name, bool creation)
int _validatePetioleIndex(cls, petiole_index, str name="Petiole index")
Reject a non-int or negative petiole/leaf index.
List[int] getChildShootIDs(self, int plant_id, int shoot_id)
Get the shoots that grew directly out of a shoot.
int addChildShoot(self, int plant_id, int parent_shoot_id, int parent_node_index, int current_node_number, AxisRotation shoot_base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label, int petiole_index=0)
Add a child shoot at an axillary bud position on a parent shoot.
bool doesPlantModelDeclareLeafInclinationDistribution(self, str plant_model_name)
Whether a library plant model declares a leaf inclination distribution.
None disableMessages(self)
Suppress standard output from the plantarchitecture plugin.
None _validate_attraction_points(points)
Reject point sets the native layer would misread or silently ignore.
List[int] buildPlantCanopyFromLibrary(self, vec3 canopy_center, vec2 plant_spacing, int2 plant_count, float age, float germination_rate=1.0, Optional[dict] build_parameters=None)
Build a canopy of regularly spaced plants from the currently loaded library model.
None defineShootType(self, str shoot_type_label, Union[dict, ShootParameters] parameters)
Define a custom shoot type with specified parameters.
_validateNodesAndRadii(node_positions, node_radii, str positions_name, str radii_name)
Validate a measured node path and return it as plain lists for the ctypes layer.
bool isShootPruned(self, int plant_id, int shoot_id)
Report whether a shoot has been pruned away entirely.
List[float] getPlantLeafAreas(self, int plant_id)
Get the built one-sided surface area of every leaf on a plant.
List[int] getTerminalShootIDs(self, int plant_id)
Get the plant's terminal shoots – those carrying no child shoots.
None enableSolidObstacleAvoidance(self, List[int] obstacle_UUIDs, float avoidance_distance=0.5, bool enable_fruit_adjustment=False, bool enable_obstacle_pruning=False)
Enable hard obstacle avoidance for specified geometry.
List[int] getAllDescendantShootIDs(self, int plant_id, int shoot_id)
Get every shoot descending from a shoot.
bool _isPrunedOrGone(self, int plant_id, int shoot_id)
Whether a shoot has been pruned away or no longer resolves at all.
BudState _validateBudState(state)
Coerce a BudState (or its int value) and reject anything else.
None disableLeafAngleDistributionTracking(self, int plant_id)
Stop steering a plant's leaf angles toward a prescribed distribution.
vec3 getInternodeAxisVector(self, int plant_id, int shoot_id, int node_index, float stem_fraction)
Unit direction of one internode at a fraction of its length.
None scaleLeafSizeMax(self, int plant_id, int shoot_id, int node_index, float scale_factor)
Scale the size every leaf on a phytomer is expanding toward, leaving the blades where they are.
List[int] getPlantInternodeObjectIDs(self, int plant_id, Optional[str] shoot_type_label=None)
Get the object IDs of a plant's internode (tube) objects.
vec3 getLeafBasePosition(self, int plant_id, int shoot_id, int node_index, int petiole_index, int leaf_index)
Position where one leaf (leaflet) attaches to its petiole.
List[int] _validatePlantIdList(cls, plant_ids)
Coerce a sequence of plant IDs, rejecting an empty or malformed one.
None scaleInternodeMaxLength(self, int plant_id, int shoot_id, int node_index, float scale_factor)
Scale the fully-elongated length of one existing internode.
Dict[int, List[int]] getShootIDsByRank(self, int plant_id)
Group a plant's shoot IDs by branching rank.
List[float] _plantFloatVector(self, str wrapper_fn_name, int plant_id, str description)
Shared body for the per-organ built-geometry queries.
List[float] getPlantLeafInclinations(self, int plant_id)
Get the inclination angle of every leaf on a plant.
float getPlantHeight(self, int plant_id)
Get the height of a plant in meters.
float getShadowLightExposureAtPoint(self, int plant_id, vec3 position)
Local light exposure at a point from a plant's shadow-propagation grid.
List[float] getPlantInternodeLengths(self, int plant_id)
Get the built length of every internode on a plant.
float getPetioleFlexibility(self, int plant_id, int shoot_id, int node_index)
Bending flexibility of the petioles of one phytomer.
List[float] getPlantLeafInclinationAngleDistribution(self, Union[int, List[int]] plant_ids, int num_bins, bool normalize=True)
Get the leaf-area-weighted leaf inclination angle distribution of one or more plants.
int getGrowthFrameCount(self, int plant_id)
Get the number of registered growth frames for a plant.
List[int] pruneTerminalShoots(self, int plant_id, int stride=2)
Thin a plant by pruning every stride-th terminal shoot.
None deletePlantInstance(self, int plant_id)
Delete a plant instance and all associated geometry.
None setShootPhyllotacticAngle(self, int plant_id, int shoot_id, float mean_deg, Optional[float] sd_deg=None)
Set the phyllotactic angle that one shoot's next phytomers are created with.
List[int] readPlantStructureXML(self, Union[str, Path] filename, bool quiet=False)
Load plant structure from XML file.
None enableMessages(self)
Re-enable standard output from the plantarchitecture plugin.
None setPhytomerCallbackFunction(self, str shoot_type_label, Optional[Callable[[int, int, int, float], None]] callback)
Install a Python function that the plugin calls for every phytomer of a shoot type on every time step...
int getShootDepth(self, int plant_id, int shoot_id)
Get the number of shoots between a shoot and the base stem shoot.
List[int] getAllInternodeUUIDs(self)
Get UUIDs of every internode primitive in the model.
setCancelFlag(self, cancel_flag)
Register an external cancellation flag polled during long plant builds.
None breakPlantDormancy(self, int plant_id)
Break dormancy for all shoots on a plant, returning it to an active state.
List[int] getAllPlantIDs(self)
Get IDs of every plant instance in the model.
List[int] getAllFruitUUIDs(self)
Get UUIDs of every fruit primitive in the model.
None _removeShootOrgans(self, str wrapper_fn_name, int plant_id, int shoot_id, str organ_description)
Shared body for the three shoot-level organ removal methods.
__exit__(self, exc_type, exc_val, exc_tb)
Context manager exit - cleanup resources.
int getPlantLeafCount(self, int plant_id)
Get the number of leaf objects on a plant.
float getPlantAge(self, int plant_id)
Get the current age of a plant in days.
int buildPlantInstanceFromLibrary(self, vec3 base_position, float age, Optional[dict] build_parameters=None)
Build a plant instance from the currently loaded library model.
None scaleLeafPrototypeScale(self, int plant_id, int shoot_id, int node_index, float scale_factor, Optional[int] petiole_index=None)
Scale a phytomer's leaves now: both the blades' present size and the size they grow toward.
str getPlantName(self, int plant_id)
Get the name of the plant model a plant was built from.
getCurrentShootParameters(self, str shoot_type_label, bool return_typed=False)
Get current shoot parameters for a shoot type.
None writePlantStructureXML(self, int plant_id, Union[str, Path] filename)
Save plant structure to XML file for later loading.
List[vec3] getPlantLeafBases(self, int plant_id)
Get the attachment base position of every leaf on a specific plant.
None harvestPlant(self, int plant_id)
Harvest a plant by removing its flowers and fruit.
None setStaticObstacles(self, List[int] target_UUIDs)
Mark geometry as static obstacles for collision detection optimization.
List[int] getAllShootIDs(self, int plant_id)
Get the IDs of all shoots belonging to a plant.
None enableGroundClipping(self, float ground_height=0.0)
Enable automatic removal of plant organs that fall below the ground plane.
List[int] getAllPetioleUUIDs(self)
Get UUIDs of every petiole primitive in the model.
List[int] getAllPlantUUIDs(self, int plant_id, bool include_hidden=False)
Get all primitive UUIDs for a specific plant.
Dict[str, float] getPlantBuildParameters(self, int plant_id)
Get the build parameters a plant was built with.
None setPhytomerCreationFunction(self, str shoot_type_label, Optional[Callable[[int, int, int, int, int, int, float], None]] callback)
Install a Python function that the plugin calls once for every new phytomer of a shoot type.
float getInternodeLength(self, int plant_id, int shoot_id, int node_index)
Present length of one internode in meters, measured along its node positions.
List[int] getPlantPeduncleObjectIDs(self, int plant_id)
Get object IDs for all peduncle objects on a specific plant.
List[int] getPlantPetioleObjectIDs(self, int plant_id)
Get object IDs for all petiole objects on a specific plant.
None writePlantStructureUSD(self, int plant_id, Union[str, Path] filename, float elastic_modulus=5e9, float wood_density=800.0, *, float damping_time_constant=0.02, float static_friction=0.5, float dynamic_friction=0.3, float restitution=0.1, float organ_spring_stiffness=10.0, float organ_spring_damping=1.0, float leaf_mass_per_area=0.05, float fruit_mass=0.01, float flower_mass=0.002, int solver_position_iterations=32, float min_segment_length=0.001, float armature_stability_ratio=8.0, float physics_steps_per_second=60.0, Optional[float] damping_ratio=None)
Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
float getPlantStemHeight(self, int plant_id)
Get the height of a plant's main stem in meters.
None _validatePlantModelName(plant_model_name)
None loadPlantModelFromLibrary(self, str plant_label)
Load a plant model from the built-in library.
None disableCollisionDetection(self)
Disable collision detection for plant growth.
None setPlantPhenologicalThresholds(self, int plant_id, float time_to_dormancy_break, float time_to_flower_initiation, float time_to_flower_opening, float time_to_fruit_set, float time_to_fruit_maturity, float time_to_dormancy, float max_leaf_lifespan=1e6, bool is_evergreen=False)
Set phenological timing thresholds for plant developmental stages.
getDefaultNitrogenParameters(self, bool return_typed=False)
Get a default-constructed set of nitrogen-model parameters.
str determinePhenologyStage(self, int plant_id)
Determine the phenological stage of a plant.
getDefaultCarbohydrateParameters(self, bool return_typed=False)
Get a default-constructed set of carbohydrate-model parameters.
List[str] getLibraryPhytomerCreationFunctionNames()
List the library phytomer creation functions that :meth:setPhytomerCreationFunction accepts by name.
List[int] getPlantFlowerObjectIDs(self, int plant_id)
Get object IDs for all flower (inflorescence) objects on a specific plant.
int addBaseStemShoot(self, int plant_id, int current_node_number, AxisRotation base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label)
Add a base stem shoot to a plant instance (main trunk/stem).
int getShootNodeCount(self, int plant_id, int shoot_id)
Get the current number of nodes (phytomers) on one shoot.
float getPlantMaxAge(self, int plant_id)
Get the maximum age of a plant, beyond which it stops growing.
float getSeasonPeakLightExposureAtPoint(self, int plant_id, vec3 position)
Local light exposure at a point under the plant's most recent seasonal peak canopy.
int _validateNodeIndex(node_index, str name="Node index")
Reject a non-int or negative phytomer index.
None enableLeafAzimuthAngleDistributionTracking(self, int plant_id, float eccentricity, float ellipse_rotation_degrees, float lambda_degrees)
Steer leaf azimuth toward an ellipsoidal distribution as the plant grows, leaving inclination to the ...
None setPlantCarbohydrateParameters(self, int plant_id, Union[dict, CarbohydrateParameters] parameters)
Set carbohydrate-model parameters for a plant.
None setPetioleLeafGeometry(self, int plant_id, int shoot_id, int node_index, int petiole_index, List[vec3] leaf_bases, List[AxisRotation] leaf_rotations, List[float] leaf_sizes)
Prescribe the base position, orientation and size of every leaf on a petiole.
None setPetioleAndLeafScaleFraction(self, int plant_id, int shoot_id, int node_index, int petiole_index, float petiole_scale_factor_fraction, float leaf_scale_factor_fraction)
Set a petiole's length and its leaves' size together, each as a fraction of its own fully-elongated v...
List[int] getAllObjectIDs(self)
Get object IDs of every plant compound object in the model.
List[List[int]] getPhytomerLeafObjectIDs(self, int plant_id, int shoot_id, int node_index)
Object IDs of one phytomer's leaves, grouped by petiole.
_makePhytomerCallbackTrampoline(self, callback)
Wrap callback so an exception is stored for re-raising instead of being lost inside ctypes.
None _validateShootIdentifiers(int plant_id, int shoot_id)
Reject non-int and negative plant/shoot identifiers.
Optional[str] getPhytomerCreationFunctionName(self, str shoot_type_label)
Get the name of the library creation function a shoot type carries.
None bendPetioleUnderLeafWeight(self, int plant_id, int shoot_id, int node_index, int petiole_index, bool include_posed_leaves=False)
Bend one petiole, and the leaves it carries, under the weight of its leaflets.
List[float] getPetioleRadii(self, int plant_id, int shoot_id, int node_index, int petiole_index)
Radius in meters of one petiole at each of its centerline vertices (see :meth:getPetioleVertices).
__enter__(self)
Context manager entry.
None setInternodeMaxLength(self, int plant_id, int shoot_id, int node_index, float length)
Set the fully-elongated length of one existing internode.
Dict[str, Any] getShoot(self, int plant_id, int shoot_id)
Get a read-only view of a shoot's topology.
List[int] getPlantCollisionRelevantObjectIDs(self, int plant_id)
Get object IDs of collision-relevant geometry for a specific plant.
bool isShootGeometryPrescribed(self, int plant_id, int shoot_id)
Report whether a shoot's existing geometry was prescribed by the caller rather than generated.
None setPetioleScaleFraction(self, int plant_id, int shoot_id, int node_index, int petiole_index, float petiole_scale_factor_fraction)
Set one petiole's current length as a fraction of its fully-elongated length, leaving the leaves it c...
None enableLeafAngleDistributionTracking(self, plant_ids, float beta_mu_inclination, float beta_nu_inclination, float eccentricity, float ellipse_rotation_degrees, float lambda_degrees)
Steer leaf inclination and azimuth toward a prescribed distribution as the plant grows.
None setLeafAngleDistribution(self, plant_ids, float beta_mu_inclination, float beta_nu_inclination, Optional[float] eccentricity=None, float ellipse_rotation_degrees=0.0)
Re-aim every leaf of a finished plant so that its leaf angles follow a prescribed distribution.
None optionalOutputObjectData(self, Union[str, List[str]] object_data_labels)
Enable optional output object data to be written to the Context.
List[int] getShootChildIDs(self, int plant_id, int shoot_id)
Get the child shoot IDs of a shoot (flattened across parent node indices).
float getInternodeRadius(self, int plant_id, int shoot_id, int node_index)
Radius in meters at the base of one internode.
None setPlantMaxAge(self, int plant_id, float max_age)
Set the maximum age of a plant, beyond which it stops growing.
int getShootVegetativeBudCount(self, int plant_id, int shoot_id, Optional[BudState] state=None)
Count a shoot's axillary vegetative buds, summed over all phytomers and petioles.
int appendShoot(self, int plant_id, int parent_shoot_id, int current_node_number, AxisRotation base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label)
Append a shoot to the end of an existing shoot.
None updateAttractionPoints(self, List[vec3] points, Optional[int] plant_id=None)
Replace the current attraction point set.
None enableSoftCollisionAvoidance(self, Optional[List[int]] target_object_UUIDs=None, Optional[List[int]] target_object_IDs=None, bool enable_petiole_collision=False, bool enable_fruit_collision=False)
Enable soft collision avoidance for procedural plant growth.
None setLeafNormal(self, int plant_id, int shoot_id, int node_index, int petiole_index, int leaf_index, vec3 target_normal)
Re-aim one leaf so its blade faces a given direction.
None _validate_build_parameters(self, Optional[dict] build_parameters)
Validate build parameters against the names the loaded model declares natively.
None setPetioleFlexibility(self, int plant_id, int shoot_id, int node_index, float flexibility)
Replace the bending flexibility of the petioles of one existing phytomer.
None terminateApicalBud(self, int plant_id, int shoot_id)
Stop a shoot's apex from adding any further phytomers.
tuple getPlantModelLeafInclinationDistribution(self, str plant_model_name)
Beta leaf inclination distribution (mu, nu) that a library plant model declares.
Union[vec3, List[vec3]] getPlantBasePosition(self, Union[int, List[int]] plant_ids)
Get the base position of one plant or of several plants.
_check_context_alive(self)
Raise if the owning Context has been destroyed (see Context.check_context_alive).
Optional[str] _getLibraryPhytomerFunctionName(self, str shoot_type_label, bool creation)
None _reject_inside_phytomer_callback(self, str method_name)
Raise if called from a Python phytomer creation or callback function.
List[int] getAllUUIDs(self)
Get UUIDs of every plant primitive in the model.
setProgressCallback(self, callback)
Set a callback to receive progress updates during long-running operations.
None writePlantGrowthUSD(self, int plant_id, Union[str, Path] filename, float seconds_per_frame=1.0)
Export all registered growth frames as a time-sampled USD animation file.
List[str] getAvailablePlantModels(self)
Get list of all available plant models in the library.
None setShootInternodeLengthMax(self, int plant_id, int shoot_id, float internode_length_max)
Set the target length of internodes grown at the apex of an existing shoot.
None disablePlantPhenology(self, int plant_id)
Disable phenological progression for a plant.
None setPlantBasePosition(self, int plant_id, vec3 base_position)
Set the base position of a plant, moving the plant if it has already been built.
List[int] getPlantFruitObjectIDs(self, int plant_id)
Get object IDs for all fruit objects on a specific plant.
List[int] _liveChildShootIDs(self, int plant_id, int shoot_id)
Child shoot IDs that have not been pruned away, ascending.
List[vec3] getInternodeNodePositions(self, int plant_id, int shoot_id, int node_index)
Positions of one internode's nodes, from its base to its tip.
None removeShootFloralBuds(self, int plant_id, int shoot_id)
Kill all floral buds on a single shoot.
_makePhytomerCreationTrampoline(self, callback)
Wrap callback so an exception is stored for re-raising instead of being lost inside ctypes.
None writeQSMCylinderFile(self, int plant_id, Union[str, Path] filename)
Export plant structure in TreeQSM cylinder format.
int addPlantInstance(self, vec3 base_position, float current_age)
Create an empty plant instance for custom plant building.
None recordPetioleRestShape(self, int plant_id, int shoot_id, int node_index, int petiole_index)
Record one petiole's current centerline as its undeformed rest shape.
List[tuple] getShootInternodeVertices(self, int plant_id, int shoot_id)
Get the woody internode polyline vertices of a shoot as a list of (x, y, z) tuples.
__del__(self)
Destructor to ensure C++ resources freed even without 'with' statement.
int getParentShootID(self, int plant_id, int shoot_id)
Get the ID of the shoot a shoot grew from.
None setAttractionParameters(self, float view_half_angle_deg, float look_ahead_distance, float attraction_weight, float obstacle_reduction_factor=0.75, Optional[int] plant_id=None)
Tune how strongly attraction points steer growth.
float getPlantAvailableNitrogen(self, int plant_id)
Nitrogen (g N) in a plant's available pool, awaiting allocation to its organs.
None setPetioleLeafCount(self, int plant_id, int shoot_id, int node_index, int petiole_index, int leaf_count)
Change the number of leaves (leaflets) on one petiole of an existing phytomer.
None setPlantModelLeafInclinationDistribution(self, str plant_model_name, float beta_mu, float beta_nu)
Set or clear the Beta leaf inclination distribution of a library plant model.
float getPlantLeafArea(self, int plant_id)
Get the total leaf area of a plant in m².
float getPetioleLength(self, int plant_id, int shoot_id, int node_index, Optional[int] petiole_index=None)
Current length of a phytomer's petioles, measured along the centerline.
Optional[str] getPhytomerCallbackFunctionName(self, str shoot_type_label)
Get the name of the library per-timestep callback function a shoot type carries.
None enableLeafElevationAngleDistributionTracking(self, int plant_id, float beta_mu_inclination, float beta_nu_inclination, float lambda_degrees)
Steer leaf inclination toward a Beta distribution as the plant grows, leaving azimuth to the procedur...
_shootScalarQuery(self, str wrapper_fn_name, int plant_id, int shoot_id, str description)
Shared body for the per-shoot hierarchy accessors.
List[str] getLibraryPhytomerCallbackFunctionNames()
List the library per-timestep callback functions that :meth:setPhytomerCallbackFunction accepts by na...
__init__(self, Context context)
Initialize PlantArchitecture with a Helios context.
List[int] getAllPeduncleUUIDs(self)
Get UUIDs of every peduncle primitive in the model.
float _validateScaleFactor(scale_factor, str name="Scale factor")
Reject a non-numeric or non-positive scale factor.
None setSoftCollisionAvoidanceParameters(self, float view_half_angle_deg=80.0, float look_ahead_distance=0.1, int sample_count=256, float inertia_weight=0.4)
Configure parameters for soft collision avoidance algorithm.
None enableAttractionPoints(self, List[vec3] points, Optional[int] plant_id=None, Optional[float] view_half_angle_deg=None, float look_ahead_distance=0.1, float attraction_weight=0.6)
Steer shoot growth toward a set of target points.
List[vec3] getPetioleVertices(self, int plant_id, int shoot_id, int node_index, int petiole_index)
Centerline vertices of one petiole, from its base at the node to its tip.
vec3 getPetioleAxisVector(self, int plant_id, int shoot_id, int node_index, float stem_fraction, int petiole_index)
Unit direction of one petiole at a fraction of its length.
State of a vegetative or floral bud.
Flat nitrogen-model parameters.
validate_vec3(value, name, func)
None _validate_build_parameters(Optional[dict] build_parameters, Optional[str] plant_model, Optional[List[str]] accepted=None)
Check build parameter types, and names against those the plant model accepts.
validate_int2(value, name, func)
str _resolve_user_path(Union[str, Path] filepath)
Convert relative paths to absolute paths before changing working directory.
_surfaces_phytomer_callback_errors(method)
Re-raise an exception from a Python phytomer creation or callback function out of the call that ran i...
validate_vec2(value, name, func)
PlantArchitecture create_plant_architecture(Context context)
Create PlantArchitecture instance with context.
is_plantarchitecture_available()
Check if PlantArchitecture plugin is available for use.
_plantarchitecture_working_directory()
Context manager that temporarily changes working directory to where PlantArchitecture assets are loca...