0.1.33
Loading...
Searching...
No Matches
PlantArchitecture.py
Go to the documentation of this file.
1"""
2High-level PlantArchitecture interface for PyHelios.
3
4This module provides a user-friendly interface to the plant architecture modeling
5capabilities with graceful plugin handling and informative error messages.
6"""
7
8import logging
9import os
10from contextlib import contextmanager
11from pathlib import Path
12from typing import List, Optional, Union, Dict, Any
13
14from .Context import Context, check_context_alive
15from .plugins.registry import get_plugin_registry, require_plugin
16from .wrappers import UPlantArchitectureWrapper as plantarch_wrapper
17from .wrappers.DataTypes import vec3, vec2, int2, AxisRotation
18try:
19 from .validation.datatypes import validate_vec3, validate_vec2, validate_int2
20except ImportError:
21 # Fallback validation functions for when validation module is not available
22 def validate_vec3(value, name, func):
23 if hasattr(value, 'x') and hasattr(value, 'y') and hasattr(value, 'z'):
24 return value
25 if isinstance(value, (list, tuple)) and len(value) == 3:
26 from .wrappers.DataTypes import vec3
27 return vec3(*value)
28 raise ValueError(f"{name} must be vec3 or 3-element list/tuple")
29
30 def validate_vec2(value, name, func):
31 if hasattr(value, 'x') and hasattr(value, 'y'):
32 return value
33 if isinstance(value, (list, tuple)) and len(value) == 2:
34 from .wrappers.DataTypes import vec2
35 return vec2(*value)
36 raise ValueError(f"{name} must be vec2 or 2-element list/tuple")
37
38 def validate_int2(value, name, func):
39 if hasattr(value, 'x') and hasattr(value, 'y'):
40 return value
41 if isinstance(value, (list, tuple)) and len(value) == 2:
42 from .wrappers.DataTypes import int2
43 return int2(*value)
44 raise ValueError(f"{name} must be int2 or 2-element list/tuple")
45from .validation.core import validate_positive_value
46from .assets import get_asset_manager
47from .plant_architecture_params import (
48 BudState,
49 ShootParameters,
50 CarbohydrateParameters,
51 NitrogenParameters,
52 RandomParameter,
53 RandomParameterFloat,
54 RandomParameterInt,
55)
56
57logger = logging.getLogger(__name__)
58
59
60# Build parameters accepted by each library plant model, mirroring the
61# getParameterValue(current_build_parameters, ...) calls in PlantLibrary.cpp. Models absent
62# from this table read no build parameters at all. The native library silently ignores keys
63# it does not recognize, so PyHelios validates against this table to keep a typo or a
64# wrong-species key from producing a plant that quietly used defaults.
65_BUILD_PARAMETERS_BY_MODEL: Dict[str, frozenset] = {
66 "almond": frozenset({"trunk_height", "num_scaffolds", "scaffold_angle"}),
67 "almond_aldrich": frozenset({"trunk_height", "num_scaffolds", "scaffold_angle"}),
68 "almond_wood_colony": frozenset({"trunk_height", "num_scaffolds", "scaffold_angle"}),
69 "apple": frozenset({"trunk_height", "num_scaffolds", "scaffold_angle"}),
70 "grapevine_VSP": frozenset({"trunk_height", "vine_spacing"}),
71 "grapevine_wye": frozenset(
72 {"trunk_height", "vine_spacing", "cordon_spacing", "catch_wire_height"}
73 ),
74 "pistachio": frozenset({"trunk_height", "num_scaffolds", "scaffold_angle"}),
75 "walnut": frozenset({"trunk_height", "num_scaffolds", "scaffold_angle"}),
76}
77
78# Every build parameter name recognized by any model, for error messages when the current
79# model is unknown.
80_ALL_BUILD_PARAMETERS = frozenset().union(*_BUILD_PARAMETERS_BY_MODEL.values())
81
82
83def _validate_build_parameters(build_parameters: Optional[dict], plant_model: Optional[str]) -> None:
84 """Reject build parameter keys the loaded plant model will not read.
85
86 The native library looks each key up in a map and falls back to a default when it is
87 absent, so an unrecognized key is silently discarded. Raising here instead keeps a
88 misspelled or wrong-species parameter from being mistaken for one that took effect.
89
90 Args:
91 build_parameters: Mapping supplied by the caller, or None.
92 plant_model: Label of the currently loaded model, or None if no model has been
93 loaded through this instance.
94
95 Raises:
96 ValueError: If build_parameters is not a dict of str -> number, or contains a key
97 the loaded model does not accept.
98 """
99 if build_parameters is None:
100 return
101
102 if not isinstance(build_parameters, dict):
103 raise ValueError("build_parameters must be a dict or None")
104
105 for key, value in build_parameters.items():
106 if not isinstance(key, str):
107 raise ValueError("build_parameters keys must be strings")
108 if isinstance(value, bool) or not isinstance(value, (int, float)):
109 raise ValueError("build_parameters values must be numeric (int or float)")
110
111 if not build_parameters:
112 return
113
114 # An unrecognized model label cannot be checked against a specific list. Fall back to
115 # the union so an outright typo is still caught, rather than skipping validation.
116 if plant_model is not None:
117 accepted = _BUILD_PARAMETERS_BY_MODEL.get(plant_model, frozenset())
118 model_description = f"Plant model '{plant_model}'"
119 else:
120 accepted = _ALL_BUILD_PARAMETERS
121 model_description = "No plant model has been loaded through this instance, so"
122
123 unknown = sorted(set(build_parameters) - accepted)
124 if not unknown:
125 return
126
127 if accepted:
128 accepted_description = f"accepts only: {', '.join(sorted(accepted))}"
129 else:
130 accepted_description = "accepts no build parameters"
131
132 raise ValueError(
133 f"Unknown build parameter(s) {', '.join(repr(k) for k in unknown)}. "
134 f"{model_description} {accepted_description}. "
135 f"Unrecognized parameters are ignored by the native library, so they would "
136 f"otherwise have no effect."
137 )
138
139
140def _resolve_user_path(filepath: Union[str, Path]) -> str:
141 """
142 Convert relative paths to absolute paths before changing working directory.
143
144 This preserves the user's intended file location when the working directory
145 is temporarily changed for C++ asset access. Absolute paths are returned unchanged.
146
147 Args:
148 filepath: File path to resolve (string or Path object)
149
150 Returns:
151 Absolute path as string
152 """
153 path = Path(filepath)
154 if not path.is_absolute():
155 return str(Path.cwd() / path)
156 return str(path)
157
158
159@contextmanager
161 """
162 Context manager that temporarily changes working directory to where PlantArchitecture assets are located.
163
164 PlantArchitecture C++ code uses hardcoded relative paths like "plugins/plantarchitecture/assets/textures/"
165 expecting assets relative to working directory. This manager temporarily changes to the build directory
166 where assets are actually located.
167
168 Raises:
169 RuntimeError: If build directory or PlantArchitecture assets are not found, indicating a build system error.
170 """
171 # Find the build directory containing PlantArchitecture assets
172 # Try asset manager first (works for both development and wheel installations)
173 asset_manager = get_asset_manager()
174 working_dir = asset_manager._get_helios_build_path()
175
176 if working_dir and working_dir.exists():
177 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
178 else:
179 # For wheel installations, check packaged assets
180 current_dir = Path(__file__).parent
181 packaged_build = current_dir / 'assets' / 'build'
182
183 if packaged_build.exists():
184 working_dir = packaged_build
185 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
186 else:
187 # Fallback to development paths
188 repo_root = current_dir.parent
189 build_lib_dir = repo_root / 'pyhelios_build' / 'build' / 'lib'
190 working_dir = build_lib_dir.parent
191 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
192
193 if not build_lib_dir.exists():
194 raise RuntimeError(
195 f"PyHelios build directory not found at {build_lib_dir}. "
196 f"PlantArchitecture requires native libraries to be built. "
197 f"Run: build_scripts/build_helios --plugins plantarchitecture"
198 )
199
200 if not plantarch_assets.exists():
201 raise RuntimeError(
202 f"PlantArchitecture assets not found at {plantarch_assets}. "
203 f"Build system failed to copy PlantArchitecture assets. "
204 f"Run: build_scripts/build_helios --clean --plugins plantarchitecture"
205 )
206
207 # Verify essential assets exist
208 assets_dir = plantarch_assets / 'assets'
209 if not assets_dir.exists():
210 raise RuntimeError(
211 f"PlantArchitecture assets directory not found: {assets_dir}. "
212 f"Essential assets missing. Rebuild with: "
213 f"build_scripts/build_helios --clean --plugins plantarchitecture"
214 )
215
216 # Change to the build directory temporarily
217 original_dir = os.getcwd()
218 try:
219 os.chdir(working_dir)
220 logger.debug(f"Changed working directory to {working_dir} for PlantArchitecture asset access")
221 yield working_dir
222 finally:
223 os.chdir(original_dir)
224 logger.debug(f"Restored working directory to {original_dir}")
225
226
227class PlantArchitectureError(Exception):
228 """Raised when PlantArchitecture operations fail."""
229 pass
230
231
233 """
234 Check if PlantArchitecture plugin is available for use.
235
236 Returns:
237 bool: True if PlantArchitecture can be used, False otherwise
238 """
239 try:
240 # Check plugin registry
241 plugin_registry = get_plugin_registry()
242 if not plugin_registry.is_plugin_available('plantarchitecture'):
243 return False
244
245 # Check if wrapper functions are available
246 if not plantarch_wrapper._PLANTARCHITECTURE_FUNCTIONS_AVAILABLE:
247 return False
248
249 return True
250 except Exception:
251 return False
252
253
255 """
256 High-level interface for plant architecture modeling and procedural plant generation.
257
258 PlantArchitecture provides access to the comprehensive plant library with 25+ plant models
259 including trees (almond, apple, olive, walnut), crops (bean, cowpea, maize, rice, soybean),
260 and other plants. This class enables procedural plant generation, time-based growth
261 simulation, and plant community modeling.
262
263 This class requires the native Helios library built with PlantArchitecture support.
264 Use context managers for proper resource cleanup.
265
266 Example:
267 >>> with Context() as context:
268 ... with PlantArchitecture(context) as plantarch:
269 ... plantarch.loadPlantModelFromLibrary("bean")
270 ... plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(0, 0, 0), age=30)
271 ... plantarch.advanceTime(10.0) # Grow for 10 days
272 """
273
274 def __new__(cls, context=None):
275 """
276 Create PlantArchitecture instance.
277 Explicit __new__ to prevent ctypes contamination on Windows.
278 """
279 return object.__new__(cls)
280
281 def __init__(self, context: Context):
282 """
283 Initialize PlantArchitecture with a Helios context.
284
285 Args:
286 context: Active Helios Context instance
288 Raises:
289 PlantArchitectureError: If plugin not available in current build
290 RuntimeError: If plugin initialization fails
291 """
292 # Check plugin availability
293 registry = get_plugin_registry()
294 if not registry.is_plugin_available('plantarchitecture'):
296 "PlantArchitecture not available in current Helios library. "
297 "Rebuild PyHelios with PlantArchitecture support:\n"
298 " build_scripts/build_helios --plugins plantarchitecture\n"
299 "\n"
300 "System requirements:\n"
301 f" - Platforms: Windows, Linux, macOS\n"
302 " - Dependencies: Extensive asset library (textures, OBJ models)\n"
303 " - GPU: Not required\n"
304 "\n"
305 "Plant library includes 25+ models: almond, apple, bean, cowpea, maize, "
306 "rice, soybean, tomato, wheat, and many others."
307 )
308
309 self.context = context
310 self._plantarch_ptr = None
311 # Label passed to the most recent loadPlantModelFromLibrary(), used to validate
312 # build parameters against the model that actually consumes them.
313 self._current_plant_model = None
314
315 # Create PlantArchitecture instance with asset-aware working directory
317 self._plantarch_ptr = plantarch_wrapper.createPlantArchitecture(context.getNativePtr())
319 if not self._plantarch_ptr:
320 raise PlantArchitectureError("Failed to initialize PlantArchitecture")
321
323 """Raise if the owning Context has been destroyed (see Context.check_context_alive)."""
324 check_context_alive(getattr(self, "context", None), "PlantArchitecture")
325
326 def __enter__(self):
327 """Context manager entry"""
328 return self
329
330 def __exit__(self, exc_type, exc_val, exc_tb):
331 """Context manager exit - cleanup resources"""
332 if hasattr(self, '_plantarch_ptr') and self._plantarch_ptr:
333 plantarch_wrapper.destroyPlantArchitecture(self._plantarch_ptr)
334 self._plantarch_ptr = None
335
336 def __del__(self):
337 """Destructor to ensure C++ resources freed even without 'with' statement."""
338 if hasattr(self, '_plantarch_ptr') and self._plantarch_ptr is not None:
339 try:
340 plantarch_wrapper.destroyPlantArchitecture(self._plantarch_ptr)
341 self._plantarch_ptr = None
342 except Exception as e:
343 import warnings
344 warnings.warn(f"Error in PlantArchitecture.__del__: {e}")
345
346 def loadPlantModelFromLibrary(self, plant_label: str) -> None:
347 """
348 Load a plant model from the built-in library.
349
350 Args:
351 plant_label: Plant model identifier from library. Available models include:
352 "almond", "apple", "bean", "bindweed", "butterlettuce", "capsicum",
353 "cheeseweed", "cowpea", "easternredbud", "grapevine_VSP", "maize",
354 "olive", "pistachio", "puncturevine", "rice", "sorghum", "soybean",
355 "strawberry", "sugarbeet", "tomato", "cherrytomato", "walnut", "wheat"
356
357 Raises:
358 ValueError: If plant_label is empty or invalid
359 PlantArchitectureError: If model loading fails
360
361 Example:
362 >>> plantarch.loadPlantModelFromLibrary("bean")
363 >>> plantarch.loadPlantModelFromLibrary("almond")
364 """
365 if not plant_label:
366 raise ValueError("Plant label cannot be empty")
367
368 if not plant_label.strip():
369 raise ValueError("Plant label cannot be only whitespace")
370
372 try:
374 plantarch_wrapper.loadPlantModelFromLibrary(self._plantarch_ptr, plant_label.strip())
375 except Exception as e:
376 raise PlantArchitectureError(f"Failed to load plant model '{plant_label}': {e}")
377
378 self._current_plant_model = plant_label.strip()
379
380 def buildPlantInstanceFromLibrary(self, base_position: vec3, age: float,
381 build_parameters: Optional[dict] = None) -> int:
382 """
383 Build a plant instance from the currently loaded library model.
384
385 Args:
386 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
387 age: Age of the plant in days (must be >= 0)
388 build_parameters: Optional dict of parameter overrides for training system
389 parameters. Only some models read them, and a key the model does
390 not accept raises ValueError rather than being ignored:
391 - almond, almond_aldrich, almond_wood_colony, apple, pistachio,
392 walnut: trunk_height, num_scaffolds, scaffold_angle
393 - grapevine_VSP: trunk_height, vine_spacing
394 - grapevine_wye: trunk_height, vine_spacing, cordon_spacing,
395 catch_wire_height
396 All other models read no build parameters.
397
398 Returns:
399 Plant ID for the created plant instance
400
401 Raises:
402 ValueError: If age is negative or build_parameters is invalid
403 PlantArchitectureError: If plant building fails
404 RuntimeError: If no model has been loaded
405
406 Example:
407 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(2.0, 3.0, 0.0), age=45.0)
408 >>> # With custom parameters
409 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(
410 ... base_position=vec3(0, 0, 0),
411 ... age=30.0,
412 ... build_parameters={'trunk_height': 2.0}
413 ... )
414 """
415 # Parameter type validation
416 if not isinstance(base_position, vec3):
417 raise ValueError(f"base_position must be a vec3, got {type(base_position).__name__}")
418
419 # Convert position to list for C++ interface
420 position_list = [base_position.x, base_position.y, base_position.z]
421
422 # Validate age (allow zero)
423 if age < 0:
424 raise ValueError(f"Age must be non-negative, got {age}")
425
427
429 try:
431 return plantarch_wrapper.buildPlantInstanceFromLibrary(
432 self._plantarch_ptr, position_list, age, build_parameters
433 )
434 except Exception as e:
435 raise PlantArchitectureError(f"Failed to build plant instance: {e}")
436
437 def buildPlantCanopyFromLibrary(self, canopy_center: vec3,
438 plant_spacing: vec2,
439 plant_count: int2, age: float,
440 germination_rate: float = 1.0,
441 build_parameters: Optional[dict] = None) -> List[int]:
442 """
443 Build a canopy of regularly spaced plants from the currently loaded library model.
444
445 Args:
446 canopy_center: Cartesian (x,y,z) coordinates of canopy center as vec3
447 plant_spacing: Spacing between plants in x- and y-directions (meters) as vec2
448 plant_count: Number of plants in x- and y-directions as int2
449 age: Age of all plants in days (must be >= 0)
450 germination_rate: Probability that each plant position will be occupied (0 to 1).
451 A value of 1.0 means all positions are filled; 0.5 means roughly
452 half the positions will have plants. Default is 1.0.
453 build_parameters: Optional dict of parameter overrides for training system
454 parameters, applied to every plant in the canopy. Only some models
455 read them, and a key the model does not accept raises ValueError
456 rather than being ignored. See buildPlantInstanceFromLibrary() for
457 the per-model list.
458
459 Returns:
460 List of plant IDs for the created plant instances
461
462 Raises:
463 ValueError: If age is negative, germination_rate is not in [0, 1],
464 plant count values are not positive, or build_parameters is invalid
465 PlantArchitectureError: If canopy building fails
466
467 Example:
468 >>> # 3x3 canopy with 0.5m spacing, 30-day-old plants
469 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
470 ... canopy_center=vec3(0, 0, 0),
471 ... plant_spacing=vec2(0.5, 0.5),
472 ... plant_count=int2(3, 3),
473 ... age=30.0
474 ... )
475 >>> # With 80% germination rate and custom parameters
476 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
477 ... canopy_center=vec3(0, 0, 0),
478 ... plant_spacing=vec2(1.5, 2.0),
479 ... plant_count=int2(5, 3),
480 ... age=45.0,
481 ... germination_rate=0.8,
482 ... build_parameters={'trunk_height': 1.8}
483 ... )
484 """
485 # Parameter type validation
486 if not isinstance(canopy_center, vec3):
487 raise ValueError(f"canopy_center must be a vec3, got {type(canopy_center).__name__}")
488 if not isinstance(plant_spacing, vec2):
489 raise ValueError(f"plant_spacing must be a vec2, got {type(plant_spacing).__name__}")
490 if not isinstance(plant_count, int2):
491 raise ValueError(f"plant_count must be an int2, got {type(plant_count).__name__}")
492
493 # Validate age (allow zero)
494 if age < 0:
495 raise ValueError(f"Age must be non-negative, got {age}")
496
497 # Validate germination rate
498 if not isinstance(germination_rate, (int, float)):
499 raise ValueError(f"germination_rate must be a number, got {type(germination_rate).__name__}")
500 if germination_rate < 0 or germination_rate > 1:
501 raise ValueError(f"germination_rate must be between 0 and 1, got {germination_rate}")
502
503 # Validate count values
504 if plant_count.x <= 0 or plant_count.y <= 0:
505 raise ValueError("Plant count values must be positive integers")
506
508
509 # Convert to lists for C++ interface
510 center_list = [canopy_center.x, canopy_center.y, canopy_center.z]
511 spacing_list = [plant_spacing.x, plant_spacing.y]
512 count_list = [plant_count.x, plant_count.y]
513
515 try:
517 return plantarch_wrapper.buildPlantCanopyFromLibrary(
518 self._plantarch_ptr, center_list, spacing_list, count_list, age,
519 germination_rate, build_parameters
520 )
521 except Exception as e:
522 raise PlantArchitectureError(f"Failed to build plant canopy: {e}")
523
524 def advanceTime(self, dt: float, plant_id: Optional[int] = None,
525 plant_ids: Optional[List[int]] = None,
526 years: Optional[int] = None) -> None:
527 """
528 Advance time for plant growth and development.
529
530 Updates plants in the simulation, potentially adding new phytomers, growing
531 existing organs, transitioning phenological stages, and updating plant geometry.
532
533 By default every plant advances together. Pass plant_id or plant_ids to advance a
534 subset, which is what staggered planting dates and mixed-age stands require.
535
536 Args:
537 dt: Time step to advance in days (must be >= 0)
538 plant_id: Advance only this plant. Mutually exclusive with plant_ids.
539 plant_ids: Advance only these plants. Mutually exclusive with plant_id.
540 years: Advance this many whole years in addition to dt days. Applies to all
541 plants and cannot be combined with plant_id or plant_ids.
542
543 Raises:
544 ValueError: If dt or years is negative, or selectors are combined
545 PlantArchitectureError: If time advancement fails
546
547 Note:
548 Large time steps are more efficient than many small steps. The timestep value
549 can be larger than the phyllochron, allowing multiple phytomers to be produced
550 in a single call.
551
552 Example:
553 >>> plantarch.advanceTime(10.0) # all plants, 10 days
554 >>> plantarch.advanceTime(10.0, plant_id=early) # one plant only
555 >>> plantarch.advanceTime(10.0, plant_ids=[a, b]) # a subset
556 >>> plantarch.advanceTime(0.0, years=4) # all plants, 4 years
557 """
558 if dt < 0:
559 raise ValueError(f"Time step must be non-negative, got {dt}")
560
561 selectors = sum(x is not None for x in (plant_id, plant_ids, years))
562 if selectors > 1:
563 raise ValueError("Pass at most one of plant_id, plant_ids, or years")
564
565 if plant_id is not None and plant_id < 0:
566 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
567 if plant_ids is not None and any(pid < 0 for pid in plant_ids):
568 raise ValueError("plant_ids must all be non-negative")
569 if years is not None and years < 0:
570 raise ValueError(f"years must be non-negative, got {years}")
571
573 try:
575 if plant_id is not None:
576 plantarch_wrapper.advanceTimeForPlant(self._plantarch_ptr, plant_id, dt)
577 elif plant_ids is not None:
578 if not plant_ids:
579 return
580 plantarch_wrapper.advanceTimeForPlants(self._plantarch_ptr, plant_ids, dt)
581 elif years is not None:
582 plantarch_wrapper.advanceTimeYears(self._plantarch_ptr, years, dt)
583 else:
584 plantarch_wrapper.advanceTime(self._plantarch_ptr, dt)
585 except Exception as e:
586 raise PlantArchitectureError(f"Failed to advance time by {dt} days: {e}")
587
588 def enableAttractionPoints(self, points: List[vec3],
589 plant_id: Optional[int] = None,
590 view_half_angle_deg: Optional[float] = None,
591 look_ahead_distance: float = 0.1,
592 attraction_weight: float = 0.6) -> None:
593 """
594 Steer shoot growth toward a set of target points.
595
596 Attraction points are the counterpart to collision avoidance: collision tells a
597 plant what to grow around, attraction tells it what to grow toward. This is how
598 trellis wires, espalier targets and greenhouse supports are modelled.
599
600 Steering applies to growth that happens after this call, since the direction is
601 chosen as each phytomer is constructed. Enable the points before advanceTime().
602
603 Args:
604 points: Target locations as a list of vec3
605 plant_id: Apply to this plant only. Applies to every plant when None.
606 view_half_angle_deg: Half-angle of the search cone in degrees. Defaults to
607 45 for the global form and 80 for the per-plant form, matching the
608 native defaults, which differ between the two.
609 look_ahead_distance: How far ahead a shoot tip looks, in meters
610 attraction_weight: Strength of the steering, 0 to 1
611
612 Raises:
613 ValueError: If points is empty or contains a non-vec3, or plant_id is negative
614 PlantArchitectureError: If the operation fails
615
616 Example:
617 >>> wires = [vec3(x, 0, 2.1) for x in range(0, 10)]
618 >>> plantarch.enableAttractionPoints(wires)
619 """
620 self._validate_attraction_points(points)
621 if plant_id is not None and plant_id < 0:
622 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
623
624 if view_half_angle_deg is None:
625 view_half_angle_deg = 45.0 if plant_id is None else 80.0
628 try:
630 plantarch_wrapper.enableAttractionPoints(
631 self._plantarch_ptr, plant_id, points,
632 view_half_angle_deg, look_ahead_distance, attraction_weight
633 )
634 except Exception as e:
635 raise PlantArchitectureError(f"Failed to enable attraction points: {e}")
636
637 def disableAttractionPoints(self, plant_id: Optional[int] = None) -> None:
638 """
639 Stop steering growth toward attraction points.
640
641 Args:
642 plant_id: Disable for this plant only. Disables globally when None.
643
644 Raises:
645 ValueError: If plant_id is negative
646 PlantArchitectureError: If the operation fails
647 """
648 if plant_id is not None and plant_id < 0:
649 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
650
652 try:
654 plantarch_wrapper.disableAttractionPoints(self._plantarch_ptr, plant_id)
655 except Exception as e:
656 raise PlantArchitectureError(f"Failed to disable attraction points: {e}")
657
658 def updateAttractionPoints(self, points: List[vec3],
659 plant_id: Optional[int] = None) -> None:
660 """
661 Replace the current attraction point set.
662
663 Args:
664 points: Replacement target locations as a list of vec3
665 plant_id: Update this plant only. Updates globally when None.
666
667 Raises:
668 ValueError: If points is empty or contains a non-vec3, or plant_id is negative
669 PlantArchitectureError: If the operation fails
670 """
671 self._validate_attraction_points(points)
672 if plant_id is not None and plant_id < 0:
673 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
674
676 try:
678 plantarch_wrapper.updateAttractionPoints(self._plantarch_ptr, plant_id, points)
679 except Exception as e:
680 raise PlantArchitectureError(f"Failed to update attraction points: {e}")
681
682 def appendAttractionPoints(self, points: List[vec3],
683 plant_id: Optional[int] = None) -> None:
684 """
685 Add to the current attraction point set.
686
687 Args:
688 points: Additional target locations as a list of vec3
689 plant_id: Append for this plant only. Appends globally when None.
690
691 Raises:
692 ValueError: If points is empty or contains a non-vec3, or plant_id is negative
693 PlantArchitectureError: If the operation fails
694 """
695 self._validate_attraction_points(points)
696 if plant_id is not None and plant_id < 0:
697 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
698
700 try:
702 plantarch_wrapper.appendAttractionPoints(self._plantarch_ptr, plant_id, points)
703 except Exception as e:
704 raise PlantArchitectureError(f"Failed to append attraction points: {e}")
705
706 def setAttractionParameters(self, view_half_angle_deg: float,
707 look_ahead_distance: float,
708 attraction_weight: float,
709 obstacle_reduction_factor: float = 0.75,
710 plant_id: Optional[int] = None) -> None:
711 """
712 Tune how strongly attraction points steer growth.
713
714 Args:
715 view_half_angle_deg: Half-angle of the search cone in degrees
716 look_ahead_distance: How far ahead a shoot tip looks, in meters
717 attraction_weight: Strength of the steering, 0 to 1
718 obstacle_reduction_factor: Scales attraction where an obstacle intervenes
719 plant_id: Apply to this plant only. Applies globally when None.
720
721 Raises:
722 ValueError: If plant_id is negative
723 PlantArchitectureError: If the operation fails
724 """
725 if plant_id is not None and plant_id < 0:
726 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
727
729 try:
731 plantarch_wrapper.setAttractionParameters(
732 self._plantarch_ptr, plant_id, view_half_angle_deg,
733 look_ahead_distance, attraction_weight, obstacle_reduction_factor
734 )
735 except Exception as e:
736 raise PlantArchitectureError(f"Failed to set attraction parameters: {e}")
737
738 @staticmethod
739 def _validate_attraction_points(points) -> None:
740 """Reject point sets the native layer would misread or silently ignore."""
741 if not isinstance(points, (list, tuple)):
742 raise ValueError(
743 f"points must be a list of vec3, got {type(points).__name__}"
744 )
745 if not points:
746 raise ValueError("points cannot be empty")
747 for index, point in enumerate(points):
748 if not isinstance(point, vec3):
749 raise ValueError(
750 f"points[{index}] must be a vec3, got {type(point).__name__}"
751 )
752
753 def setProgressCallback(self, callback):
754 """Set a callback to receive progress updates during long-running operations.
755
756 The callback fires during advanceTime() and adjustFruitForObstacleCollision()
757 as the underlying ProgressBar updates.
758
759 Args:
760 callback: A callable(progress: float, message: str) where progress is
761 in [0, 1], or None to clear the callback.
762
763 Raises:
764 ValueError: If callback is not callable and not None.
765 """
766 if callback is not None:
767 if not callable(callback):
768 raise ValueError(
769 f"callback must be callable or None, got {type(callback).__name__}"
770 )
771
772 def _c_callback(progress, message_bytes):
773 msg = message_bytes.decode('utf-8') if isinstance(message_bytes, bytes) else str(message_bytes)
774 callback(progress, msg)
775
777 self._progress_callback_ref = plantarch_wrapper.PROGRESS_CALLBACK(_c_callback)
779 plantarch_wrapper.setProgressCallback(self._plantarch_ptr, self._progress_callback_ref)
780 else:
782 plantarch_wrapper.setProgressCallback(self._plantarch_ptr, None)
783 self._progress_callback_ref = None
784
785 def setCancelFlag(self, cancel_flag):
786 """Register an external cancellation flag polled during long plant builds.
787
788 ``cancel_flag`` is a ctypes.c_int that, when set non-zero from another
789 thread, stops the canopy build loop and the advanceTime() growth loop
790 between plants/timesteps — so a long generation can be aborted mid-build
791 (returning whatever was built so far). Set it before the build call; pass
792 None to clear. The flag is caller-owned and must outlive the build.
793 """
795 plantarch_wrapper.setCancelFlag(self._plantarch_ptr, cancel_flag)
796
797 def getCurrentShootParameters(self, shoot_type_label: str, return_typed: bool = False):
798 """
799 Get current shoot parameters for a shoot type.
800
801 Returns the full nested shoot and phytomer parameter set, including the
802 internode/petiole/leaf/peduncle/inflorescence sub-structures and the leaf
803 prototype. Every numeric field is a RandomParameter spec with a
804 'distribution' and 'parameters'.
805
806 Args:
807 shoot_type_label: Label for the shoot type. Labels are species-specific,
808 e.g. "trifoliate" (bean), "trunk"/"scaffold" (almond).
809 return_typed: If True, return a typed
810 :class:`pyhelios.plant_architecture_params.ShootParameters`
811 object instead of a plain nested dict.
812
813 Returns:
814 A nested ``dict`` (default) or a ``ShootParameters`` object containing:
815 - Geometric parameters (max_nodes, insertion_angle_tip, etc.)
816 - Growth parameters (phyllochron_min, elongation_rate_max, etc.)
817 - Boolean flags (flowers_require_dormancy, etc.)
818 - ``phytomer_parameters`` with nested internode/petiole/leaf/peduncle/
819 inflorescence parameters and the leaf prototype
820
821 Raises:
822 ValueError: If shoot_type_label is empty
823 PlantArchitectureError: If parameter retrieval fails
824
825 Example:
826 >>> plantarch.loadPlantModelFromLibrary("bean")
827 >>> params = plantarch.getCurrentShootParameters("trifoliate")
828 >>> print(params['max_nodes'])
829 {'distribution': 'constant', 'parameters': [25.0]}
830 >>> print(params['phytomer_parameters']['leaf']['pitch'])
831 {'distribution': 'normal', 'parameters': [0.0, 20.0]}
832 """
833 if not shoot_type_label:
834 raise ValueError("Shoot type label cannot be empty")
835
836 if not shoot_type_label.strip():
837 raise ValueError("Shoot type label cannot be only whitespace")
838
840 try:
842 params = plantarch_wrapper.getCurrentShootParameters(
843 self._plantarch_ptr, shoot_type_label.strip()
844 )
845 except Exception as e:
846 # An unknown label is the common case here, and the native error does not say
847 # which labels exist. Name them so the caller does not have to guess.
848 available = ""
849 try:
850 labels = self.listShootTypeLabels()
851 if labels:
852 available = f" Available shoot types: {', '.join(sorted(labels))}."
853 except Exception:
854 pass
855 raise PlantArchitectureError(f"{e}.{available}")
856
857 return ShootParameters.from_dict(params) if return_typed else params
858
859 def defineShootType(self, shoot_type_label: str, parameters: Union[dict, ShootParameters]) -> None:
860 """
861 Define a custom shoot type with specified parameters.
862
863 Allows creating new shoot types or modifying existing ones. Pass either a
864 nested parameter ``dict`` (use :meth:`getCurrentShootParameters` as a
865 template) or a typed
866 :class:`pyhelios.plant_architecture_params.ShootParameters` object.
867
868 Redefining an existing library shoot type preserves that species' built-in
869 phytomer creation and callback functions, so species-specific organ behavior
870 (such as maize forming ears rather than a tassel at every node) is retained.
871
872 Args:
873 shoot_type_label: Unique name for this shoot type
874 parameters: A nested dict matching the ShootParameters structure, or a
875 ShootParameters object.
876
877 Raises:
878 ValueError: If shoot_type_label is empty, or parameters is not a dict
879 or ShootParameters
880 PlantArchitectureError: If shoot type definition fails
881
882 Example:
883 >>> from pyhelios.plant_architecture_params import ShootParameters, RandomParameterFloat
884 >>> plantarch.loadPlantModelFromLibrary("bean")
885 >>> sp = plantarch.getCurrentShootParameters("trifoliate", return_typed=True)
886 >>> sp.max_nodes = RandomParameterFloat.constant(20)
887 >>> sp.phytomer_parameters.leaf.pitch = RandomParameterFloat.uniform(40, 50)
888 >>> plantarch.defineShootType("TallStem", sp)
889 """
890 if not shoot_type_label:
891 raise ValueError("Shoot type label cannot be empty")
892
893 if not shoot_type_label.strip():
894 raise ValueError("Shoot type label cannot be only whitespace")
895
896 if isinstance(parameters, ShootParameters):
897 parameters = parameters.to_dict()
898 elif not isinstance(parameters, dict):
899 raise ValueError(
900 f"Parameters must be a dict or ShootParameters, got {type(parameters).__name__}"
901 )
904 try:
906 plantarch_wrapper.defineShootType(
907 self._plantarch_ptr, self.context.context, shoot_type_label.strip(), parameters
908 )
909 except Exception as e:
910 raise PlantArchitectureError(f"Failed to define shoot type '{shoot_type_label}': {e}")
911
912 def getDefaultCarbohydrateParameters(self, return_typed: bool = False):
913 """
914 Get a default-constructed set of carbohydrate-model parameters.
915
916 The native API exposes no per-plant getter for carbohydrate parameters, so
917 this returns the C++ defaults as a template to modify and apply via
918 :meth:`setPlantCarbohydrateParameters`.
919
920 Args:
921 return_typed: If True, return a typed
922 :class:`pyhelios.plant_architecture_params.CarbohydrateParameters`.
923
924 Returns:
925 A flat ``dict`` (default) or ``CarbohydrateParameters`` object.
926 """
928 try:
930 params = plantarch_wrapper.getDefaultCarbohydrateParameters()
931 except Exception as e:
932 raise PlantArchitectureError(f"Failed to get default carbohydrate parameters: {e}")
933 return CarbohydrateParameters.from_dict(params) if return_typed else params
934
935 def setPlantCarbohydrateParameters(self, plant_id: int, parameters: Union[dict, CarbohydrateParameters]) -> None:
936 """
937 Set carbohydrate-model parameters for a plant.
938
939 Args:
940 plant_id: Target plant instance ID
941 parameters: A flat dict or a CarbohydrateParameters object.
942
943 Raises:
944 ValueError: If parameters is not a dict or CarbohydrateParameters
945 PlantArchitectureError: If the operation fails
946 """
947 if isinstance(parameters, CarbohydrateParameters):
948 parameters = parameters.to_dict()
949 elif not isinstance(parameters, dict):
950 raise ValueError(
951 f"Parameters must be a dict or CarbohydrateParameters, got {type(parameters).__name__}"
952 )
954 try:
956 plantarch_wrapper.setPlantCarbohydrateParameters(self._plantarch_ptr, plant_id, parameters)
957 except Exception as e:
958 raise PlantArchitectureError(f"Failed to set carbohydrate parameters for plant {plant_id}: {e}")
960 def getDefaultNitrogenParameters(self, return_typed: bool = False):
961 """
962 Get a default-constructed set of nitrogen-model parameters.
963
964 The native API exposes no per-plant getter for nitrogen parameters, so this
965 returns the C++ defaults as a template to modify and apply via
966 :meth:`setPlantNitrogenParameters`.
967
968 Args:
969 return_typed: If True, return a typed
970 :class:`pyhelios.plant_architecture_params.NitrogenParameters`.
971
972 Returns:
973 A flat ``dict`` (default) or ``NitrogenParameters`` object.
974 """
976 try:
978 params = plantarch_wrapper.getDefaultNitrogenParameters()
979 except Exception as e:
980 raise PlantArchitectureError(f"Failed to get default nitrogen parameters: {e}")
981 return NitrogenParameters.from_dict(params) if return_typed else params
982
983 def setPlantNitrogenParameters(self, plant_id: int, parameters: Union[dict, NitrogenParameters]) -> None:
984 """
985 Set nitrogen-model parameters for a plant.
986
987 Args:
988 plant_id: Target plant instance ID
989 parameters: A flat dict or a NitrogenParameters object.
990
991 Raises:
992 ValueError: If parameters is not a dict or NitrogenParameters
993 PlantArchitectureError: If the operation fails
994 """
995 if isinstance(parameters, NitrogenParameters):
996 parameters = parameters.to_dict()
997 elif not isinstance(parameters, dict):
998 raise ValueError(
999 f"Parameters must be a dict or NitrogenParameters, got {type(parameters).__name__}"
1000 )
1002 try:
1004 plantarch_wrapper.setPlantNitrogenParameters(self._plantarch_ptr, plant_id, parameters)
1005 except Exception as e:
1006 raise PlantArchitectureError(f"Failed to set nitrogen parameters for plant {plant_id}: {e}")
1008 def getAvailablePlantModels(self) -> List[str]:
1009 """
1010 Get list of all available plant models in the library.
1011
1012 Returns:
1013 List of plant model names available for loading
1014
1015 Raises:
1016 PlantArchitectureError: If retrieval fails
1017
1018 Example:
1019 >>> models = plantarch.getAvailablePlantModels()
1020 >>> print(f"Available models: {', '.join(models)}")
1021 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
1022 """
1024 try:
1026 return plantarch_wrapper.getAvailablePlantModels(self._plantarch_ptr)
1027 except Exception as e:
1028 raise PlantArchitectureError(f"Failed to get available plant models: {e}")
1029
1030 def listShootTypeLabels(self, plant_model: Optional[str] = None,
1031 plant_id: Optional[int] = None) -> List[str]:
1032 """
1033 Get the shoot type labels defined for a plant model.
1034
1035 Shoot type labels are species-specific strings such as "trunk" or "scaffold", and
1036 every shoot-parameter call takes one. Use this to discover the valid labels rather
1037 than guessing them.
1038
1039 Args:
1040 plant_model: Query this library model without changing the currently loaded
1041 one. Use getAvailablePlantModels() for valid names. Mutually exclusive
1042 with plant_id.
1043 plant_id: Query the shoot types captured by this plant instance when it was
1044 created. Mutually exclusive with plant_model.
1045
1046 With neither argument, queries the currently loaded model, which requires a prior
1047 call to loadPlantModelFromLibrary().
1048
1049 Returns:
1050 List of shoot type label strings.
1051
1052 Raises:
1053 ValueError: If both plant_model and plant_id are given, or plant_id is negative
1054 PlantArchitectureError: If no model is loaded, or the model or plant is unknown
1055
1056 Example:
1057 >>> plantarch.loadPlantModelFromLibrary("almond")
1058 >>> plantarch.listShootTypeLabels()
1059 ['proleptic', 'scaffold', 'sylleptic', 'trunk']
1060 >>> plantarch.listShootTypeLabels(plant_model="bean")
1061 ['trifoliate', 'unifoliate']
1062 """
1063 if plant_model is not None and plant_id is not None:
1064 raise ValueError("Pass either plant_model or plant_id, not both")
1065 if plant_id is not None and plant_id < 0:
1066 raise ValueError(f"plant_id must be non-negative, got {plant_id}")
1067 if plant_model is not None and not plant_model.strip():
1068 raise ValueError("plant_model cannot be empty or only whitespace")
1069
1071 try:
1073 return plantarch_wrapper.listShootTypeLabels(
1075 plant_model.strip() if plant_model is not None else None,
1076 plant_id,
1077 )
1078 except Exception as e:
1079 raise PlantArchitectureError(f"Failed to list shoot type labels: {e}")
1080
1081 def getAllUUIDs(self) -> List[int]:
1082 """
1083 Get UUIDs of every plant primitive in the model.
1084
1085 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1086 such as assigning optical properties or reading flux by organ type needs.
1087
1088 Returns:
1089 List of primitive UUIDs
1090
1091 Raises:
1092 PlantArchitectureError: If retrieval fails
1093
1094 Example:
1095 >>> ids = plantarch.getAllUUIDs()
1096 """
1098 try:
1100 return plantarch_wrapper.getAllUUIDs(self._plantarch_ptr)
1101 except Exception as e:
1102 raise PlantArchitectureError(f"Failed to get primitive UUIDs: {e}")
1103
1104 def getAllLeafUUIDs(self) -> List[int]:
1105 """
1106 Get UUIDs of every leaf primitive in the model.
1107
1108 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1109 such as assigning optical properties or reading flux by organ type needs.
1110
1111 Returns:
1112 List of leaf primitive UUIDs
1113
1114 Raises:
1115 PlantArchitectureError: If retrieval fails
1116
1117 Example:
1118 >>> ids = plantarch.getAllLeafUUIDs()
1119 """
1121 try:
1123 return plantarch_wrapper.getAllLeafUUIDs(self._plantarch_ptr)
1124 except Exception as e:
1125 raise PlantArchitectureError(f"Failed to get leaf primitive UUIDs: {e}")
1126
1127 def getAllInternodeUUIDs(self) -> List[int]:
1128 """
1129 Get UUIDs of every internode primitive in the model.
1130
1131 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1132 such as assigning optical properties or reading flux by organ type needs.
1133
1134 Returns:
1135 List of internode primitive UUIDs
1136
1137 Raises:
1138 PlantArchitectureError: If retrieval fails
1139
1140 Example:
1141 >>> ids = plantarch.getAllInternodeUUIDs()
1142 """
1144 try:
1146 return plantarch_wrapper.getAllInternodeUUIDs(self._plantarch_ptr)
1147 except Exception as e:
1148 raise PlantArchitectureError(f"Failed to get internode primitive UUIDs: {e}")
1149
1150 def getAllPetioleUUIDs(self) -> List[int]:
1151 """
1152 Get UUIDs of every petiole primitive in the model.
1153
1154 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1155 such as assigning optical properties or reading flux by organ type needs.
1156
1157 Returns:
1158 List of petiole primitive UUIDs
1159
1160 Raises:
1161 PlantArchitectureError: If retrieval fails
1162
1163 Example:
1164 >>> ids = plantarch.getAllPetioleUUIDs()
1165 """
1167 try:
1169 return plantarch_wrapper.getAllPetioleUUIDs(self._plantarch_ptr)
1170 except Exception as e:
1171 raise PlantArchitectureError(f"Failed to get petiole primitive UUIDs: {e}")
1172
1173 def getAllPeduncleUUIDs(self) -> List[int]:
1174 """
1175 Get UUIDs of every peduncle primitive in the model.
1176
1177 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1178 such as assigning optical properties or reading flux by organ type needs.
1179
1180 An empty list means no plant has reached the corresponding growth stage,
1181 which is a legitimate result rather than a failure.
1182
1183 Returns:
1184 List of peduncle primitive UUIDs
1185
1186 Raises:
1187 PlantArchitectureError: If retrieval fails
1188
1189 Example:
1190 >>> ids = plantarch.getAllPeduncleUUIDs()
1191 """
1193 try:
1195 return plantarch_wrapper.getAllPeduncleUUIDs(self._plantarch_ptr)
1196 except Exception as e:
1197 raise PlantArchitectureError(f"Failed to get peduncle primitive UUIDs: {e}")
1198
1199 def getAllFlowerUUIDs(self) -> List[int]:
1200 """
1201 Get UUIDs of every flower primitive in the model.
1202
1203 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1204 such as assigning optical properties or reading flux by organ type needs.
1205
1206 An empty list means no plant has reached the corresponding growth stage,
1207 which is a legitimate result rather than a failure.
1208
1209 Returns:
1210 List of flower primitive UUIDs
1211
1212 Raises:
1213 PlantArchitectureError: If retrieval fails
1214
1215 Example:
1216 >>> ids = plantarch.getAllFlowerUUIDs()
1217 """
1219 try:
1221 return plantarch_wrapper.getAllFlowerUUIDs(self._plantarch_ptr)
1222 except Exception as e:
1223 raise PlantArchitectureError(f"Failed to get flower primitive UUIDs: {e}")
1224
1225 def getAllFruitUUIDs(self) -> List[int]:
1226 """
1227 Get UUIDs of every fruit primitive in the model.
1228
1229 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1230 such as assigning optical properties or reading flux by organ type needs.
1231
1232 An empty list means no plant has reached the corresponding growth stage,
1233 which is a legitimate result rather than a failure.
1234
1235 Returns:
1236 List of fruit primitive UUIDs
1237
1238 Raises:
1239 PlantArchitectureError: If retrieval fails
1240
1241 Example:
1242 >>> ids = plantarch.getAllFruitUUIDs()
1243 """
1245 try:
1247 return plantarch_wrapper.getAllFruitUUIDs(self._plantarch_ptr)
1248 except Exception as e:
1249 raise PlantArchitectureError(f"Failed to get fruit primitive UUIDs: {e}")
1250
1251 def getAllObjectIDs(self) -> List[int]:
1252 """
1253 Get object IDs of every plant compound object in the model.
1254
1255 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1256 such as assigning optical properties or reading flux by organ type needs.
1257
1258 Returns:
1259 List of object IDs
1260
1261 Raises:
1262 PlantArchitectureError: If retrieval fails
1263
1264 Example:
1265 >>> ids = plantarch.getAllObjectIDs()
1266 """
1268 try:
1270 return plantarch_wrapper.getAllObjectIDs(self._plantarch_ptr)
1271 except Exception as e:
1272 raise PlantArchitectureError(f"Failed to get object IDs: {e}")
1273
1274 def getAllPlantIDs(self) -> List[int]:
1275 """
1276 Get IDs of every plant instance in the model.
1277
1278 Spans every plant, unlike the per-plant getters, which is what canopy-wide work
1279 such as assigning optical properties or reading flux by organ type needs.
1280
1281 Returns:
1282 List of plant IDs
1283
1284 Raises:
1285 PlantArchitectureError: If retrieval fails
1286
1287 Example:
1288 >>> ids = plantarch.getAllPlantIDs()
1289 """
1291 try:
1293 return plantarch_wrapper.getAllPlantIDs(self._plantarch_ptr)
1294 except Exception as e:
1295 raise PlantArchitectureError(f"Failed to get plant IDs: {e}")
1296
1297 def getAllPlantObjectIDs(self, plant_id: int) -> List[int]:
1298 """
1299 Get all object IDs for a specific plant.
1300
1301 Args:
1302 plant_id: ID of the plant instance
1303
1304 Returns:
1305 List of object IDs comprising the plant
1306
1307 Raises:
1308 ValueError: If plant_id is negative
1309 PlantArchitectureError: If retrieval fails
1310
1311 Example:
1312 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
1313 >>> print(f"Plant has {len(object_ids)} objects")
1314 """
1315 if plant_id < 0:
1316 raise ValueError("Plant ID must be non-negative")
1317
1319 try:
1320 return plantarch_wrapper.getAllPlantObjectIDs(self._plantarch_ptr, plant_id)
1321 except Exception as e:
1322 raise PlantArchitectureError(f"Failed to get object IDs for plant {plant_id}: {e}")
1323
1324 def getPlantLeafObjectIDs(self, plant_id: int) -> List[int]:
1325 """
1326 Get object IDs for all leaf objects on a specific plant.
1328 Args:
1329 plant_id: ID of the plant instance
1330
1331 Returns:
1332 List of object IDs, one per leaf
1333
1334 Raises:
1335 ValueError: If plant_id is negative
1336 PlantArchitectureError: If retrieval fails
1337
1338 Warning:
1339 Do **not** pair this result positionally with :meth:`getPlantLeafBases`.
1340 The two are built by independent traversals of the shoot tree, so their
1341 index correspondence is not guaranteed by the native API.
1342
1343 Example:
1344 >>> leaf_ids = plantarch.getPlantLeafObjectIDs(plant_id)
1345 >>> print(f"Plant has {len(leaf_ids)} leaves")
1346 """
1347 if plant_id < 0:
1348 raise ValueError("Plant ID must be non-negative")
1349
1351 try:
1352 return plantarch_wrapper.getPlantLeafObjectIDs(self._plantarch_ptr, plant_id)
1353 except Exception as e:
1354 raise PlantArchitectureError(f"Failed to get leaf object IDs for plant {plant_id}: {e}")
1355
1356 def getPlantPetioleObjectIDs(self, plant_id: int) -> List[int]:
1357 """
1358 Get object IDs for all petiole objects on a specific plant.
1360 Petioles are the stalks attaching leaves to the stem, so this is the
1361 structural counterpart to :meth:`getPlantLeafObjectIDs`.
1362
1363 Args:
1364 plant_id: ID of the plant instance
1365
1366 Returns:
1367 List of object IDs, one per petiole
1368
1369 Raises:
1370 ValueError: If plant_id is negative
1371 PlantArchitectureError: If retrieval fails
1372
1373 Example:
1374 >>> petiole_ids = plantarch.getPlantPetioleObjectIDs(plant_id)
1375 >>> print(f"Plant has {len(petiole_ids)} petioles")
1376 """
1377 if plant_id < 0:
1378 raise ValueError("Plant ID must be non-negative")
1379
1381 try:
1382 return plantarch_wrapper.getPlantPetioleObjectIDs(self._plantarch_ptr, plant_id)
1383 except Exception as e:
1384 raise PlantArchitectureError(f"Failed to get petiole object IDs for plant {plant_id}: {e}")
1385
1386 def getPlantPeduncleObjectIDs(self, plant_id: int) -> List[int]:
1387 """
1388 Get object IDs for all peduncle objects on a specific plant.
1390 Peduncles are the stalks bearing flowers and fruit.
1391
1392 Args:
1393 plant_id: ID of the plant instance
1394
1395 Returns:
1396 List of object IDs, one per peduncle. Empty if the plant has not
1397 reached its reproductive stage, which is a normal result rather than
1398 an error.
1399
1400 Raises:
1401 ValueError: If plant_id is negative
1402 PlantArchitectureError: If retrieval fails
1403
1404 Example:
1405 >>> peduncle_ids = plantarch.getPlantPeduncleObjectIDs(plant_id)
1406 >>> print(f"Plant has {len(peduncle_ids)} peduncles")
1407 """
1408 if plant_id < 0:
1409 raise ValueError("Plant ID must be non-negative")
1410
1412 try:
1413 return plantarch_wrapper.getPlantPeduncleObjectIDs(self._plantarch_ptr, plant_id)
1414 except Exception as e:
1415 raise PlantArchitectureError(f"Failed to get peduncle object IDs for plant {plant_id}: {e}")
1416
1417 def getPlantFlowerObjectIDs(self, plant_id: int) -> List[int]:
1418 """
1419 Get object IDs for all flower (inflorescence) objects on a specific plant.
1421 Args:
1422 plant_id: ID of the plant instance
1423
1424 Returns:
1425 List of object IDs, one per flower. Empty if the plant has not
1426 flowered -- or has already flowered and set fruit, since flowers are
1427 replaced by fruit as growth proceeds. Both are normal results rather
1428 than errors.
1429
1430 Raises:
1431 ValueError: If plant_id is negative
1432 PlantArchitectureError: If retrieval fails
1433
1434 Example:
1435 >>> flower_ids = plantarch.getPlantFlowerObjectIDs(plant_id)
1436 >>> print(f"Plant has {len(flower_ids)} flowers")
1437 """
1438 if plant_id < 0:
1439 raise ValueError("Plant ID must be non-negative")
1440
1442 try:
1443 return plantarch_wrapper.getPlantFlowerObjectIDs(self._plantarch_ptr, plant_id)
1444 except Exception as e:
1445 raise PlantArchitectureError(f"Failed to get flower object IDs for plant {plant_id}: {e}")
1446
1447 def getPlantFruitObjectIDs(self, plant_id: int) -> List[int]:
1448 """
1449 Get object IDs for all fruit objects on a specific plant.
1451 Args:
1452 plant_id: ID of the plant instance
1453
1454 Returns:
1455 List of object IDs, one per fruit. Empty if the plant has not
1456 fruited, which is a normal result rather than an error -- fruit
1457 appear only once a plant reaches the reproductive stage, so a plant
1458 built at a young age or from a model with no fruit yields ``[]``.
1459
1460 Raises:
1461 ValueError: If plant_id is negative
1462 PlantArchitectureError: If retrieval fails
1463
1464 Example:
1465 >>> fruit_ids = plantarch.getPlantFruitObjectIDs(plant_id)
1466 >>> print(f"Plant has {len(fruit_ids)} fruit")
1467 >>> # Object IDs are Context object IDs, so the usual queries apply:
1468 >>> uuids = context.getObjectPrimitiveUUIDs(fruit_ids[0])
1469 """
1470 if plant_id < 0:
1471 raise ValueError("Plant ID must be non-negative")
1472
1474 try:
1475 return plantarch_wrapper.getPlantFruitObjectIDs(self._plantarch_ptr, plant_id)
1476 except Exception as e:
1477 raise PlantArchitectureError(f"Failed to get fruit object IDs for plant {plant_id}: {e}")
1478
1479 def getPlantLeafBases(self, plant_id: int) -> List[vec3]:
1480 """
1481 Get the attachment base position of every leaf on a specific plant.
1483 The base is where the leaf attaches to its petiole, not the leaf centroid.
1484
1485 Args:
1486 plant_id: ID of the plant instance
1487
1488 Returns:
1489 List of vec3 base positions, one per leaf
1490
1491 Raises:
1492 ValueError: If plant_id is negative
1493 PlantArchitectureError: If retrieval fails
1494
1495 Warning:
1496 Do **not** pair this result positionally with
1497 :meth:`getPlantLeafObjectIDs`. The two are built by independent
1498 traversals of the shoot tree, so their index correspondence is not
1499 guaranteed by the native API. (helios-core has an internal
1500 ``getPlantLeafObjectIDsAndBases()`` that gathers both in one traversal
1501 for exactly this reason, but it is protected and not callable from here.)
1502
1503 Example:
1504 >>> bases = plantarch.getPlantLeafBases(plant_id)
1505 >>> print(f"First leaf attaches at {bases[0]}")
1506 """
1507 if plant_id < 0:
1508 raise ValueError("Plant ID must be non-negative")
1509
1511 try:
1512 flat = plantarch_wrapper.getPlantLeafBases(self._plantarch_ptr, plant_id)
1513 except Exception as e:
1514 raise PlantArchitectureError(f"Failed to get leaf bases for plant {plant_id}: {e}")
1515
1516 return [vec3(float(flat[i]), float(flat[i + 1]), float(flat[i + 2]))
1517 for i in range(0, len(flat), 3)]
1518
1519 def getAllPlantUUIDs(self, plant_id: int, include_hidden: bool = False) -> List[int]:
1520 """
1521 Get all primitive UUIDs for a specific plant.
1522
1523 Args:
1524 plant_id: ID of the plant instance
1525 include_hidden: If True, also include UUIDs of hidden prototype
1526 primitives managed by this PlantArchitecture instance.
1527
1528 Returns:
1529 List of primitive UUIDs comprising the plant (and optionally hidden prototypes)
1530
1531 Raises:
1532 ValueError: If plant_id is negative
1533 PlantArchitectureError: If retrieval fails
1534
1535 Example:
1536 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
1537 >>> print(f"Plant has {len(uuids)} primitives")
1538 """
1539 if plant_id < 0:
1540 raise ValueError("Plant ID must be non-negative")
1541
1543 try:
1544 return plantarch_wrapper.getAllPlantUUIDs(self._plantarch_ptr, plant_id, include_hidden)
1545 except Exception as e:
1546 raise PlantArchitectureError(f"Failed to get UUIDs for plant {plant_id}: {e}")
1547
1548 def getAllShootIDs(self, plant_id: int) -> List[int]:
1549 """
1550 Get the IDs of all shoots belonging to a plant.
1552 Shoot IDs are contiguous 0-based indices into the plant's shoot tree, in creation
1553 order; shoot 0 is always the base stem. The returned IDs can be passed to
1554 :meth:`getShoot`, :meth:`getShootChildIDs`, etc.
1555
1556 Args:
1557 plant_id: ID of the plant instance
1558
1559 Returns:
1560 List of shoot IDs for the plant
1561 """
1562 if plant_id < 0:
1563 raise ValueError("Plant ID must be non-negative")
1565 try:
1566 return plantarch_wrapper.getAllPlantShootIDs(self._plantarch_ptr, plant_id)
1567 except Exception as e:
1568 raise PlantArchitectureError(f"Failed to get shoot IDs for plant {plant_id}: {e}")
1569
1570 def getShoot(self, plant_id: int, shoot_id: int) -> Dict[str, Any]:
1571 """
1572 Get a read-only view of a shoot's topology.
1573
1574 Args:
1575 plant_id: ID of the plant instance
1576 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
1577
1578 Returns:
1579 A dict with keys ``rank``, ``parent_shoot_id`` (-1 for the base stem),
1580 ``parent_node_index``, and ``node_count``.
1581 """
1582 if plant_id < 0 or shoot_id < 0:
1583 raise ValueError("Plant ID and shoot ID must be non-negative")
1585 try:
1586 return plantarch_wrapper.getPlantShootTopology(self._plantarch_ptr, plant_id, shoot_id)
1587 except Exception as e:
1589 f"Failed to get shoot {shoot_id} of plant {plant_id}: {e}")
1590
1591 def getShootChildIDs(self, plant_id: int, shoot_id: int) -> List[int]:
1592 """Get the child shoot IDs of a shoot (flattened across parent node indices)."""
1593 if plant_id < 0 or shoot_id < 0:
1594 raise ValueError("Plant ID and shoot ID must be non-negative")
1596 try:
1597 return plantarch_wrapper.getPlantShootChildIDs(self._plantarch_ptr, plant_id, shoot_id)
1598 except Exception as e:
1600 f"Failed to get child shoots of shoot {shoot_id}, plant {plant_id}: {e}")
1601
1602 def getParentShootID(self, plant_id: int, shoot_id: int) -> int:
1603 """
1604 Get the ID of the shoot a shoot grew from.
1606 Args:
1607 plant_id: ID of the plant instance
1608 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
1609
1610 Returns:
1611 ID of the parent shoot, or -1 if this is the base stem shoot.
1612
1613 Note:
1614 A pruned shoot still reports the parent it grew from, even though it is no
1615 longer listed among that parent's children.
1616
1617 Example:
1618 >>> parent = plantarch.getParentShootID(plant_id, shoot_id=3)
1619 """
1620 return self._shootScalarQuery("getParentShootID", plant_id, shoot_id, "parent shoot ID")
1621
1622 def getShootRank(self, plant_id: int, shoot_id: int) -> int:
1623 """
1624 Get the branching rank of a shoot.
1625
1626 Rank is the botanical branching order: the base stem is rank 0, a branch off it
1627 is rank 1, and so on. A shoot created by :meth:`appendShoot` continues its
1628 parent's axis rather than branching from it, so it keeps the parent's rank.
1629 Rank is therefore not the same as :meth:`getShootDepth`.
1630
1631 Args:
1632 plant_id: ID of the plant instance
1633 shoot_id: Shoot index within the plant
1634
1635 Returns:
1636 Branching rank of the shoot.
1637
1638 Example:
1639 >>> rank = plantarch.getShootRank(plant_id, shoot_id=3)
1640 """
1641 return self._shootScalarQuery("getShootRank", plant_id, shoot_id, "shoot rank")
1642
1643 def getShootDepth(self, plant_id: int, shoot_id: int) -> int:
1644 """
1645 Get the number of shoots between a shoot and the base stem shoot.
1646
1647 The base stem has depth 0, its children depth 1, and so on. Unlike
1648 :meth:`getShootRank` this counts every step in the shoot tree, including axis
1649 continuations created by :meth:`appendShoot`.
1650
1651 Args:
1652 plant_id: ID of the plant instance
1653 shoot_id: Shoot index within the plant
1654
1655 Returns:
1656 Number of steps from this shoot to the base stem shoot.
1657 """
1658 return self._shootScalarQuery("getShootDepth", plant_id, shoot_id, "shoot depth")
1659
1660 def isShootPruned(self, plant_id: int, shoot_id: int) -> bool:
1661 """
1662 Report whether a shoot has been pruned away entirely.
1663
1664 :meth:`pruneBranch` called with ``node_index=0`` removes all of a shoot's
1665 phytomers and geometry but keeps the shoot in the plant's tree so that shoot IDs
1666 stay stable. Such a shoot is still returned by :meth:`getAllShootIDs` but is
1667 inert: it has zero nodes, contributes no leaf area, and cannot be queried for
1668 geometry. Use this to skip those shoots when walking :meth:`getAllShootIDs`.
1669
1670 Args:
1671 plant_id: ID of the plant instance
1672 shoot_id: Shoot index within the plant
1673
1674 Returns:
1675 True if the shoot was pruned away and no longer forms part of the plant.
1676
1677 Example:
1678 >>> live = [s for s in plantarch.getAllShootIDs(plant_id)
1679 ... if not plantarch.isShootPruned(plant_id, s)]
1680 """
1681 return self._shootScalarQuery("isShootPruned", plant_id, shoot_id, "pruned state")
1682
1683 def getPathToRoot(self, plant_id: int, shoot_id: int) -> List[int]:
1684 """
1685 Get the chain of shoots connecting a shoot to the base stem shoot.
1686
1687 Args:
1688 plant_id: ID of the plant instance
1689 shoot_id: Shoot index within the plant
1690
1691 Returns:
1692 Shoot IDs ordered from the given shoot to the base stem shoot, including
1693 both. For the base stem shoot this is a single element.
1694
1695 Example:
1696 >>> path = plantarch.getPathToRoot(plant_id, shoot_id=5)
1697 """
1698 return self._shootScalarQuery("getPathToRoot", plant_id, shoot_id, "path to root")
1699
1700 def getChildShootIDs(self, plant_id: int, shoot_id: int) -> List[int]:
1701 """
1702 Get the shoots that grew directly out of a shoot.
1703
1704 Ordered by the node they attach to. This includes shoots created by
1705 :meth:`appendShoot`, which continue the parent's axis rather than branching from
1706 it; compare their :meth:`getShootRank` with the parent's to tell the two apart.
1707 Pruned shoots are not included.
1708
1709 Args:
1710 plant_id: ID of the plant instance
1711 shoot_id: Shoot index within the plant
1712
1713 Returns:
1714 IDs of the direct children of the shoot, empty if it has none.
1715 """
1716 return self._shootScalarQuery("getChildShootIDs", plant_id, shoot_id, "child shoot IDs")
1717
1718 def getAllDescendantShootIDs(self, plant_id: int, shoot_id: int) -> List[int]:
1719 """
1720 Get every shoot descending from a shoot.
1721
1722 Collected depth-first, so a shoot is always listed before its own descendants.
1723 The shoot itself is not included, and pruned shoots are omitted.
1724
1725 Args:
1726 plant_id: ID of the plant instance
1727 shoot_id: Shoot whose descendants to collect
1729 Returns:
1730 IDs of all descendants of the shoot, empty if it has none.
1731
1732 Example:
1733 >>> descendants = plantarch.getAllDescendantShootIDs(plant_id, shoot_id=1)
1734 >>> print(f"Branch carries {len(descendants)} sub-shoots")
1735 """
1736 return self._shootScalarQuery("getAllDescendantShootIDs", plant_id, shoot_id,
1737 "descendant shoot IDs")
1738
1739 def getShootHierarchyMap(self, plant_id: int) -> Dict[int, List[int]]:
1740 """
1741 Get the parent-to-children structure of a plant.
1742
1743 Only shoots that actually have children appear as keys. Pruned shoots appear
1744 neither as keys nor among the children.
1745
1746 Args:
1747 plant_id: ID of the plant instance
1749 Returns:
1750 Dict mapping shoot ID to the IDs of its direct children.
1751
1752 Example:
1753 >>> hierarchy = plantarch.getShootHierarchyMap(plant_id)
1754 >>> print(f"{len(hierarchy)} shoots carry branches")
1755 """
1756 if plant_id < 0:
1757 raise ValueError("Plant ID must be non-negative")
1759 try:
1760 return plantarch_wrapper.getShootHierarchyMap(self._plantarch_ptr, plant_id)
1761 except Exception as e:
1763 f"Failed to get shoot hierarchy of plant {plant_id}: {e}")
1764
1765 def _shootScalarQuery(self, wrapper_fn_name: str, plant_id: int, shoot_id: int,
1766 description: str):
1767 """Shared body for the per-shoot hierarchy accessors."""
1768 if plant_id < 0 or shoot_id < 0:
1769 raise ValueError("Plant ID and shoot ID must be non-negative")
1771 try:
1772 return getattr(plantarch_wrapper, wrapper_fn_name)(
1773 self._plantarch_ptr, plant_id, shoot_id)
1774 except Exception as e:
1776 f"Failed to get {description} of shoot {shoot_id}, plant {plant_id}: {e}")
1777
1778 def getShootInternodeVertices(self, plant_id: int, shoot_id: int) -> List[tuple]:
1779 """Get the woody internode polyline vertices of a shoot as a list of (x, y, z) tuples."""
1780 if plant_id < 0 or shoot_id < 0:
1781 raise ValueError("Plant ID and shoot ID must be non-negative")
1783 try:
1784 return plantarch_wrapper.getPlantShootInternodeVertices(self._plantarch_ptr, plant_id, shoot_id)
1785 except Exception as e:
1787 f"Failed to get internode vertices of shoot {shoot_id}, plant {plant_id}: {e}")
1788
1789 def getShootInternodeRadii(self, plant_id: int, shoot_id: int) -> List[float]:
1790 """Get the per-vertex woody internode radii of a shoot."""
1791 if plant_id < 0 or shoot_id < 0:
1792 raise ValueError("Plant ID and shoot ID must be non-negative")
1794 try:
1795 return plantarch_wrapper.getPlantShootInternodeRadii(self._plantarch_ptr, plant_id, shoot_id)
1796 except Exception as e:
1798 f"Failed to get internode radii of shoot {shoot_id}, plant {plant_id}: {e}")
1799
1800 # =========================================================================
1801 # Built-geometry organ queries (helios-core 1.3.85+)
1802 # =========================================================================
1803
1804 def _plantFloatVector(self, wrapper_fn_name: str, plant_id: int, description: str) -> List[float]:
1805 """Shared body for the per-organ built-geometry queries."""
1806 if plant_id < 0:
1807 raise ValueError("Plant ID must be non-negative")
1809 try:
1810 return getattr(plantarch_wrapper, wrapper_fn_name)(self._plantarch_ptr, plant_id)
1811 except Exception as e:
1812 raise PlantArchitectureError(f"Failed to get {description} for plant {plant_id}: {e}")
1813
1814 def getPlantLeafAreas(self, plant_id: int) -> List[float]:
1815 """
1816 Get the built one-sided surface area of every leaf on a plant.
1817
1818 Measured from the geometry that was actually built, rather than reported from
1819 the shoot type's parameters. The two answer different questions: the shoot type
1820 gives the distribution a parameter was drawn from, while this gives what the
1821 plant ended up with. A plant whose leaf parameters carry a wide spread can still
1822 deliver leaves of a single size (a random parameter caches its first draw, and a
1823 shoot holds a copy of its type's parameters), and nothing in the parameters
1824 themselves would reveal that.
1825
1826 This reports present area, so a leaf part-way through its growth is counted at
1827 its current size. Leaves are visited shoot by shoot and then phytomer by phytomer,
1828 the same order as :meth:`getPlantLeafObjectIDs`. Leaves whose geometry does not
1829 exist (removed, senesced, or never built) are omitted rather than reported as
1830 zero, so the result can be shorter than the list from :meth:`getPlantLeafObjectIDs`.
1831
1832 Requires helios-core v1.3.85 or newer.
1833
1834 Args:
1835 plant_id: ID of the plant instance
1836
1837 Returns:
1838 One-sided surface area (m^2) of each leaf on the plant
1839
1840 Raises:
1841 ValueError: If plant_id is negative
1842 PlantArchitectureError: If the query fails or the library predates v1.3.85
1843
1844 Example:
1845 >>> areas = plantarch.getPlantLeafAreas(plant_id)
1846 >>> print(f"{len(areas)} leaves, mean {sum(areas)/len(areas):.4f} m^2")
1847 """
1848 return self._plantFloatVector("getPlantLeafAreas", plant_id, "leaf areas")
1849
1850 def getPlantInternodeLengths(self, plant_id: int) -> List[float]:
1851 """
1852 Get the built length of every internode on a plant.
1853
1854 Measured along the internode's node positions as they were built, so a shoot
1855 whose geometry was prescribed by :meth:`addShootFromNodePositions` reports its
1856 measured lengths and a grown shoot reports what growth produced. See
1857 :meth:`getPlantLeafAreas` for why this differs from reading the shoot type's
1858 ``internode_length_max``.
1859
1860 Internodes are visited shoot by shoot and then phytomer by phytomer, so the
1861 result has one entry per phytomer on the plant.
1862
1863 Requires helios-core v1.3.85 or newer.
1865 Args:
1866 plant_id: ID of the plant instance
1867
1868 Returns:
1869 Length (m) of each internode on the plant
1870
1871 Raises:
1872 ValueError: If plant_id is negative
1873 PlantArchitectureError: If the query fails or the library predates v1.3.85
1874 """
1875 return self._plantFloatVector("getPlantInternodeLengths", plant_id, "internode lengths")
1876
1877 def getPlantLeafInclinations(self, plant_id: int) -> List[float]:
1878 """
1879 Get the inclination angle of every leaf on a plant.
1880
1881 The angle between each leaf blade and the horizontal, computed from its
1882 area-weighted normal so that a curved or folded blade is summarized by the
1883 direction it mostly faces. 0 degrees is a horizontal blade and 90 degrees a
1884 vertical one; because a blade is a surface, a normal pointing down describes the
1885 same inclination as its opposite pointing up, so the angle is folded about the
1886 horizontal and never exceeds 90 degrees.
1887
1888 Ordering and the treatment of missing geometry match :meth:`getPlantLeafAreas`.
1889 A blade whose facet normals cancel exactly is additionally omitted, since it
1890 faces no single direction.
1892 Requires helios-core v1.3.85 or newer.
1893
1894 Args:
1895 plant_id: ID of the plant instance
1896
1897 Returns:
1898 Inclination angle (degrees, in [0, 90]) of each leaf on the plant
1899
1900 Raises:
1901 ValueError: If plant_id is negative
1902 PlantArchitectureError: If the query fails or the library predates v1.3.85
1903 """
1904 return self._plantFloatVector("getPlantLeafInclinations", plant_id, "leaf inclinations")
1905
1906 def isShootGeometryPrescribed(self, plant_id: int, shoot_id: int) -> bool:
1907 """
1908 Report whether a shoot's existing geometry was prescribed by the caller rather than generated.
1909
1910 True for a shoot built by :meth:`addShootFromNodePositions`, whose internode path
1911 follows measured node positions. Such a shoot's existing phytomers are exempt
1912 from the re-scaling and re-curving performed by :meth:`advanceTime`, so a caller
1913 reading geometry back can tell which parts of a plant are measurement and which
1914 are model output.
1915
1916 Requires helios-core v1.3.85 or newer.
1917
1918 Args:
1919 plant_id: ID of the plant instance
1920 shoot_id: Shoot index within the plant
1921
1922 Returns:
1923 True if the shoot was built from prescribed node positions
1924
1925 Raises:
1926 ValueError: If either ID is negative
1927 PlantArchitectureError: If the query fails or the library predates v1.3.85
1928 """
1929 return self._shootScalarQuery("isShootGeometryPrescribed", plant_id, shoot_id,
1930 "prescribed-geometry state")
1931
1932 def getPlantAge(self, plant_id: int) -> float:
1933 """
1934 Get the current age of a plant in days.
1935
1936 Args:
1937 plant_id: ID of the plant instance
1938
1939 Returns:
1940 Plant age in days
1941
1942 Raises:
1943 ValueError: If plant_id is negative
1944 PlantArchitectureError: If retrieval fails
1946 Example:
1947 >>> age = plantarch.getPlantAge(plant_id)
1948 >>> print(f"Plant is {age} days old")
1949 """
1950 if plant_id < 0:
1951 raise ValueError("Plant ID must be non-negative")
1952
1954 try:
1956 return plantarch_wrapper.getPlantAge(self._plantarch_ptr, plant_id)
1957 except Exception as e:
1958 raise PlantArchitectureError(f"Failed to get age for plant {plant_id}: {e}")
1959
1960 def getPlantMaxAge(self, plant_id: int) -> float:
1961 """
1962 Get the maximum age of a plant, beyond which it stops growing.
1963
1964 Args:
1965 plant_id: ID of the plant instance
1967 Returns:
1968 Maximum plant age in days. See :meth:`setPlantMaxAge`.
1969
1970 Raises:
1971 ValueError: If plant_id is negative
1972 PlantArchitectureError: If retrieval fails
1973
1974 Example:
1975 >>> max_age = plantarch.getPlantMaxAge(plant_id)
1976 """
1977 if plant_id < 0:
1978 raise ValueError("Plant ID must be non-negative")
1980 try:
1981 return plantarch_wrapper.getPlantMaxAge(self._plantarch_ptr, plant_id)
1982 except Exception as e:
1984 f"Failed to get maximum age of plant {plant_id}: {e}")
1985
1986 def setPlantMaxAge(self, plant_id: int, max_age: float) -> None:
1987 """
1988 Set the maximum age of a plant, beyond which it stops growing.
1989
1990 Once a plant's age reaches this value, :meth:`advanceTime` stops advancing it and
1991 its geometry becomes static. The default is 999 days. Every plant model in the
1992 library sets its own value as part of its builder (an apple tree, for example,
1993 uses 1460 days), but a plant assembled manually with :meth:`addPlantInstance`
1994 keeps the default and so silently stops growing after 999 days.
1995
1996 Setting a maximum age below the plant's current age is permitted, and freezes the
1997 plant at its current form.
1998
1999 Args:
2000 plant_id: ID of the plant instance
2001 max_age: Maximum age of the plant in days. Must be non-negative.
2002
2003 Raises:
2004 ValueError: If plant_id is negative or max_age is negative
2005 PlantArchitectureError: If the plant does not exist
2006
2007 Example:
2008 >>> plantarch.setPlantMaxAge(plant_id, 1460.0)
2009 """
2010 if plant_id < 0:
2011 raise ValueError("Plant ID must be non-negative")
2012 if max_age < 0:
2013 raise ValueError(f"Maximum age must be non-negative, got {max_age}")
2015 try:
2016 plantarch_wrapper.setPlantMaxAge(self._plantarch_ptr, plant_id, max_age)
2017 except Exception as e:
2019 f"Failed to set maximum age of plant {plant_id}: {e}")
2020
2021 def getPlantHeight(self, plant_id: int) -> float:
2022 """
2023 Get the height of a plant in meters.
2024
2025 Args:
2026 plant_id: ID of the plant instance
2027
2028 Returns:
2029 Plant height in meters (vertical extent)
2030
2031 Raises:
2032 ValueError: If plant_id is negative
2033 PlantArchitectureError: If retrieval fails
2034
2035 Example:
2036 >>> height = plantarch.getPlantHeight(plant_id)
2037 >>> print(f"Plant is {height:.2f}m tall")
2038 """
2039 if plant_id < 0:
2040 raise ValueError("Plant ID must be non-negative")
2041
2043 try:
2045 return plantarch_wrapper.getPlantHeight(self._plantarch_ptr, plant_id)
2046 except Exception as e:
2047 raise PlantArchitectureError(f"Failed to get height for plant {plant_id}: {e}")
2048
2049 def getPlantLeafArea(self, plant_id: int) -> float:
2050 """
2051 Get the total leaf area of a plant in m².
2052
2053 Args:
2054 plant_id: ID of the plant instance
2056 Returns:
2057 Total leaf area in square meters
2058
2059 Raises:
2060 ValueError: If plant_id is negative
2061 PlantArchitectureError: If retrieval fails
2062
2063 Example:
2064 >>> leaf_area = plantarch.getPlantLeafArea(plant_id)
2065 >>> print(f"Total leaf area: {leaf_area:.3f} m²")
2066 """
2067 if plant_id < 0:
2068 raise ValueError("Plant ID must be non-negative")
2069
2071 try:
2073 return plantarch_wrapper.sumPlantLeafArea(self._plantarch_ptr, plant_id)
2074 except Exception as e:
2075 raise PlantArchitectureError(f"Failed to get leaf area for plant {plant_id}: {e}")
2076
2077 def optionalOutputObjectData(self, object_data_labels: Union[str, List[str]]) -> None:
2078 """
2079 Enable optional output object data to be written to the Context.
2080
2081 By default, the plant architecture model only writes a minimal set of
2082 object data. This method enables additional object data fields so that
2083 they are available on the Context's compound objects after building.
2084
2085 Args:
2086 object_data_labels: A single label or a list of labels to enable.
2087 Valid labels include: "age", "rank", "plantID", "plant_name",
2088 "plant_height", "plant_type", "phenology_stage", "leafID",
2089 "peduncleID", "closedflowerID", "openflowerID", "fruitID",
2090 "carbohydrate_concentration". The special label "all" enables
2091 every available field.
2092
2093 Raises:
2094 ValueError: If a label is empty or not a string
2095 PlantArchitectureError: If an invalid label is supplied or the
2096 operation otherwise fails
2097
2098 Example:
2099 >>> plantarch.optionalOutputObjectData("age")
2100 >>> plantarch.optionalOutputObjectData(["rank", "plant_height"])
2101 >>> plantarch.optionalOutputObjectData("all")
2102 """
2103 if isinstance(object_data_labels, str):
2104 labels = [object_data_labels]
2105 else:
2106 labels = list(object_data_labels)
2107
2109 try:
2111 for label in labels:
2112 plantarch_wrapper.optionalOutputObjectData(self._plantarch_ptr, label)
2113 except ValueError:
2114 raise
2115 except Exception as e:
2116 raise PlantArchitectureError(f"Failed to enable optional output object data: {e}")
2117
2119 self,
2120 plant_id: int,
2121 time_to_dormancy_break: float,
2122 time_to_flower_initiation: float,
2123 time_to_flower_opening: float,
2124 time_to_fruit_set: float,
2125 time_to_fruit_maturity: float,
2126 time_to_dormancy: float,
2127 max_leaf_lifespan: float = 1e6,
2128 is_evergreen: bool = False
2129 ) -> None:
2130 """
2131 Set phenological timing thresholds for plant developmental stages.
2132
2133 Controls the timing of key phenological events based on thermal time
2134 or calendar time depending on the plant model.
2135
2136 Args:
2137 plant_id: ID of the plant instance
2138 time_to_dormancy_break: Degree-days or days until dormancy ends
2139 time_to_flower_initiation: Time until flower buds are initiated
2140 time_to_flower_opening: Time until flowers open
2141 time_to_fruit_set: Time until fruit begins developing
2142 time_to_fruit_maturity: Time until fruit reaches maturity
2143 time_to_dormancy: Time until plant enters dormancy
2144 max_leaf_lifespan: Maximum leaf lifespan in days (default: 1e6)
2145 is_evergreen: If True, the plant retains leaves through dormancy
2146 instead of shedding them at senescence (default: False)
2147
2148 Raises:
2149 ValueError: If plant_id is negative
2150 PlantArchitectureError: If phenology setting fails
2151
2152 Example:
2153 >>> # Set phenology for perennial fruit tree
2154 >>> plantarch.setPlantPhenologicalThresholds(
2155 ... plant_id=plant_id,
2156 ... time_to_dormancy_break=60, # Spring: 60 degree-days
2157 ... time_to_flower_initiation=90, # Early spring flowering
2158 ... time_to_flower_opening=105, # Bloom period
2159 ... time_to_fruit_set=120, # Fruit set after pollination
2160 ... time_to_fruit_maturity=200, # Summer fruit maturation
2161 ... time_to_dormancy=280, # Fall dormancy
2162 ... max_leaf_lifespan=180 # Deciduous - 6 month leaf life
2163 ... )
2164 """
2165 if plant_id < 0:
2166 raise ValueError("Plant ID must be non-negative")
2167
2169 try:
2171 plantarch_wrapper.setPlantPhenologicalThresholds(
2172 self._plantarch_ptr,
2173 plant_id,
2174 time_to_dormancy_break,
2175 time_to_flower_initiation,
2176 time_to_flower_opening,
2177 time_to_fruit_set,
2178 time_to_fruit_maturity,
2179 time_to_dormancy,
2180 max_leaf_lifespan,
2181 is_evergreen
2182 )
2183 except Exception as e:
2184 raise PlantArchitectureError(f"Failed to set phenological thresholds for plant {plant_id}: {e}")
2185
2186 def disablePlantPhenology(self, plant_id: int) -> None:
2187 """
2188 Disable phenological progression for a plant.
2189
2190 The plant continues to grow, but no phenological stage is ever scheduled: it does not
2191 enter dormancy, and flower and fruit stages are skipped. This is the explicit form of the
2192 state a plant is already in when :meth:`setPlantPhenologicalThresholds` has never been
2193 called on it, so it is mainly useful for turning phenology back off on a plant that had
2194 thresholds set earlier.
2195
2196 Args:
2197 plant_id: Identifier of the plant whose phenology is to be disabled
2198
2199 Warning:
2200 helios-core's ``disablePlantPhenology()`` sets ``dd_to_fruit_maturity`` to ``-1``,
2201 whereas the "no phenology scheduled" default for that field is ``1e6``. The field is
2202 used as a divisor in the fruit-growth branch of ``advanceTime()``, which is gated only
2203 on a bud being in the ``BUD_FRUITING`` state, and ``appendPhytomerToShoot()`` can set
2204 that state from shoot structure alone. On a plant that already has a fruiting bud, a
2205 subsequent ``advanceTime()`` can therefore compute a negative fruit scale factor. Avoid
2206 calling this on a plant with fruiting buds until it is fixed upstream; a plant that
2207 never had thresholds set is already in the no-phenology state and does not need it.
2208
2209 Raises:
2210 ValueError: If plant_id is negative
2211 PlantArchitectureError: If disabling phenology fails
2212
2213 Example:
2214 >>> plantarch.setPlantPhenologicalThresholds(plant_id, 60, 90, 105, 120, 200, 280)
2215 >>> plantarch.disablePlantPhenology(plant_id) # growth only, no dormancy or fruiting
2216 """
2217 if plant_id < 0:
2218 raise ValueError("Plant ID must be non-negative")
2219
2221 try:
2223 plantarch_wrapper.disablePlantPhenology(self._plantarch_ptr, plant_id)
2224 except Exception as e:
2225 raise PlantArchitectureError(f"Failed to disable phenology for plant {plant_id}: {e}")
2226
2227 # Dormancy control methods
2228 def makePlantDormant(self, plant_id: int) -> None:
2229 """
2230 Force a plant into a dormant state immediately.
2231
2232 This is the direct equivalent of ``makePlantDormant()`` in helios-core, as called by the
2233 library builders such as ``buildAppleTree()``. It is the counterpart to scheduling dormancy
2234 through :meth:`setPlantPhenologicalThresholds`: this forces the state now, rather than
2235 waiting for a degree-day threshold to be crossed.
2236
2237 Dormancy strips the plant's leaves and marks its non-dormant buds dormant, so a
2238 custom-built plant can be put into the same over-winter state that a library-built
2239 perennial reaches through phenology.
2240
2241 Args:
2242 plant_id: Identifier of the plant to make dormant
2243
2244 Raises:
2245 ValueError: If plant_id is negative
2246 PlantArchitectureError: If the plant does not exist or the call fails
2247
2248 Example:
2249 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
2250 >>> plantarch.addBaseStemShoot(plant_id, 3, AxisRotation(0, 0, 0),
2251 ... 0.01, 0.1, 1.0, 1.0, 0.9, "trifoliate")
2252 >>> plantarch.makePlantDormant(plant_id)
2253 """
2254 if plant_id < 0:
2255 raise ValueError("Plant ID must be non-negative")
2256
2258 try:
2260 plantarch_wrapper.makePlantDormant(self._plantarch_ptr, plant_id)
2261 except Exception as e:
2262 raise PlantArchitectureError(f"Failed to make plant {plant_id} dormant: {e}")
2263
2264 def breakPlantDormancy(self, plant_id: int) -> None:
2265 """
2266 Break dormancy for all shoots on a plant, returning it to an active state.
2267
2268 This is the counterpart to :meth:`makePlantDormant`. Note that it only revives buds that
2269 are not dead, so a plant that was repeatedly made dormant may not recover every bud.
2271 Args:
2272 plant_id: Identifier of the plant whose dormancy should be broken
2273
2274 Raises:
2275 ValueError: If plant_id is negative
2276 PlantArchitectureError: If the plant does not exist or the call fails
2277
2278 Example:
2279 >>> plantarch.makePlantDormant(plant_id)
2280 >>> plantarch.breakPlantDormancy(plant_id) # resume growth in spring
2281 """
2282 if plant_id < 0:
2283 raise ValueError("Plant ID must be non-negative")
2284
2286 try:
2288 plantarch_wrapper.breakPlantDormancy(self._plantarch_ptr, plant_id)
2289 except Exception as e:
2290 raise PlantArchitectureError(f"Failed to break dormancy for plant {plant_id}: {e}")
2291
2292 def isPlantDormant(self, plant_id: int) -> bool:
2293 """
2294 Check whether a plant is dormant.
2295
2296 Args:
2297 plant_id: Identifier of the plant to check
2299 Returns:
2300 True if all shoots on the plant are dormant, False otherwise
2301
2302 Raises:
2303 ValueError: If plant_id is negative
2304 PlantArchitectureError: If the plant does not exist or the query fails
2305
2306 Example:
2307 >>> plantarch.makePlantDormant(plant_id)
2308 >>> plantarch.isPlantDormant(plant_id)
2309 True
2310 """
2311 if plant_id < 0:
2312 raise ValueError("Plant ID must be non-negative")
2313
2315 try:
2317 return plantarch_wrapper.isPlantDormant(self._plantarch_ptr, plant_id)
2318 except Exception as e:
2319 raise PlantArchitectureError(f"Failed to query dormancy state for plant {plant_id}: {e}")
2320
2321 # Pruning and organ removal methods
2322 def pruneBranch(self, plant_id: int, shoot_id: int, node_index: int) -> None:
2323 """
2324 Prune a shoot at a node, removing that node and everything distal to it.
2325
2326 The phytomer at ``node_index`` is deleted along with every phytomer above it
2327 on the same shoot, and the cut recurses into every child shoot attached at or
2328 above that node. The shoot's woody internode tube is trimmed back to the cut
2329 and its apical bud is terminated, so the pruned axis will not resume growing.
2330 Pruning at ``node_index=0`` therefore removes the entire shoot and its whole
2331 branch system.
2332
2333 Args:
2334 plant_id: ID of the plant instance
2335 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2336 node_index: Node on the shoot to cut at, in ``[0, node_count)``
2337
2338 Raises:
2339 ValueError: If any identifier is negative
2340 PlantArchitectureError: If the plant or shoot does not exist, if
2341 ``node_index`` is beyond the shoot's current node count, or if the
2342 native call fails
2343
2344 Note:
2345 A pruned shoot currently keeps its ID in :meth:`getAllShootIDs` with a
2346 ``node_count`` of 0 rather than disappearing. Do not rely on either
2347 behavior; traverse with :meth:`getShoot` and treat ``node_count == 0``
2348 as "nothing left here".
2349
2350 Example:
2351 >>> # Remove a whole branch and everything growing off it
2352 >>> plantarch.pruneBranch(plant_id, shoot_id=3, node_index=0)
2353 >>> # Head back a leader, keeping its lowest 5 nodes
2354 >>> plantarch.pruneBranch(plant_id, shoot_id=0, node_index=5)
2355 """
2356 if plant_id < 0 or shoot_id < 0 or node_index < 0:
2357 raise ValueError("Plant ID, shoot ID and node index must be non-negative")
2358
2360 try:
2361 plantarch_wrapper.pruneBranch(self._plantarch_ptr, plant_id, shoot_id, node_index)
2362 except Exception as e:
2364 f"Failed to prune shoot {shoot_id} of plant {plant_id} at node {node_index}: {e}")
2365
2366 def harvestPlant(self, plant_id: int) -> None:
2367 """
2368 Harvest a plant by removing its flowers and fruit.
2369
2370 Every non-dormant floral bud on the plant is killed, which deletes the
2371 associated flower, fruit and peduncle geometry from the Context. Vegetative
2372 structure is untouched and the plant continues to grow afterwards.
2373
2374 Args:
2375 plant_id: ID of the plant instance to harvest
2376
2377 Raises:
2378 ValueError: If plant_id is negative
2379 PlantArchitectureError: If the plant does not exist or the call fails
2380
2381 Note:
2382 Leaves are **not** removed, despite what the upstream Helios
2383 documentation for ``harvestPlant`` states. Use :meth:`removePlantLeaves`
2384 to defoliate.
2385
2386 Example:
2387 >>> before = len(plantarch.getPlantFruitObjectIDs(plant_id))
2388 >>> plantarch.harvestPlant(plant_id)
2389 >>> len(plantarch.getPlantFruitObjectIDs(plant_id)) < before
2390 True
2391 """
2392 if plant_id < 0:
2393 raise ValueError("Plant ID must be non-negative")
2394
2396 try:
2397 plantarch_wrapper.harvestPlant(self._plantarch_ptr, plant_id)
2398 except Exception as e:
2399 raise PlantArchitectureError(f"Failed to harvest plant {plant_id}: {e}")
2400
2401 def removePlantLeaves(self, plant_id: int) -> None:
2402 """
2403 Remove all leaves from every shoot on a plant.
2404
2405 Leaf and petiole geometry is deleted from the Context. Buds are left alive,
2406 so the plant can produce new leaves as it continues to grow.
2407
2408 Args:
2409 plant_id: ID of the plant instance to defoliate
2410
2411 Raises:
2412 ValueError: If plant_id is negative
2413 PlantArchitectureError: If the plant does not exist or the call fails
2414
2415 Example:
2416 >>> plantarch.removePlantLeaves(plant_id)
2417 >>> plantarch.getPlantLeafObjectIDs(plant_id)
2418 []
2419 """
2420 if plant_id < 0:
2421 raise ValueError("Plant ID must be non-negative")
2422
2424 try:
2425 plantarch_wrapper.removePlantLeaves(self._plantarch_ptr, plant_id)
2426 except Exception as e:
2427 raise PlantArchitectureError(f"Failed to remove leaves from plant {plant_id}: {e}")
2428
2429 def removeShootLeaves(self, plant_id: int, shoot_id: int) -> None:
2430 """
2431 Remove all leaves from a single shoot.
2432
2433 Args:
2434 plant_id: ID of the plant instance
2435 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2437 Raises:
2438 ValueError: If either identifier is negative
2439 PlantArchitectureError: If the plant or shoot does not exist
2440
2441 Example:
2442 >>> # Strip the leaves off a grapevine trunk, as in a trained architecture
2443 >>> plantarch.removeShootLeaves(plant_id, shoot_id=0)
2444 """
2445 self._removeShootOrgans("removeShootLeaves", plant_id, shoot_id, "leaves")
2446
2447 def removeShootVegetativeBuds(self, plant_id: int, shoot_id: int) -> None:
2448 """
2449 Mark every vegetative bud on a single shoot as dead.
2450
2451 Despite the name, nothing is removed: each axillary vegetative bud on the shoot
2452 is set to ``BudState.DEAD`` and the bud entries themselves stay in place, so
2453 :meth:`getShootVegetativeBudCount` still sees them and the unfiltered count is
2454 unchanged. Dead buds are skipped when dormancy breaks, so the shoot keeps its
2455 existing structure but produces no new lateral shoots -- the standard way to stop
2456 a trained axis from throwing new canes, and to stop the old wood of a
2457 reconstructed tree re-growing.
2458
2459 This is exactly equivalent to setting every bud on the shoot to
2460 ``BudState.DEAD``; the shoot's own apex is unaffected, so pair it with
2461 :meth:`terminateApicalBud` to stop the shoot extending as well.
2462
2463 Args:
2464 plant_id: ID of the plant instance
2465 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2466
2467 Raises:
2468 ValueError: If either identifier is negative
2469 PlantArchitectureError: If the plant or shoot does not exist
2470
2471 See Also:
2472 :meth:`getShootVegetativeBudCount`, to confirm the buds are dead rather than
2473 gone, and :meth:`terminateApicalBud`, for the shoot's apex.
2474
2475 Example:
2476 >>> plantarch.removeShootVegetativeBuds(plant_id, shoot_id=1)
2477 """
2478 self._removeShootOrgans("removeShootVegetativeBuds", plant_id, shoot_id,
2479 "vegetative buds")
2480
2481 def removeShootFloralBuds(self, plant_id: int, shoot_id: int) -> None:
2482 """
2483 Kill all floral buds on a single shoot.
2484
2485 Existing flower, fruit and peduncle geometry on the shoot is deleted and no
2486 new flowers will form there.
2487
2488 Args:
2489 plant_id: ID of the plant instance
2490 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
2491
2492 Raises:
2493 ValueError: If either identifier is negative
2494 PlantArchitectureError: If the plant or shoot does not exist
2495
2496 Example:
2497 >>> plantarch.removeShootFloralBuds(plant_id, shoot_id=1)
2498 """
2499 self._removeShootOrgans("removeShootFloralBuds", plant_id, shoot_id, "floral buds")
2500
2501 def _removeShootOrgans(self, wrapper_fn_name: str, plant_id: int, shoot_id: int,
2502 organ_description: str) -> None:
2503 """Shared body for the three shoot-level organ removal methods."""
2504 if plant_id < 0 or shoot_id < 0:
2505 raise ValueError("Plant ID and shoot ID must be non-negative")
2506
2508 try:
2509 getattr(plantarch_wrapper, wrapper_fn_name)(self._plantarch_ptr, plant_id, shoot_id)
2510 except Exception as e:
2512 f"Failed to remove {organ_description} from shoot {shoot_id} "
2513 f"of plant {plant_id}: {e}")
2514
2515 # Shoot hierarchy traversal
2516 def getShootIDsByRank(self, plant_id: int) -> Dict[int, List[int]]:
2517 """
2518 Group a plant's shoot IDs by branching rank.
2519
2520 Rank 0 is the base stem, rank 1 its direct branches, and so on. Shoots that have
2521 been pruned away are not included.
2522
2523 Args:
2524 plant_id: ID of the plant instance
2525
2526 Returns:
2527 Dict mapping rank to the list of shoot IDs at that rank. Ranks with no live
2528 shoots are omitted from the dict.
2529
2530 Raises:
2531 ValueError: If plant_id is negative
2532 PlantArchitectureError: If the plant does not exist
2533
2534 Example:
2535 >>> by_rank = plantarch.getShootIDsByRank(plant_id)
2536 >>> print(f"{len(by_rank.get(1, []))} primary branches")
2537 """
2538 if plant_id < 0:
2539 raise ValueError("Plant ID must be non-negative")
2541 try:
2542 groups = plantarch_wrapper.getShootIDsByRank(self._plantarch_ptr, plant_id)
2543 except Exception as e:
2545 f"Failed to get shoot IDs by rank for plant {plant_id}: {e}")
2546
2547 # Native returns one group per rank, indexed by rank, with empty groups for ranks
2548 # that have no live shoots. The dict form drops those empties.
2549 return {rank: shoot_ids for rank, shoot_ids in enumerate(groups) if shoot_ids}
2550
2551 def getTerminalShootIDs(self, plant_id: int) -> List[int]:
2552 """
2553 Get the plant's terminal shoots -- those carrying no child shoots.
2554
2555 These are the tips of the shoot tree. Note that this is a topological test rather
2556 than a botanical one: a shoot whose axis is continued by :meth:`appendShoot` has
2557 that continuation as a child and so is not terminal. Pruned shoots are omitted.
2558
2559 Args:
2560 plant_id: ID of the plant instance
2561
2562 Returns:
2563 List of terminal shoot IDs.
2564
2565 Raises:
2566 ValueError: If plant_id is negative
2567 PlantArchitectureError: If the plant does not exist
2568
2569 Example:
2570 >>> tips = plantarch.getTerminalShootIDs(plant_id)
2571 """
2572 if plant_id < 0:
2573 raise ValueError("Plant ID must be non-negative")
2575 try:
2576 return plantarch_wrapper.getTerminalShootIDs(self._plantarch_ptr, plant_id)
2577 except Exception as e:
2579 f"Failed to get terminal shoots for plant {plant_id}: {e}")
2580
2581 # Bulk pruning built on the traversal helpers
2582 def pruneShootsByRank(self, plant_id: int, min_rank: int) -> List[int]:
2583 """
2584 Prune every shoot at or above a given branching rank.
2585
2586 This is the "remove higher-order branches" thinning operation: passing
2587 ``min_rank=3`` leaves the base stem and its first two orders of branching
2588 intact and cuts everything finer. Because :meth:`pruneBranch` already
2589 recurses into child shoots, only the shallowest shoot on each pruned axis is
2590 cut and the rest follow.
2591
2592 Args:
2593 plant_id: ID of the plant instance
2594 min_rank: Lowest rank to prune. Must be at least 1 -- rank 0 is the base
2595 stem, and pruning it would destroy the plant.
2596
2597 Returns:
2598 Ascending list of the shoot IDs actually cut. Shoots removed as a side
2599 effect of a shallower cut are not listed.
2600
2601 Raises:
2602 ValueError: If plant_id is negative or min_rank is less than 1
2603 PlantArchitectureError: If the plant does not exist
2604
2605 Note:
2606 To remove a whole plant use :meth:`deletePlantInstance`; to cut the base
2607 stem itself call :meth:`pruneBranch` directly.
2608
2609 Example:
2610 >>> pruned = plantarch.pruneShootsByRank(plant_id, min_rank=3)
2611 >>> print(f"Cut {len(pruned)} higher-order branches")
2612 """
2613 if plant_id < 0:
2614 raise ValueError("Plant ID must be non-negative")
2615 if min_rank < 1:
2616 raise ValueError(
2617 f"min_rank must be at least 1, got {min_rank}. Rank 0 is the base stem; "
2618 "use deletePlantInstance() to remove the whole plant, or pruneBranch() "
2619 "to cut the base stem explicitly.")
2620
2621 by_rank = self.getShootIDsByRank(plant_id)
2622 targets = {shoot_id
2623 for rank, shoot_ids in by_rank.items() if rank >= min_rank
2624 for shoot_id in shoot_ids}
2625 return self._pruneShallowest(plant_id, targets)
2626
2627 def pruneShootSubtree(self, plant_id: int, shoot_id: int,
2628 include_self: bool = True) -> List[int]:
2629 """
2630 Prune a shoot and everything growing off it.
2632 Args:
2633 plant_id: ID of the plant instance
2634 shoot_id: Root of the branch system to remove
2635 include_self: If True (default) the shoot itself is cut at node 0. If
2636 False the shoot is kept and only its child shoots are cut.
2637
2638 Returns:
2639 Ascending list of the shoot IDs actually cut. Shoots removed as a side
2640 effect of a shallower cut are not listed.
2641
2642 Raises:
2643 ValueError: If either identifier is negative
2644 PlantArchitectureError: If the plant or shoot does not exist
2645
2646 Example:
2647 >>> # Remove a whole branch system
2648 >>> plantarch.pruneShootSubtree(plant_id, shoot_id=2)
2649 >>> # Keep the cane but strip everything growing off it
2650 >>> plantarch.pruneShootSubtree(plant_id, shoot_id=2, include_self=False)
2651 """
2652 if plant_id < 0 or shoot_id < 0:
2653 raise ValueError("Plant ID and shoot ID must be non-negative")
2654
2655 if include_self:
2656 return self._pruneShallowest(plant_id, {shoot_id})
2657 return self._pruneShallowest(plant_id, set(self._liveChildShootIDs(plant_id, shoot_id)))
2658
2659 def pruneTerminalShoots(self, plant_id: int, stride: int = 2) -> List[int]:
2660 """
2661 Thin a plant by pruning every *stride*-th terminal shoot.
2662
2663 Terminal shoots are taken in ascending ID order and every ``stride``-th one
2664 starting from the first is cut, so ``stride=2`` removes about half the tips
2665 and ``stride=3`` about a third. The base stem is never cut.
2666
2667 Args:
2668 plant_id: ID of the plant instance
2669 stride: Spacing between pruned tips. Must be at least 1; ``stride=1``
2670 prunes every terminal shoot.
2671
2672 Returns:
2673 Ascending list of the shoot IDs actually cut.
2674
2675 Raises:
2676 ValueError: If plant_id is negative or stride is less than 1
2677 PlantArchitectureError: If the plant does not exist
2678
2679 Example:
2680 >>> pruned = plantarch.pruneTerminalShoots(plant_id, stride=2)
2681 >>> print(f"Thinned {len(pruned)} tips")
2682 """
2683 if plant_id < 0:
2684 raise ValueError("Plant ID must be non-negative")
2685 if stride < 1:
2686 raise ValueError(f"stride must be at least 1, got {stride}")
2687
2688 targets = set()
2689 for index, shoot_id in enumerate(self.getTerminalShootIDs(plant_id)):
2690 if index % stride != 0:
2691 continue
2692 if self.getShootRank(plant_id, shoot_id) == 0:
2693 continue # never cut the base stem
2694 targets.add(shoot_id)
2695 return self._pruneShallowest(plant_id, targets)
2696
2697 def _childShootIDsOrEmpty(self, plant_id: int, shoot_id: int) -> List[int]:
2698 """Return a shoot's child IDs, or an empty list if it no longer resolves."""
2699 try:
2700 return self.getShootChildIDs(plant_id, shoot_id)
2701 except PlantArchitectureError:
2702 return []
2703
2704 def _liveChildShootIDs(self, plant_id: int, shoot_id: int) -> List[int]:
2705 """Child shoot IDs that have not been pruned away, ascending."""
2706 # getChildShootIDs already excludes pruned shoots, so no second filter is needed.
2707 return sorted(set(self._childShootIDsOrEmpty(plant_id, shoot_id)))
2708
2709 def _pruneShallowest(self, plant_id: int, target_shoot_ids) -> List[int]:
2710 """Prune every target that something shallower has not already removed.
2711
2712 pruneBranch() recurses into child shoots, so cutting a shoot also empties
2713 every shoot descended from it. Targets are therefore visited in ascending
2714 shoot ID order -- a child shoot is always created after its parent and so
2715 always has the higher ID -- which puts each shoot after its ancestors. By
2716 the time a descendant of an already-cut shoot comes up it has nothing left
2717 on it and is skipped, so no shoot is cut twice and the returned list holds
2718 only the cuts that actually did something.
2719 """
2720 pruned = []
2721 for shoot_id in sorted(set(target_shoot_ids)):
2722 if self._isPrunedOrGone(plant_id, shoot_id):
2723 continue # gone already, either pruned above or pruned earlier
2724 self.pruneBranch(plant_id, shoot_id, 0)
2725 pruned.append(shoot_id)
2726 return pruned
2727
2728 def _isPrunedOrGone(self, plant_id: int, shoot_id: int) -> bool:
2729 """Whether a shoot has been pruned away or no longer resolves at all."""
2730 try:
2731 return self.isShootPruned(plant_id, shoot_id)
2732 except PlantArchitectureError:
2733 return True
2734
2735 # Collision detection methods
2737 target_object_UUIDs: Optional[List[int]] = None,
2738 target_object_IDs: Optional[List[int]] = None,
2739 enable_petiole_collision: bool = False,
2740 enable_fruit_collision: bool = False) -> None:
2741 """
2742 Enable soft collision avoidance for procedural plant growth.
2743
2744 This method enables the collision detection system that guides plant growth away from
2745 obstacles and other plants. The system uses cone-based gap detection to find optimal
2746 growth directions that minimize collisions while maintaining natural plant architecture.
2747
2748 Args:
2749 target_object_UUIDs: List of primitive UUIDs to avoid collisions with. If empty,
2750 avoids all geometry in the context.
2751 target_object_IDs: List of compound object IDs to avoid collisions with.
2752 enable_petiole_collision: Enable collision detection for leaf petioles
2753 enable_fruit_collision: Enable collision detection for fruit organs
2754
2755 Raises:
2756 PlantArchitectureError: If collision detection activation fails
2757
2758 Note:
2759 Collision detection adds computational overhead. Use setStaticObstacles() to mark
2760 static geometry for BVH optimization and improved performance.
2761
2762 Example:
2763 >>> # Avoid all geometry
2764 >>> plantarch.enableSoftCollisionAvoidance()
2765 >>>
2766 >>> # Avoid specific obstacles
2767 >>> obstacle_uuids = context.getAllUUIDs()
2768 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
2769 >>>
2770 >>> # Enable collision detection for petioles and fruit
2771 >>> plantarch.enableSoftCollisionAvoidance(
2772 ... enable_petiole_collision=True,
2773 ... enable_fruit_collision=True
2774 ... )
2775 """
2777 try:
2779 plantarch_wrapper.enableSoftCollisionAvoidance(
2780 self._plantarch_ptr,
2781 target_UUIDs=target_object_UUIDs,
2782 target_IDs=target_object_IDs,
2783 enable_petiole=enable_petiole_collision,
2784 enable_fruit=enable_fruit_collision
2785 )
2786 except Exception as e:
2787 raise PlantArchitectureError(f"Failed to enable soft collision avoidance: {e}")
2788
2789 def enableGroundClipping(self, ground_height: float = 0.0) -> None:
2790 """
2791 Enable automatic removal of plant organs that fall below the ground plane.
2792
2793 Organ vertices below `ground_height` are clipped as plant geometry is
2794 built, which prevents drooping leaves and low branches from poking
2795 through a ground tile.
2796
2797 Args:
2798 ground_height: Height of the ground plane (default 0.0)
2799
2800 Raises:
2801 ValueError: If ground_height is not a number
2802 PlantArchitectureError: If the call fails
2803
2804 Example:
2805 >>> plantarch.enableGroundClipping(0.0)
2806 >>> plantarch.advanceTime(30.0)
2807 """
2809
2810 if isinstance(ground_height, bool) or not isinstance(ground_height, (int, float)):
2811 raise ValueError(f"Ground height must be a number, got {type(ground_height).__name__}")
2812
2813 try:
2814 plantarch_wrapper.enableGroundClipping(self._plantarch_ptr, float(ground_height))
2815 except Exception as e:
2816 raise PlantArchitectureError(f"Failed to enable ground clipping: {e}")
2817
2818 def disableMessages(self) -> None:
2819 """
2820 Suppress standard output from the plantarchitecture plugin.
2821
2822 This silences progress bars and informational messages the C++ plugin
2823 writes to stdout, including the "BVH not cached" warning emitted during
2824 the first growth steps of a collision-enabled canopy (before any plant
2825 geometry exists for the BVH to contain).
2826
2827 Raises:
2828 PlantArchitectureError: If the call fails
2829
2830 Example:
2831 >>> plantarch.disableMessages()
2832 >>> plantarch.advanceTime(30.0) # runs quietly
2833 """
2835 try:
2836 plantarch_wrapper.disableMessages(self._plantarch_ptr)
2837 except Exception as e:
2838 raise PlantArchitectureError(f"Failed to disable messages: {e}")
2839
2840 def enableMessages(self) -> None:
2841 """
2842 Re-enable standard output from the plantarchitecture plugin.
2843
2844 Raises:
2845 PlantArchitectureError: If the call fails
2846
2847 Example:
2848 >>> plantarch.enableMessages()
2849 """
2851 try:
2852 plantarch_wrapper.enableMessages(self._plantarch_ptr)
2853 except Exception as e:
2854 raise PlantArchitectureError(f"Failed to enable messages: {e}")
2855
2856 def disableCollisionDetection(self) -> None:
2857 """
2858 Disable collision detection for plant growth.
2859
2860 This method turns off the collision detection system, allowing plants to grow
2861 without checking for obstacles. This improves performance but plants may grow
2862 through obstacles and other geometry.
2863
2864 Raises:
2865 PlantArchitectureError: If disabling fails
2866
2867 Example:
2868 >>> plantarch.disableCollisionDetection()
2869 """
2871 try:
2872 plantarch_wrapper.disableCollisionDetection(self._plantarch_ptr)
2873 except Exception as e:
2874 raise PlantArchitectureError(f"Failed to disable collision detection: {e}")
2875
2877 view_half_angle_deg: float = 80.0,
2878 look_ahead_distance: float = 0.1,
2879 sample_count: int = 256,
2880 inertia_weight: float = 0.4) -> None:
2881 """
2882 Configure parameters for soft collision avoidance algorithm.
2883
2884 These parameters control the cone-based gap detection algorithm that guides
2885 plant growth away from obstacles. Adjusting these values allows fine-tuning
2886 the balance between collision avoidance and natural growth patterns.
2887
2888 Args:
2889 view_half_angle_deg: Half-angle of detection cone in degrees (0-180).
2890 Default 80° provides wide field of view.
2891 look_ahead_distance: Distance to look ahead for collisions in meters.
2892 Larger values detect distant obstacles. Default 0.1m.
2893 sample_count: Number of ray samples within cone. More samples improve
2894 accuracy but reduce performance. Default 256.
2895 inertia_weight: Weight for previous growth direction (0-1). Higher values
2896 make growth smoother but less responsive. Default 0.4.
2897
2898 Raises:
2899 ValueError: If parameters are outside valid ranges
2900 PlantArchitectureError: If parameter setting fails
2901
2902 Example:
2903 >>> # Use default parameters (recommended)
2904 >>> plantarch.setSoftCollisionAvoidanceParameters()
2905 >>>
2906 >>> # Tune for dense canopy with close obstacles
2907 >>> plantarch.setSoftCollisionAvoidanceParameters(
2908 ... view_half_angle_deg=60.0, # Narrower detection cone
2909 ... look_ahead_distance=0.05, # Shorter look-ahead
2910 ... sample_count=512, # More accurate detection
2911 ... inertia_weight=0.3 # More responsive to obstacles
2912 ... )
2913 """
2914 # Validate parameters
2915 if not (0 <= view_half_angle_deg <= 180):
2916 raise ValueError(f"view_half_angle_deg must be between 0 and 180, got {view_half_angle_deg}")
2917 if look_ahead_distance <= 0:
2918 raise ValueError(f"look_ahead_distance must be positive, got {look_ahead_distance}")
2919 if sample_count <= 0:
2920 raise ValueError(f"sample_count must be positive, got {sample_count}")
2921 if not (0 <= inertia_weight <= 1):
2922 raise ValueError(f"inertia_weight must be between 0 and 1, got {inertia_weight}")
2923
2925 try:
2926 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
2927 self._plantarch_ptr,
2928 view_half_angle_deg,
2929 look_ahead_distance,
2930 sample_count,
2931 inertia_weight
2932 )
2933 except Exception as e:
2934 raise PlantArchitectureError(f"Failed to set collision avoidance parameters: {e}")
2935
2937 include_internodes: bool = False,
2938 include_leaves: bool = True,
2939 include_petioles: bool = False,
2940 include_flowers: bool = False,
2941 include_fruit: bool = False) -> None:
2942 """
2943 Specify which plant organs participate in collision detection.
2944
2945 This method allows filtering which organs are considered during collision detection,
2946 enabling optimization by excluding organs unlikely to cause problematic collisions.
2947
2948 Args:
2949 include_internodes: Include stem internodes in collision detection
2950 include_leaves: Include leaf blades in collision detection
2951 include_petioles: Include leaf petioles in collision detection
2952 include_flowers: Include flowers in collision detection
2953 include_fruit: Include fruit in collision detection
2954
2955 Raises:
2956 PlantArchitectureError: If organ filtering fails
2957
2958 Example:
2959 >>> # Only detect collisions for stems and leaves (default behavior)
2960 >>> plantarch.setCollisionRelevantOrgans(
2961 ... include_internodes=True,
2962 ... include_leaves=True
2963 ... )
2964 >>>
2965 >>> # Include all organs
2966 >>> plantarch.setCollisionRelevantOrgans(
2967 ... include_internodes=True,
2968 ... include_leaves=True,
2969 ... include_petioles=True,
2970 ... include_flowers=True,
2971 ... include_fruit=True
2972 ... )
2973 """
2975 try:
2976 plantarch_wrapper.setCollisionRelevantOrgans(
2977 self._plantarch_ptr,
2978 include_internodes,
2979 include_leaves,
2980 include_petioles,
2981 include_flowers,
2982 include_fruit
2983 )
2984 except Exception as e:
2985 raise PlantArchitectureError(f"Failed to set collision-relevant organs: {e}")
2986
2988 obstacle_UUIDs: List[int],
2989 avoidance_distance: float = 0.5,
2990 enable_fruit_adjustment: bool = False,
2991 enable_obstacle_pruning: bool = False) -> None:
2992 """
2993 Enable hard obstacle avoidance for specified geometry.
2994
2995 This method configures solid obstacles that plants cannot grow through. Unlike soft
2996 collision avoidance (which guides growth), solid obstacles cause complete growth
2997 termination when encountered within the avoidance distance.
2998
2999 Args:
3000 obstacle_UUIDs: List of primitive UUIDs representing solid obstacles
3001 avoidance_distance: Minimum distance to maintain from obstacles (meters).
3002 Growth stops if obstacles are closer. Default 0.5m.
3003 enable_fruit_adjustment: Adjust fruit positions away from obstacles
3004 enable_obstacle_pruning: Remove plant organs that penetrate obstacles
3005
3006 Raises:
3007 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
3008 PlantArchitectureError: If solid obstacle configuration fails
3009
3010 Example:
3011 >>> # Simple solid obstacle avoidance
3012 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
3013 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
3014 >>>
3015 >>> # Close avoidance with fruit adjustment
3016 >>> plantarch.enableSolidObstacleAvoidance(
3017 ... obstacle_UUIDs=wall_uuids,
3018 ... avoidance_distance=0.1,
3019 ... enable_fruit_adjustment=True
3020 ... )
3021 """
3022 if not obstacle_UUIDs:
3023 raise ValueError("Obstacle UUIDs list cannot be empty")
3024 if avoidance_distance <= 0:
3025 raise ValueError(f"avoidance_distance must be positive, got {avoidance_distance}")
3026
3028 try:
3030 plantarch_wrapper.enableSolidObstacleAvoidance(
3031 self._plantarch_ptr,
3032 obstacle_UUIDs,
3033 avoidance_distance,
3034 enable_fruit_adjustment,
3035 enable_obstacle_pruning
3036 )
3037 except Exception as e:
3038 raise PlantArchitectureError(f"Failed to enable solid obstacle avoidance: {e}")
3039
3040 def setStaticObstacles(self, target_UUIDs: List[int]) -> None:
3041 """
3042 Mark geometry as static obstacles for collision detection optimization.
3043
3044 This method tells the collision detection system that certain geometry will not
3045 move during the simulation. The system can then build an optimized Bounding Volume
3046 Hierarchy (BVH) for these obstacles, significantly improving collision detection
3047 performance in scenes with many static obstacles.
3048
3049 Args:
3050 target_UUIDs: List of primitive UUIDs representing static obstacles
3051
3052 Raises:
3053 ValueError: If target_UUIDs is empty
3054 PlantArchitectureError: If static obstacle configuration fails
3055
3056 Note:
3057 Collision avoidance must be enabled BEFORE calling this method -- the
3058 native call raises "Collision detection must be enabled before setting
3059 static obstacles" otherwise.
3060 Static obstacles cannot be modified or moved after being marked static.
3061
3062 Example:
3063 >>> # Enable collision avoidance first
3064 >>> plantarch.enableSoftCollisionAvoidance()
3065 >>> # Then mark ground and building geometry as static
3066 >>> static_uuids = ground_uuids + building_uuids
3067 >>> plantarch.setStaticObstacles(static_uuids)
3068 """
3069 if not target_UUIDs:
3070 raise ValueError("target_UUIDs list cannot be empty")
3071
3073 try:
3075 plantarch_wrapper.setStaticObstacles(self._plantarch_ptr, target_UUIDs)
3076 except Exception as e:
3077 raise PlantArchitectureError(f"Failed to set static obstacles: {e}")
3078
3079 def getPlantCollisionRelevantObjectIDs(self, plant_id: int) -> List[int]:
3080 """
3081 Get object IDs of collision-relevant geometry for a specific plant.
3082
3083 This method returns the subset of plant geometry that participates in collision
3084 detection, as filtered by setCollisionRelevantOrgans(). Useful for visualization
3085 and debugging collision detection behavior.
3086
3087 Args:
3088 plant_id: ID of the plant instance
3089
3090 Returns:
3091 List of object IDs for collision-relevant plant geometry
3092
3093 Raises:
3094 ValueError: If plant_id is negative
3095 PlantArchitectureError: If retrieval fails
3096
3097 Example:
3098 >>> # Get collision-relevant geometry
3099 >>> collision_obj_ids = plantarch.getPlantCollisionRelevantObjectIDs(plant_id)
3100 >>> print(f"Plant has {len(collision_obj_ids)} collision-relevant objects")
3101 >>>
3102 >>> # Highlight collision geometry in visualization
3103 >>> for obj_id in collision_obj_ids:
3104 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
3105 """
3106 if plant_id < 0:
3107 raise ValueError("Plant ID must be non-negative")
3108
3110 try:
3111 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self._plantarch_ptr, plant_id)
3112 except Exception as e:
3113 raise PlantArchitectureError(f"Failed to get collision-relevant object IDs for plant {plant_id}: {e}")
3114
3115 # File I/O methods
3116 def writePlantMeshVertices(self, plant_id: int, filename: Union[str, Path]) -> None:
3117 """
3118 Write all plant mesh vertices to file for external processing.
3119
3120 This method exports all vertex coordinates (x,y,z) for every primitive in the plant,
3121 writing one vertex per line. Useful for external processing such as computing bounding
3122 volumes, convex hulls, or performing custom geometric analysis.
3123
3124 Args:
3125 plant_id: ID of the plant instance to export
3126 filename: Path to output file (absolute or relative to current working directory)
3127
3128 Raises:
3129 ValueError: If plant_id is negative or filename is empty
3130 PlantArchitectureError: If plant doesn't exist or file cannot be written
3131
3132 Example:
3133 >>> # Export vertices for convex hull analysis
3134 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
3135 >>>
3136 >>> # Use with Path object
3137 >>> from pathlib import Path
3138 >>> output_dir = Path("output")
3139 >>> output_dir.mkdir(exist_ok=True)
3140 >>> plantarch.writePlantMeshVertices(plant_id, output_dir / "vertices.txt")
3141 """
3142 if plant_id < 0:
3143 raise ValueError("Plant ID must be non-negative")
3144 if not filename:
3145 raise ValueError("Filename cannot be empty")
3146
3147 # Resolve path before changing directory
3148 absolute_path = _resolve_user_path(filename)
3149
3151 try:
3153 plantarch_wrapper.writePlantMeshVertices(
3154 self._plantarch_ptr, plant_id, absolute_path
3155 )
3156 except Exception as e:
3157 raise PlantArchitectureError(f"Failed to write plant mesh vertices to {filename}: {e}")
3158
3159 def writePlantStructureXML(self, plant_id: int, filename: Union[str, Path]) -> None:
3160 """
3161 Save plant structure to XML file for later loading.
3162
3163 This method exports the complete plant architecture to an XML file, including
3164 all shoots, phytomers, organs, and their properties. The saved plant can be
3165 reloaded later using readPlantStructureXML().
3166
3167 Args:
3168 plant_id: ID of the plant instance to save
3169 filename: Path to output XML file (absolute or relative to current working directory)
3170
3171 Raises:
3172 ValueError: If plant_id is negative or filename is empty
3173 PlantArchitectureError: If plant doesn't exist or file cannot be written
3174
3175 Note:
3176 The XML format preserves the complete plant state including:
3177 - Shoot structure and hierarchy
3178 - Phytomer properties and development stage
3179 - Organ geometry and attributes
3180 - Growth parameters and phenological state
3181
3182 Example:
3183 >>> # Save plant at current growth stage
3184 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
3185 >>>
3186 >>> # Later, reload the saved plant
3187 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
3188 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
3189 """
3190 if plant_id < 0:
3191 raise ValueError("Plant ID must be non-negative")
3192 if not filename:
3193 raise ValueError("Filename cannot be empty")
3194
3195 # Resolve path before changing directory
3196 absolute_path = _resolve_user_path(filename)
3197
3199 try:
3201 plantarch_wrapper.writePlantStructureXML(
3202 self._plantarch_ptr, plant_id, absolute_path
3203 )
3204 except Exception as e:
3205 raise PlantArchitectureError(f"Failed to write plant structure XML to {filename}: {e}")
3206
3207 def writeQSMCylinderFile(self, plant_id: int, filename: Union[str, Path]) -> None:
3208 """
3209 Export plant structure in TreeQSM cylinder format.
3210
3211 This method writes the plant structure as a series of cylinders following the
3212 TreeQSM format (Raumonen et al., 2013). Each row represents one cylinder with
3213 columns for radius, length, start position, axis direction, branch topology,
3214 and other structural properties. Useful for biomechanical analysis and
3215 quantitative structure modeling.
3217 Args:
3218 plant_id: ID of the plant instance to export
3219 filename: Path to output file (absolute or relative, typically .txt extension)
3220
3221 Raises:
3222 ValueError: If plant_id is negative or filename is empty
3223 PlantArchitectureError: If plant doesn't exist or file cannot be written
3224
3225 Note:
3226 The TreeQSM format includes columns for:
3227 - Cylinder dimensions (radius, length)
3228 - Spatial position and orientation
3229 - Branch topology (parent ID, extension ID, branch ID)
3230 - Branch hierarchy (branch order, position in branch)
3231 - Quality metrics (mean absolute distance, surface coverage)
3232
3233 Example:
3234 >>> # Export for biomechanical analysis
3235 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
3236 >>>
3237 >>> # Use with external QSM tools
3238 >>> import pandas as pd
3239 >>> qsm_data = pd.read_csv("tree_structure_qsm.txt", sep="\\t")
3240 >>> print(f"Tree has {len(qsm_data)} cylinders")
3241
3242 References:
3243 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
3244 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
3245 """
3246 if plant_id < 0:
3247 raise ValueError("Plant ID must be non-negative")
3248 if not filename:
3249 raise ValueError("Filename cannot be empty")
3250
3251 # Resolve path before changing directory
3252 absolute_path = _resolve_user_path(filename)
3253
3255 try:
3257 plantarch_wrapper.writeQSMCylinderFile(
3258 self._plantarch_ptr, plant_id, absolute_path
3259 )
3260 except Exception as e:
3261 raise PlantArchitectureError(f"Failed to write QSM cylinder file to {filename}: {e}")
3262
3263 def writePlantStructureUSD(self, plant_id: int, filename: Union[str, Path],
3264 elastic_modulus: float = 5e9,
3265 wood_density: float = 800.0,
3266 damping_ratio: float = 0.1,
3267 static_friction: float = 0.5,
3268 dynamic_friction: float = 0.3,
3269 restitution: float = 0.1,
3270 organ_spring_stiffness: float = 10.0,
3271 organ_spring_damping: float = 1.0,
3272 leaf_mass_per_area: float = 0.05,
3273 fruit_mass: float = 0.01,
3274 flower_mass: float = 0.002,
3275 solver_position_iterations: int = 32,
3276 min_segment_length: float = 0.001) -> None:
3277 """
3278 Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
3279
3280 Each tube segment becomes a capsule-shaped rigid link connected by spherical joints.
3281 Spring/damper drives are derived from beam bending stiffness (E*I/L). Leaves, fruits,
3282 and flowers are represented as mass bodies attached by spring links.
3283
3284 Args:
3285 plant_id: ID of the plant instance to export
3286 filename: Output file path (should have .usda extension)
3287 elastic_modulus: Young's modulus (Pa) for joint stiffness, K = E*I/L
3288 wood_density: Wood density (kg/m^3) used to compute mass from capsule volume
3289 damping_ratio: Joint damping ratio (dimensionless)
3290 static_friction: Static friction coefficient for collision material
3291 dynamic_friction: Dynamic friction coefficient for collision material
3292 restitution: Restitution (bounciness) for collision material
3293 organ_spring_stiffness: Spring stiffness (N*m/rad) for organ attachment joints
3294 organ_spring_damping: Damping (N*m*s/rad) for organ attachment joints
3295 leaf_mass_per_area: Leaf mass per unit area (kg/m^2)
3296 fruit_mass: Mass per fruit (kg)
3297 flower_mass: Mass per flower (kg)
3298 solver_position_iterations: PhysX articulation solver position iteration count
3299 min_segment_length: Minimum segment length (m); shorter segments are skipped
3300
3301 Raises:
3302 ValueError: If plant_id is negative or filename is empty
3303 PlantArchitectureError: If plant doesn't exist or file cannot be written
3304
3305 Example:
3306 >>> plantarch.writePlantStructureUSD(plant_id, "plant.usda")
3307 """
3308 if plant_id < 0:
3309 raise ValueError("Plant ID must be non-negative")
3310 if not filename:
3311 raise ValueError("Filename cannot be empty")
3312
3313 absolute_path = _resolve_user_path(filename)
3314
3316 try:
3318 plantarch_wrapper.writePlantStructureUSD(
3319 self._plantarch_ptr, plant_id, absolute_path,
3320 elastic_modulus, wood_density, damping_ratio,
3321 static_friction, dynamic_friction, restitution,
3322 organ_spring_stiffness, organ_spring_damping,
3323 leaf_mass_per_area, fruit_mass, flower_mass,
3324 solver_position_iterations, min_segment_length
3325 )
3326 except Exception as e:
3327 raise PlantArchitectureError(f"Failed to write plant structure USD to {filename}: {e}")
3328
3329 def registerGrowthFrame(self, plant_id: int, min_segment_length: float = 0.001) -> None:
3330 """
3331 Capture a snapshot of the plant's geometry as a growth animation frame.
3332
3333 Call this after each :meth:`advanceTime` step to record the plant state for later
3334 animation export via :meth:`writePlantGrowthUSD`.
3335
3336 Args:
3337 plant_id: ID of the plant instance to capture
3338 min_segment_length: Minimum segment length (m); shorter segments are skipped
3339
3340 Raises:
3341 ValueError: If plant_id is negative
3342 PlantArchitectureError: If plant doesn't exist
3343 """
3344 if plant_id < 0:
3345 raise ValueError("Plant ID must be non-negative")
3346
3348 try:
3349 plantarch_wrapper.registerGrowthFrame(self._plantarch_ptr, plant_id, min_segment_length)
3350 except Exception as e:
3351 raise PlantArchitectureError(f"Failed to register growth frame for plant {plant_id}: {e}")
3352
3353 def writePlantGrowthUSD(self, plant_id: int, filename: Union[str, Path],
3354 seconds_per_frame: float = 1.0) -> None:
3355 """
3356 Export all registered growth frames as a time-sampled USD animation file.
3357
3358 The resulting file can be imported directly into Blender. This is a visual-only
3359 export — no physics prims, joints, or collision shapes are written.
3360
3361 Args:
3362 plant_id: ID of the plant instance to export
3363 filename: Output file path (should have .usda extension)
3364 seconds_per_frame: Duration in seconds each growth frame occupies (default: 1.0)
3365
3366 Raises:
3367 ValueError: If plant_id is negative or filename is empty
3368 PlantArchitectureError: If plant doesn't exist or file cannot be written
3369 """
3370 if plant_id < 0:
3371 raise ValueError("Plant ID must be non-negative")
3372 if not filename:
3373 raise ValueError("Filename cannot be empty")
3374
3375 absolute_path = _resolve_user_path(filename)
3376
3378 try:
3380 plantarch_wrapper.writePlantGrowthUSD(
3381 self._plantarch_ptr, plant_id, absolute_path, seconds_per_frame
3382 )
3383 except Exception as e:
3384 raise PlantArchitectureError(f"Failed to write plant growth USD to {filename}: {e}")
3385
3386 def clearGrowthFrames(self, plant_id: int) -> None:
3387 """
3388 Clear stored growth animation frames for a plant.
3389
3390 Args:
3391 plant_id: ID of the plant instance whose frames should be cleared
3392
3393 Raises:
3394 ValueError: If plant_id is negative
3395 """
3396 if plant_id < 0:
3397 raise ValueError("Plant ID must be non-negative")
3398
3400 try:
3401 plantarch_wrapper.clearGrowthFrames(self._plantarch_ptr, plant_id)
3402 except Exception as e:
3403 raise PlantArchitectureError(f"Failed to clear growth frames for plant {plant_id}: {e}")
3404
3405 def getGrowthFrameCount(self, plant_id: int) -> int:
3406 """
3407 Get the number of registered growth frames for a plant.
3408
3409 Args:
3410 plant_id: ID of the plant instance to query
3411
3412 Returns:
3413 Number of frames registered via :meth:`registerGrowthFrame`
3414
3415 Raises:
3416 ValueError: If plant_id is negative
3417 """
3418 if plant_id < 0:
3419 raise ValueError("Plant ID must be non-negative")
3420
3422 try:
3423 return plantarch_wrapper.getGrowthFrameCount(self._plantarch_ptr, plant_id)
3424 except Exception as e:
3425 raise PlantArchitectureError(f"Failed to get growth frame count for plant {plant_id}: {e}")
3426
3427 def readPlantStructureXML(self, filename: Union[str, Path], quiet: bool = False) -> List[int]:
3428 """
3429 Load plant structure from XML file.
3430
3431 This method reads plant architecture data from an XML file previously saved with
3432 writePlantStructureXML(). The loaded plants are added to the current context
3433 and can be grown, modified, or analyzed like any other plants.
3434
3435 Args:
3436 filename: Path to XML file to load (absolute or relative to current working directory)
3437 quiet: If True, suppress console output during loading (default: False)
3438
3439 Returns:
3440 List of plant IDs for the loaded plant instances
3441
3442 Raises:
3443 ValueError: If filename is empty
3444 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
3445
3446 Note:
3447 The XML file can contain multiple plant instances. All plants in the file
3448 will be loaded and their IDs returned in a list. Plant models referenced
3449 in the XML must be available in the plant library.
3450
3451 Example:
3452 >>> # Load previously saved plants
3453 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
3454 >>> print(f"Loaded {len(plant_ids)} plants")
3455 >>>
3456 >>> # Continue growing the loaded plants
3457 >>> plantarch.advanceTime(10.0)
3458 >>>
3459 >>> # Load quietly without console messages
3460 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
3461 """
3462 if not filename:
3463 raise ValueError("Filename cannot be empty")
3464
3465 # Resolve path before changing directory
3466 absolute_path = _resolve_user_path(filename)
3467
3469 try:
3471 return plantarch_wrapper.readPlantStructureXML(
3472 self._plantarch_ptr, absolute_path, quiet
3473 )
3474 except Exception as e:
3475 raise PlantArchitectureError(f"Failed to read plant structure XML from {filename}: {e}")
3476
3477 # Custom plant building methods
3478 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
3479 """
3480 Create an empty plant instance for custom plant building.
3481
3482 This method creates a new plant instance at the specified location without any
3483 shoots or organs. Use addBaseStemShoot(), appendShoot(), and addChildShoot() to
3484 manually construct the plant structure. This provides low-level control over
3485 plant architecture, enabling custom morphologies not available in the plant library.
3486
3487 Args:
3488 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
3489 current_age: Current age of the plant in days (must be >= 0)
3490
3491 Returns:
3492 Plant ID for the created plant instance
3493
3494 Raises:
3495 ValueError: If age is negative
3496 PlantArchitectureError: If plant creation fails
3497
3498 Example:
3499 >>> # Create empty plant at origin
3500 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
3501 >>>
3502 >>> # Now add shoots to build custom plant structure
3503 >>> shoot_id = plantarch.addBaseStemShoot(
3504 ... plant_id, 1, AxisRotation(0, 0, 0), 0.01, 0.1, 1.0, 1.0, 0.8, "mainstem"
3505 ... )
3506 """
3507 # Parameter type validation
3508 if not isinstance(base_position, vec3):
3509 raise ValueError(f"base_position must be a vec3, got {type(base_position).__name__}")
3510
3511 # Convert position to list for C++ interface
3512 position_list = [base_position.x, base_position.y, base_position.z]
3513
3514 # Validate age
3515 if current_age < 0:
3516 raise ValueError(f"Age must be non-negative, got {current_age}")
3517
3519 try:
3521 return plantarch_wrapper.addPlantInstance(
3522 self._plantarch_ptr, position_list, current_age
3523 )
3524 except Exception as e:
3525 raise PlantArchitectureError(f"Failed to add plant instance: {e}")
3526
3527 def deletePlantInstance(self, plant_id: int) -> None:
3528 """
3529 Delete a plant instance and all associated geometry.
3530
3531 This method removes a plant from the simulation, deleting all shoots, organs,
3532 and associated primitives from the context. The plant ID becomes invalid after
3533 deletion and should not be used in subsequent operations.
3534
3535 Args:
3536 plant_id: ID of the plant instance to delete
3537
3538 Raises:
3539 ValueError: If plant_id is negative
3540 PlantArchitectureError: If plant deletion fails or plant doesn't exist
3541
3542 Example:
3543 >>> # Delete a plant
3544 >>> plantarch.deletePlantInstance(plant_id)
3545 >>>
3546 >>> # Delete multiple plants
3547 >>> for pid in plant_ids_to_remove:
3548 ... plantarch.deletePlantInstance(pid)
3549 """
3550 if plant_id < 0:
3551 raise ValueError("Plant ID must be non-negative")
3552
3554 try:
3556 plantarch_wrapper.deletePlantInstance(self._plantarch_ptr, plant_id)
3557 except Exception as e:
3558 raise PlantArchitectureError(f"Failed to delete plant instance {plant_id}: {e}")
3559
3560 def addBaseStemShoot(self,
3561 plant_id: int,
3562 current_node_number: int,
3563 base_rotation: AxisRotation,
3564 internode_radius: float,
3565 internode_length_max: float,
3566 internode_length_scale_factor_fraction: float,
3567 leaf_scale_factor_fraction: float,
3568 radius_taper: float,
3569 shoot_type_label: str) -> int:
3570 """
3571 Add a base stem shoot to a plant instance (main trunk/stem).
3572
3573 This method creates the primary shoot originating from the plant base. The base stem
3574 is typically the main trunk or primary stem from which all other shoots branch.
3575 Specify growth parameters to control the shoot's morphology and development.
3577 **IMPORTANT - Shoot Type Requirement**: Shoot types must be defined before use. The standard
3578 workflow is to load a plant model first using loadPlantModelFromLibrary(), which defines
3579 shoot types that can then be used for custom building. The shoot_type_label must match a
3580 shoot type defined in the loaded model.
3581
3582 Args:
3583 plant_id: ID of the plant instance
3584 current_node_number: Starting node number for this shoot (typically 1)
3585 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in radians (use math.radians() to convert)
3586 internode_radius: Base radius of internodes in meters (must be > 0)
3587 internode_length_max: Maximum internode length in meters (must be > 0)
3588 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
3589 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
3590 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
3591 shoot_type_label: Label identifying shoot type - must match a type from loaded model
3592
3593 Returns:
3594 Shoot ID for the created shoot
3595
3596 Raises:
3597 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
3598 PlantArchitectureError: If shoot creation fails or shoot type doesn't exist
3599
3600 Example:
3601 >>> from pyhelios.types import vec3, AxisRotation
3602 >>>
3603 >>> # REQUIRED: Load a plant model to define shoot types
3604 >>> plantarch.loadPlantModelFromLibrary("bean")
3605 >>>
3606 >>> # Create empty plant for custom building
3607 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
3608 >>>
3609 >>> # Add base stem using a shoot type from the loaded model. Labels are
3610 >>> # species-specific: bean defines "unifoliate"/"trifoliate", almond
3611 >>> # defines "trunk"/"scaffold"/"proleptic"/"sylleptic". There is no
3612 >>> # generic "stem" type.
3613 >>> shoot_id = plantarch.addBaseStemShoot(
3614 ... plant_id=plant_id,
3615 ... current_node_number=1,
3616 ... base_rotation=AxisRotation(0, 0, 0), # Upright
3617 ... internode_radius=0.01, # 1cm radius
3618 ... internode_length_max=0.1, # 10cm max length
3619 ... internode_length_scale_factor_fraction=1.0,
3620 ... leaf_scale_factor_fraction=1.0,
3621 ... radius_taper=0.9, # Gradual taper
3622 ... shoot_type_label="trifoliate" # Must match loaded model
3623 ... )
3624 """
3625 if plant_id < 0:
3626 raise ValueError("Plant ID must be non-negative")
3627 if current_node_number < 0:
3628 raise ValueError("Current node number must be non-negative")
3629 if internode_radius <= 0:
3630 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
3631 if internode_length_max <= 0:
3632 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
3633 if not shoot_type_label or not shoot_type_label.strip():
3634 raise ValueError("Shoot type label cannot be empty")
3635
3636 # Convert rotation to list for C++ interface
3637 rotation_list = base_rotation.to_list()
3638
3640 try:
3642 return plantarch_wrapper.addBaseStemShoot(
3643 self._plantarch_ptr, plant_id, current_node_number, rotation_list,
3644 internode_radius, internode_length_max,
3645 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
3646 radius_taper, shoot_type_label.strip()
3647 )
3648 except Exception as e:
3649 error_msg = str(e)
3650 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
3652 f"Shoot type '{shoot_type_label}' not defined. "
3653 f"Load a plant model first to define shoot types:\n"
3654 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
3655 f"Original error: {e}"
3656 )
3657 raise PlantArchitectureError(f"Failed to add base stem shoot: {e}")
3658
3659 def appendShoot(self,
3660 plant_id: int,
3661 parent_shoot_id: int,
3662 current_node_number: int,
3663 base_rotation: AxisRotation,
3664 internode_radius: float,
3665 internode_length_max: float,
3666 internode_length_scale_factor_fraction: float,
3667 leaf_scale_factor_fraction: float,
3668 radius_taper: float,
3669 shoot_type_label: str) -> int:
3670 """
3671 Append a shoot to the end of an existing shoot.
3672
3673 This method extends an existing shoot by appending a new shoot at its terminal bud.
3674 Useful for creating multi-segmented shoots with varying properties along their length,
3675 such as shoots with different growth phases or developmental stages.
3676
3677 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
3678 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
3679 calling this method.
3680
3681 Args:
3682 plant_id: ID of the plant instance
3683 parent_shoot_id: ID of the parent shoot to extend
3684 current_node_number: Starting node number for this shoot
3685 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in radians (use math.radians() to convert)
3686 internode_radius: Base radius of internodes in meters (must be > 0)
3687 internode_length_max: Maximum internode length in meters (must be > 0)
3688 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
3689 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
3690 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
3691 shoot_type_label: Label identifying shoot type - must match loaded model
3692
3693 Returns:
3694 Shoot ID for the appended shoot
3695
3696 Raises:
3697 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
3698 PlantArchitectureError: If shoot appending fails, parent doesn't exist, or shoot type not defined
3699
3700 Example:
3701 >>> # Load model to define shoot types
3702 >>> plantarch.loadPlantModelFromLibrary("bean")
3703 >>>
3704 >>> # Append shoot with reduced size to simulate apical growth
3705 >>> new_shoot_id = plantarch.appendShoot(
3706 ... plant_id=plant_id,
3707 ... parent_shoot_id=base_shoot_id,
3708 ... current_node_number=10,
3709 ... base_rotation=AxisRotation(0, 0, 0),
3710 ... internode_radius=0.008, # Smaller than base
3711 ... internode_length_max=0.08, # Shorter internodes
3712 ... internode_length_scale_factor_fraction=1.0,
3713 ... leaf_scale_factor_fraction=0.8, # Smaller leaves
3714 ... radius_taper=0.85,
3715 ... shoot_type_label="trifoliate"
3716 ... )
3717 """
3718 if plant_id < 0:
3719 raise ValueError("Plant ID must be non-negative")
3720 if parent_shoot_id < 0:
3721 raise ValueError("Parent shoot ID must be non-negative")
3722 if current_node_number < 0:
3723 raise ValueError("Current node number must be non-negative")
3724 if internode_radius <= 0:
3725 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
3726 if internode_length_max <= 0:
3727 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
3728 if not shoot_type_label or not shoot_type_label.strip():
3729 raise ValueError("Shoot type label cannot be empty")
3730
3731 # Convert rotation to list for C++ interface
3732 rotation_list = base_rotation.to_list()
3733
3735 try:
3737 return plantarch_wrapper.appendShoot(
3738 self._plantarch_ptr, plant_id, parent_shoot_id, current_node_number,
3739 rotation_list, internode_radius, internode_length_max,
3740 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
3741 radius_taper, shoot_type_label.strip()
3742 )
3743 except Exception as e:
3744 error_msg = str(e)
3745 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
3747 f"Shoot type '{shoot_type_label}' not defined. "
3748 f"Load a plant model first to define shoot types:\n"
3749 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
3750 f"Original error: {e}"
3751 )
3752 raise PlantArchitectureError(f"Failed to append shoot: {e}")
3753
3754 def addChildShoot(self,
3755 plant_id: int,
3756 parent_shoot_id: int,
3757 parent_node_index: int,
3758 current_node_number: int,
3759 shoot_base_rotation: AxisRotation,
3760 internode_radius: float,
3761 internode_length_max: float,
3762 internode_length_scale_factor_fraction: float,
3763 leaf_scale_factor_fraction: float,
3764 radius_taper: float,
3765 shoot_type_label: str,
3766 petiole_index: int = 0) -> int:
3767 """
3768 Add a child shoot at an axillary bud position on a parent shoot.
3769
3770 This method creates a lateral branch shoot emerging from a specific node on the
3771 parent shoot. Child shoots enable creation of branching architectures, with control
3772 over branch angle, size, and which petiole position the branch emerges from (for
3773 plants with multiple petioles per node).
3774
3775 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
3776 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
3777 calling this method.
3778
3779 Args:
3780 plant_id: ID of the plant instance
3781 parent_shoot_id: ID of the parent shoot
3782 parent_node_index: Index of the parent node where child emerges (0-based)
3783 current_node_number: Starting node number for this child shoot
3784 shoot_base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in radians (use math.radians() to convert)
3785 internode_radius: Base radius of child shoot internodes in meters (must be > 0)
3786 internode_length_max: Maximum internode length in meters (must be > 0)
3787 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
3788 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
3789 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
3790 shoot_type_label: Label identifying shoot type - must match loaded model
3791 petiole_index: Which petiole at the node to branch from (default: 0)
3792
3793 Returns:
3794 Shoot ID for the created child shoot
3795
3796 Raises:
3797 ValueError: If parameters are invalid (negative values, non-positive dimensions, empty label)
3798 PlantArchitectureError: If child shoot creation fails, parent doesn't exist, or shoot type not defined
3799
3800 Example:
3801 >>> import math
3802 >>> # Load model to define shoot types
3803 >>> plantarch.loadPlantModelFromLibrary("bean")
3804 >>>
3805 >>> # Add lateral branch at 45-degree angle from node 3
3806 >>> branch_id = plantarch.addChildShoot(
3807 ... plant_id=plant_id,
3808 ... parent_shoot_id=main_shoot_id,
3809 ... parent_node_index=3,
3810 ... current_node_number=1,
3811 ... shoot_base_rotation=AxisRotation(math.radians(45), math.radians(90), 0), # 45° out, 90° around
3812 ... internode_radius=0.005, # Thinner than main stem
3813 ... internode_length_max=0.06, # Shorter internodes
3814 ... internode_length_scale_factor_fraction=1.0,
3815 ... leaf_scale_factor_fraction=0.9,
3816 ... radius_taper=0.8,
3817 ... shoot_type_label="trifoliate"
3818 ... )
3819 >>>
3820 >>> # Add second branch from opposite petiole
3821 >>> branch_id2 = plantarch.addChildShoot(
3822 ... plant_id, main_shoot_id, 3, 1, AxisRotation(math.radians(45), math.radians(270), 0),
3823 ... 0.005, 0.06, 1.0, 0.9, 0.8, "trifoliate", petiole_index=1
3824 ... )
3825 """
3826 if plant_id < 0:
3827 raise ValueError("Plant ID must be non-negative")
3828 if parent_shoot_id < 0:
3829 raise ValueError("Parent shoot ID must be non-negative")
3830 if parent_node_index < 0:
3831 raise ValueError("Parent node index must be non-negative")
3832 if current_node_number < 0:
3833 raise ValueError("Current node number must be non-negative")
3834 if internode_radius <= 0:
3835 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
3836 if internode_length_max <= 0:
3837 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
3838 if not shoot_type_label or not shoot_type_label.strip():
3839 raise ValueError("Shoot type label cannot be empty")
3840 if petiole_index < 0:
3841 raise ValueError(f"Petiole index must be non-negative, got {petiole_index}")
3842
3843 # Convert rotation to list for C++ interface
3844 rotation_list = shoot_base_rotation.to_list()
3845
3847 try:
3849 return plantarch_wrapper.addChildShoot(
3850 self._plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
3851 current_node_number, rotation_list, internode_radius,
3852 internode_length_max, internode_length_scale_factor_fraction,
3853 leaf_scale_factor_fraction, radius_taper, shoot_type_label.strip(),
3854 petiole_index
3855 )
3856 except Exception as e:
3857 error_msg = str(e)
3858 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
3860 f"Shoot type '{shoot_type_label}' not defined. "
3861 f"Load a plant model first to define shoot types:\n"
3862 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
3863 f"Original error: {e}"
3864 )
3865 raise PlantArchitectureError(f"Failed to add child shoot: {e}")
3866
3867 # =========================================================================
3868 # Reconstruction from measured geometry (helios-core 1.3.85+)
3869 # =========================================================================
3870
3871 @staticmethod
3872 def _validateNodesAndRadii(node_positions, node_radii, positions_name: str, radii_name: str):
3873 """Validate a measured node path and return it as plain lists for the ctypes layer."""
3874 if not isinstance(node_positions, (list, tuple)):
3875 raise ValueError(f"{positions_name} must be a list of vec3, got {type(node_positions).__name__}")
3876 if not isinstance(node_radii, (list, tuple)):
3877 raise ValueError(f"{radii_name} must be a list of floats, got {type(node_radii).__name__}")
3878 if len(node_positions) < 2:
3879 raise ValueError(f"{positions_name} must contain at least two positions, got {len(node_positions)}")
3880 if len(node_radii) != len(node_positions):
3881 raise ValueError(
3882 f"{radii_name} must have one entry per position: got {len(node_radii)} radii "
3883 f"for {len(node_positions)} positions")
3884 positions = []
3885 for i, pt in enumerate(node_positions):
3886 if not isinstance(pt, vec3):
3887 raise ValueError(f"{positions_name}[{i}] must be a vec3, got {type(pt).__name__}")
3888 positions.append([pt.x, pt.y, pt.z])
3889 radii = []
3890 for i, r in enumerate(node_radii):
3891 if isinstance(r, bool) or not isinstance(r, (int, float)):
3892 raise ValueError(f"{radii_name}[{i}] must be a number, got {type(r).__name__}")
3893 if r <= 0:
3894 raise ValueError(f"{radii_name}[{i}] must be positive, got {r}")
3895 radii.append(float(r))
3896 return positions, radii
3897
3899 plant_id: int,
3900 parent_shoot_id: int,
3901 parent_node_index: int,
3902 node_positions: List[vec3],
3903 node_radii: List[float],
3904 shoot_type_label: str,
3905 growth_shoot_type_label: Optional[str] = None,
3906 petiole_index: int = 0) -> int:
3907 """
3908 Add a shoot whose internode geometry is prescribed by measured node positions.
3909
3910 This builds a single shoot, rendered as one continuous internode tube, that
3911 follows a path given by the caller rather than one generated from the shoot
3912 type's curvature and tortuosity parameters. It is intended for reconstructing a
3913 plant from measured geometry such as a QSM, a digitized skeleton or
3914 photogrammetry. A shoot built through :meth:`addBaseStemShoot`,
3915 :meth:`appendShoot` or :meth:`addChildShoot` is an extrapolation from its base
3916 rotation and cannot follow a measured curve; approximating one by chaining many
3917 short shoots produces a separate tube object per link, which leaves visible gaps
3918 at every bend.
3919
3920 The supplied positions are the phytomer endpoints: N+1 positions define N
3921 internodes and therefore N phytomers. The shoot type's ``internode.length_segments``
3922 still controls how finely each internode is subdivided, with the intermediate
3923 nodes interpolated along the straight segment between the two prescribed
3924 endpoints. The caller controls internode length by choosing how many nodes to
3925 supply.
3926
3927 The prescribed phytomers are created fully elongated and are therefore not
3928 re-scaled or re-curved by subsequent calls to :meth:`advanceTime`. New phytomers
3929 added at the shoot apex as the plant grows are generated normally, continuing from
3930 the direction of the final prescribed internode, and use the mean of the
3931 prescribed internode lengths as their target length. Prescribed radii act as a
3932 lower bound: a shoot type with a non-zero ``girth_area_factor`` may thicken an
3933 internode during growth but never thins one, so a girth area factor of zero
3934 preserves the prescribed radii exactly.
3935
3936 When ``parent_shoot_id`` is non-negative the base of the shoot is seated on the
3937 parent as :meth:`addChildShoot` does (offset from the attachment node to the
3938 surface of the parent internode) and the whole path is translated onto that
3939 point. All relative geometry is preserved; only the absolute position changes,
3940 and an error is raised if the discrepancy is large enough that the shoot would
3941 not be connected to its parent.
3942
3943 **Separate growth type.** Building measured wood calls for curvature and
3944 tortuosity of zero so the measured path is not fought, a node cap at least as
3945 large as the longest measured branch, and often a girth area factor of zero so
3946 the measured radii are preserved. None of those describe how the plant should
3947 grow: a shoot inheriting them extends perfectly straight and never reaches its
3948 node cap. Pass ``growth_shoot_type_label`` to take the node caps, the gravitropic
3949 curvature of phytomers added at the apex, and the type of the shoots this shoot's
3950 vegetative buds produce from a different shoot type. ``girth_area_factor`` and
3951 bud-break probability are deliberately still taken from the build type, and the
3952 build type remains the label reported by the shoot. A measured branch longer
3953 than the growth type's ``max_nodes`` is accepted and simply stops extending.
3954
3955 Requires helios-core v1.3.85 or newer.
3956
3957 Args:
3958 plant_id: ID of the plant instance
3959 parent_shoot_id: ID of the shoot to attach to, or ``-1`` to create a base stem
3960 shoot at the start of a new plant
3961 parent_node_index: Node of the parent shoot at which the new shoot is added.
3962 Ignored when ``parent_shoot_id`` is ``-1``
3963 node_positions: Internode node positions in world coordinates, ordered from the
3964 base of the shoot to its tip. At least two are required, and no two
3965 consecutive positions may be coincident
3966 node_radii: Radius of the shoot at each node, one per position. All must be > 0
3967 shoot_type_label: Shoot type whose parameters build the measured geometry.
3968 Must already be defined (by :meth:`loadPlantModelFromLibrary` or
3969 :meth:`defineShootType`)
3970 growth_shoot_type_label: Optional shoot type whose parameters govern the
3971 shoot's future growth. ``None`` grows the shoot with ``shoot_type_label``
3972 petiole_index: Petiole within the parent node to attach to (default 0)
3973
3974 Returns:
3975 ID of the newly created shoot
3976
3977 Raises:
3978 ValueError: If any ID is out of range, a position is not a vec3, a radius is
3979 not positive, fewer than two nodes are given, or the counts differ
3980 PlantArchitectureError: If the native build fails (undefined shoot type,
3981 coincident consecutive nodes, base too far from the parent, ...) or the
3982 library predates v1.3.85
3983
3984 Note:
3985 Like every other manually added shoot, the new shoot is created dormant.
3986 Call :meth:`breakPlantDormancy` before :meth:`advanceTime` if it is to grow.
3987
3988 Example:
3989 >>> plantarch.loadPlantModelFromLibrary("bean")
3990 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
3991 >>> path = [vec3(0, 0, 0), vec3(0.01, 0, 0.1), vec3(0.03, 0.01, 0.2), vec3(0.04, 0.01, 0.3)]
3992 >>> radii = [0.006, 0.005, 0.004, 0.003]
3993 >>> stem = plantarch.addShootFromNodePositions(plant_id, -1, 0, path, radii, "unifoliate")
3994 >>> assert plantarch.isShootGeometryPrescribed(plant_id, stem)
3995 """
3996 if plant_id < 0:
3997 raise ValueError("Plant ID must be non-negative")
3998 if parent_shoot_id < -1:
3999 raise ValueError("Parent shoot ID must be -1 (base stem) or a non-negative shoot ID")
4000 if parent_node_index < 0:
4001 raise ValueError("Parent node index must be non-negative")
4002 if petiole_index < 0:
4003 raise ValueError(f"Petiole index must be non-negative, got {petiole_index}")
4004 if not isinstance(shoot_type_label, str) or not shoot_type_label.strip():
4005 raise ValueError("Shoot type label cannot be empty")
4006 if growth_shoot_type_label is not None:
4007 if not isinstance(growth_shoot_type_label, str) or not growth_shoot_type_label.strip():
4008 raise ValueError("Growth shoot type label cannot be empty when given")
4009 growth_shoot_type_label = growth_shoot_type_label.strip()
4010 positions, radii = self._validateNodesAndRadii(node_positions, node_radii, "node_positions", "node_radii")
4011
4013 try:
4015 return plantarch_wrapper.addShootFromNodePositions(
4016 self._plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
4017 positions, radii, shoot_type_label.strip(), growth_shoot_type_label, petiole_index)
4018 except Exception as e:
4019 error_msg = str(e)
4020 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
4022 f"Shoot type not defined ('{shoot_type_label}'"
4023 f"{', ' + repr(growth_shoot_type_label) if growth_shoot_type_label else ''}). "
4024 f"Load a plant model or define the shoot type first:\n"
4025 f" plantarch.loadPlantModelFromLibrary('bean') # or defineShootType(...)\n"
4026 f"Original error: {e}")
4027 raise PlantArchitectureError(f"Failed to add shoot from node positions: {e}")
4028
4029 def setPetioleNodePositions(self,
4030 plant_id: int,
4031 shoot_id: int,
4032 node_index: int,
4033 petiole_index: int,
4034 node_positions: List[vec3],
4035 node_radii: List[float]) -> None:
4036 """
4037 Prescribe the path of a petiole on an existing phytomer from measured node positions.
4038
4039 This is the organ-level counterpart of :meth:`addShootFromNodePositions`. Where
4040 that method prescribes the internode skeleton of a shoot, this one prescribes the
4041 centerline of a single petiole hanging off it, so that a reconstruction from
4042 labelled measurements (a segmented point cloud, a digitized plant) can follow the
4043 measured petiole rather than the path the shoot type's petiole pitch and
4044 curvature would generate.
4045
4046 The supplied positions are the nodes of the petiole tube, ordered from the base
4047 outward. Their number is free and need not match the shoot type's
4048 ``petiole.length_segments``; the petiole tube is rebuilt to match. The first
4049 position is snapped onto the tip of the internode the petiole grows from and the
4050 rest of the path is translated by the same amount, so all relative geometry is
4051 preserved exactly. An error is raised if that discrepancy is large enough that
4052 the petiole would not be attached to the stem.
4053
4054 The prescribed petiole is not re-scaled by subsequent calls to
4055 :meth:`advanceTime`, and its radii are held as given.
4056
4057 Requires helios-core v1.3.85 or newer.
4058
4059 Args:
4060 plant_id: ID of the plant instance
4061 shoot_id: ID of the shoot carrying the phytomer
4062 node_index: Index of the phytomer within the shoot, counted from the base
4063 petiole_index: Index of the petiole within the phytomer
4064 node_positions: Petiole node positions in world coordinates, base to tip. At
4065 least two are required, and no two consecutive positions may be coincident
4066 node_radii: Radius of the petiole at each node, one per position. All must be > 0
4067
4068 Raises:
4069 ValueError: If any index is negative, a position is not a vec3, a radius is
4070 not positive, fewer than two nodes are given, or the counts differ
4071 PlantArchitectureError: If the native call fails or the library predates v1.3.85
4072
4073 Note:
4074 Call this **before** :meth:`setPetioleLeafGeometry` for the same petiole, since
4075 leaf placement is oriented from the petiole axis.
4076 """
4077 for name, v in (("Plant ID", plant_id), ("Shoot ID", shoot_id),
4078 ("Node index", node_index), ("Petiole index", petiole_index)):
4079 if v < 0:
4080 raise ValueError(f"{name} must be non-negative")
4081 positions, radii = self._validateNodesAndRadii(node_positions, node_radii, "node_positions", "node_radii")
4082
4084 try:
4086 plantarch_wrapper.setPetioleNodePositions(
4087 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index, positions, radii)
4088 except Exception as e:
4090 f"Failed to set petiole node positions (plant {plant_id}, shoot {shoot_id}, "
4091 f"node {node_index}, petiole {petiole_index}): {e}")
4092
4093 def setPetioleLeafGeometry(self,
4094 plant_id: int,
4095 shoot_id: int,
4096 node_index: int,
4097 petiole_index: int,
4098 leaf_bases: List[vec3],
4099 leaf_rotations: List[AxisRotation],
4100 leaf_sizes: List[float]) -> None:
4101 """
4102 Prescribe the base position, orientation and size of every leaf on a petiole.
4103
4104 This is the leaf-level counterpart of :meth:`setPetioleNodePositions`, intended
4105 for the same reconstruction workflow. Every leaf on the petiole is prescribed in
4106 one call: for a compound leaf the leaflets are not independent, since a
4107 leaflet's roll and yaw signs and the prototype it is a copy of all follow from
4108 its position along the petiole. A species with one leaf per petiole passes
4109 one-element lists.
4110
4111 Each leaf is rebuilt from its prototype and re-oriented through the same
4112 rotation chain used when a leaf is grown. The prescribed base, orientation and
4113 size are held exactly and are not changed by :meth:`advanceTime`; prescribed
4114 leaves are additionally exempt from the self-weight droop.
4115
4116 **Rotation units and frame.** ``leaf_rotations`` are given in **radians**, as
4117 pitch, yaw and roll relative to the petiole and internode axes, not to world
4118 axes (the same convention as the native ``Phytomer::leaf_rotation``). The full
4119 chain that places a leaf includes the petiole's own azimuth and a
4120 size-dependent correction and is not invertible, so there is no exact
4121 conversion from a world-frame blade orientation; a caller fitting to measured
4122 data should iterate by forward evaluation, reading the resulting geometry back
4123 from the Context.
4124
4125 Requires helios-core v1.3.85 or newer.
4126
4127 Args:
4128 plant_id: ID of the plant instance
4129 shoot_id: ID of the shoot carrying the phytomer
4130 node_index: Index of the phytomer within the shoot, counted from the base
4131 petiole_index: Index of the petiole within the phytomer
4132 leaf_bases: Base position of each leaf in world coordinates, one per leaf on
4133 the petiole, in the petiole's existing leaf order
4134 leaf_rotations: ``AxisRotation(pitch, yaw, roll)`` of each leaf in **radians**
4135 leaf_sizes: Fully elongated size of each leaf in meters. All must be > 0
4136
4137 Raises:
4138 ValueError: If any index is negative, a base is not a vec3, a rotation is not
4139 an AxisRotation, a size is not positive, or the three lists differ in length
4140 PlantArchitectureError: If the number of leaves does not match the petiole
4141 (the count is fixed when the phytomer is created), the native call
4142 fails, or the library predates v1.3.85
4143
4144 Note:
4145 Rebuilding each leaf discards primitive data a caller has attached to it.
4146 The object label and material are restored; other primitive data is not.
4147 """
4148 for name, v in (("Plant ID", plant_id), ("Shoot ID", shoot_id),
4149 ("Node index", node_index), ("Petiole index", petiole_index)):
4150 if v < 0:
4151 raise ValueError(f"{name} must be non-negative")
4152 for name, seq in (("leaf_bases", leaf_bases), ("leaf_rotations", leaf_rotations), ("leaf_sizes", leaf_sizes)):
4153 if not isinstance(seq, (list, tuple)):
4154 raise ValueError(f"{name} must be a list, got {type(seq).__name__}")
4155 n = len(leaf_bases)
4156 if n < 1:
4157 raise ValueError("leaf_bases must contain at least one leaf")
4158 if len(leaf_rotations) != n or len(leaf_sizes) != n:
4159 raise ValueError(
4160 f"leaf_bases, leaf_rotations and leaf_sizes must have the same length: "
4161 f"got {n}, {len(leaf_rotations)} and {len(leaf_sizes)}")
4162 bases = []
4163 for i, b in enumerate(leaf_bases):
4164 if not isinstance(b, vec3):
4165 raise ValueError(f"leaf_bases[{i}] must be a vec3, got {type(b).__name__}")
4166 bases.append([b.x, b.y, b.z])
4167 rotations = []
4168 for i, r in enumerate(leaf_rotations):
4169 if not isinstance(r, AxisRotation):
4170 raise ValueError(f"leaf_rotations[{i}] must be an AxisRotation, got {type(r).__name__}")
4171 rotations.append([r.pitch, r.yaw, r.roll])
4172 sizes = []
4173 for i, sz in enumerate(leaf_sizes):
4174 if isinstance(sz, bool) or not isinstance(sz, (int, float)):
4175 raise ValueError(f"leaf_sizes[{i}] must be a number, got {type(sz).__name__}")
4176 if sz <= 0:
4177 raise ValueError(f"leaf_sizes[{i}] must be positive, got {sz}")
4178 sizes.append(float(sz))
4179
4181 try:
4183 plantarch_wrapper.setPetioleLeafGeometry(
4184 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4185 bases, rotations, sizes)
4186 except Exception as e:
4188 f"Failed to set petiole leaf geometry (plant {plant_id}, shoot {shoot_id}, "
4189 f"node {node_index}, petiole {petiole_index}): {e}")
4190
4191 def setPetioleLeafCount(self, plant_id: int, shoot_id: int, node_index: int,
4192 petiole_index: int, leaf_count: int) -> None:
4193 """
4194 Change the number of leaves (leaflets) on one petiole of an existing phytomer.
4195
4196 The leaves are rebuilt procedurally. Without this, the count is fixed by the shoot
4197 type's ``leaf.leaves_per_petiole`` for every phytomer, so a measured compound leaf
4198 with a different number of leaflets could not be prescribed with
4199 :meth:`setPetioleLeafGeometry`.
4200
4201 Args:
4202 plant_id: Plant identifier.
4203 shoot_id: Shoot identifier.
4204 node_index: Index of the phytomer along the shoot.
4205 petiole_index: Index of the petiole on that phytomer.
4206 leaf_count: Number of leaves to place on the petiole. Must be at least 1.
4207
4208 Raises:
4209 ValueError: If any index is negative or ``leaf_count`` is less than 1.
4210 PlantArchitectureError: If the operation fails.
4211
4212 Note:
4213 Call this **before** :meth:`setPetioleLeafGeometry` for the same petiole, whose
4214 ``leaf_count`` must match the number of leaves on the petiole.
4215 """
4216 for name, v in (("Plant ID", plant_id), ("Shoot ID", shoot_id),
4217 ("Node index", node_index), ("Petiole index", petiole_index)):
4218 if v < 0:
4219 raise ValueError(f"{name} must be non-negative")
4220 if leaf_count < 1:
4221 raise ValueError(f"Leaf count must be at least 1, got {leaf_count}")
4222
4224 try:
4226 plantarch_wrapper.setPetioleLeafCount(
4227 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index, leaf_count)
4228 except Exception as e:
4230 f"Failed to set petiole leaf count (plant {plant_id}, shoot {shoot_id}, "
4231 f"node {node_index}, petiole {petiole_index}): {e}")
4232
4233 def setShootInternodeLengthMax(self, plant_id: int, shoot_id: int,
4234 internode_length_max: float) -> None:
4235 """
4236 Set the target length of internodes grown at the apex of an existing shoot.
4237
4238 A shoot built by :meth:`addShootFromNodePositions` otherwise grows toward the mean
4239 of its prescribed internode lengths, so a measured seedling -- whose measured stem
4240 is mostly hypocotyl -- could not be grown forward with realistic internodes.
4241
4242 Args:
4243 plant_id: Plant identifier.
4244 shoot_id: Shoot identifier.
4245 internode_length_max: Target internode length in meters. Must be positive.
4246
4247 Raises:
4248 ValueError: If an identifier is negative or the length is not positive.
4249 PlantArchitectureError: If the operation fails.
4250
4251 Note:
4252 This value is **not** saved by :meth:`writePlantStructureXML`, so it must be
4253 set again after :meth:`readPlantStructureXML`.
4254 """
4255 if plant_id < 0 or shoot_id < 0:
4256 raise ValueError("Plant ID and shoot ID must be non-negative")
4257 if internode_length_max <= 0:
4258 raise ValueError(f"Internode length must be positive, got {internode_length_max}")
4259
4261 try:
4263 plantarch_wrapper.setShootInternodeLengthMax(
4264 self._plantarch_ptr, plant_id, shoot_id, internode_length_max)
4265 except Exception as e:
4267 f"Failed to set shoot internode length max (plant {plant_id}, "
4268 f"shoot {shoot_id}): {e}")
4269
4270 # Bud and apex control
4271 def terminateApicalBud(self, plant_id: int, shoot_id: int) -> None:
4272 """
4273 Stop a shoot's apex from adding any further phytomers.
4274
4275 The shoot keeps everything it already has, and its vegetative buds keep whatever
4276 state they are in -- this kills only the apical meristem. The shoot therefore stops
4277 extending at its tip but can still throw laterals; to stop those as well, pair this
4278 with :meth:`removeShootVegetativeBuds`.
4279
4280 This is the standard way to freeze the old wood of a reconstructed tree before
4281 growing it forward with :meth:`advanceTime`.
4283 Args:
4284 plant_id: ID of the plant instance
4285 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
4286
4287 Raises:
4288 ValueError: If either identifier is not a non-negative int
4289 PlantArchitectureError: If the plant or shoot does not exist
4290
4291 Example:
4292 >>> # Freeze the measured scaffold so only last year's growth extends
4293 >>> for shoot_id in plantarch.getTerminalShootIDs(plant_id):
4294 ... plantarch.terminateApicalBud(plant_id, shoot_id)
4295 """
4296 self._validateShootIdentifiers(plant_id, shoot_id)
4297
4299 try:
4300 plantarch_wrapper.terminateShootApicalBud(self._plantarch_ptr, plant_id, shoot_id)
4301 except Exception as e:
4303 f"Failed to terminate the apical bud of shoot {shoot_id} "
4304 f"of plant {plant_id}: {e}")
4305
4306 def getShootVegetativeBudCount(self, plant_id: int, shoot_id: int,
4307 state: Optional[BudState] = None) -> int:
4308 """
4309 Count a shoot's axillary vegetative buds, summed over all phytomers and petioles.
4310
4311 Buds are never removed from a shoot -- only their state changes -- so the
4312 unfiltered count is stable over the shoot's life and makes a useful denominator.
4313
4314 Args:
4315 plant_id: ID of the plant instance
4316 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
4317 state: Count only buds in this :class:`~pyhelios.BudState`. ``None``
4318 (the default) counts buds in every state.
4319
4320 Returns:
4321 The number of matching vegetative buds.
4322
4323 Raises:
4324 ValueError: If an identifier is negative, or ``state`` is not a BudState
4325 PlantArchitectureError: If the plant or shoot does not exist
4326
4327 Note:
4328 ``BudState.DEAD`` means "will produce nothing further", which covers both buds
4329 that were killed and buds that have **already broken into a child shoot**. A
4330 dead-bud count is therefore not a count of killed buds. To test whether a shoot
4331 can still grow, count the live states instead -- for example the unfiltered
4332 total minus the dead count.
4333
4334 Example:
4335 >>> from pyhelios import BudState
4336 >>> total = plantarch.getShootVegetativeBudCount(plant_id, 0)
4337 >>> dead = plantarch.getShootVegetativeBudCount(plant_id, 0, BudState.DEAD)
4338 >>> print(f"{total - dead} buds can still break")
4339 """
4340 self._validateShootIdentifiers(plant_id, shoot_id)
4341 bud_state = -1 if state is None else int(self._validateBudState(state))
4342
4344 try:
4345 return plantarch_wrapper.getShootVegetativeBudCount(
4346 self._plantarch_ptr, plant_id, shoot_id, bud_state)
4347 except Exception as e:
4349 f"Failed to count the vegetative buds of shoot {shoot_id} "
4350 f"of plant {plant_id}: {e}")
4351
4352 def getPlantLeafCount(self, plant_id: int) -> int:
4353 """
4354 Get the number of leaf objects on a plant.
4355
4356 Counts compound leaf objects, not primitives -- a leaf built from many triangles
4357 counts once, and a compound leaf contributes one per leaflet. Equivalent to
4358 ``len(getPlantLeafObjectIDs(plant_id))`` without materializing the ID list.
4359
4360 Args:
4361 plant_id: ID of the plant instance
4362
4363 Returns:
4364 The number of leaf objects.
4365
4366 Raises:
4367 ValueError: If plant_id is not a non-negative int
4368 PlantArchitectureError: If the plant does not exist
4369
4370 Example:
4371 >>> print(f"{plantarch.getPlantLeafCount(plant_id)} leaves")
4372 """
4373 if isinstance(plant_id, bool) or not isinstance(plant_id, int):
4374 raise ValueError(f"Plant ID must be a non-negative int, got {type(plant_id).__name__}")
4375 if plant_id < 0:
4376 raise ValueError("Plant ID must be non-negative")
4377
4379 try:
4380 return plantarch_wrapper.getPlantLeafCount(self._plantarch_ptr, plant_id)
4381 except Exception as e:
4383 f"Failed to get the leaf count of plant {plant_id}: {e}")
4384
4385 @staticmethod
4386 def _validateShootIdentifiers(plant_id: int, shoot_id: int) -> None:
4387 """Reject non-int and negative plant/shoot identifiers.
4388
4389 bool is excluded explicitly: it is an int subclass, so True would otherwise pass
4390 as shoot 1.
4391 """
4392 for name, value in (("Plant ID", plant_id), ("Shoot ID", shoot_id)):
4393 if isinstance(value, bool) or not isinstance(value, int):
4394 raise ValueError(
4395 f"{name} must be a non-negative int, got {type(value).__name__}")
4396 if plant_id < 0 or shoot_id < 0:
4397 raise ValueError("Plant ID and shoot ID must be non-negative")
4398
4399 @staticmethod
4400 def _validateBudState(state) -> BudState:
4401 """Coerce a BudState (or its int value) and reject anything else.
4402
4403 A bare int is accepted because BudState is an IntEnum, but it still has to name a
4404 real state -- an out-of-range value would be cast onto the C++ enum, which is
4405 undefined behavior.
4406 """
4407 if isinstance(state, bool) or not isinstance(state, int):
4408 raise ValueError(f"State must be a BudState, got {type(state).__name__}")
4409 try:
4410 return BudState(int(state))
4411 except ValueError:
4412 raise ValueError(
4413 f"State must be a BudState value in 0..5, got {int(state)}")
4414
4415 def enableLeafAngleDistributionTracking(self, plant_ids, beta_mu_inclination: float,
4416 beta_nu_inclination: float, eccentricity: float,
4417 ellipse_rotation_degrees: float,
4418 lambda_degrees: float) -> None:
4419 """
4420 Steer leaf inclination and azimuth toward a prescribed distribution as the plant grows.
4421
4422 Each leaf is given a target angle as it emerges and turns onto it while it expands,
4423 so a fully grown leaf never moves again: the plant matches the distribution at every
4424 stage without leaves shifting from one timestep to the next. Targets are not drawn
4425 independently per leaf, which would reproduce the distribution while destroying the
4426 arrangement the model generated -- each emerging leaf takes the bin that best trades
4427 closeness to the angle the model gave it against how far that bin is below its share
4428 of the plant's leaf area.
4429
4430 Pass a list of plant IDs to realize the distribution over a canopy as a whole, in
4431 which case an individual plant need not follow the distribution on its own.
4432
4433 Enabling tracking on an already-tracked plant replaces its target, so the target may
4434 be varied over the plant's life.
4435
4436 Args:
4437 plant_ids: A single plant ID, or a sequence of plant IDs to steer together
4438 beta_mu_inclination: Mean parameter of the Beta inclination distribution
4439 beta_nu_inclination: Shape parameter of the Beta inclination distribution
4440 eccentricity: Eccentricity of the ellipse defining the azimuth distribution
4441 ellipse_rotation_degrees: Rotation of that ellipse (degrees)
4442 lambda_degrees: How strongly to favour filling the distribution over keeping each
4443 leaf near the angle the model gave it. Zero leaves the plant unchanged; values
4444 of order 180 match the distribution as closely as the growing plant allows.
4445
4446 Raises:
4447 ValueError: If any plant ID is not a non-negative int, or the list is empty
4448 PlantArchitectureError: If a plant does not exist
4449 RuntimeError: If the native library predates helios-core v1.3.87
4450
4451 Example:
4452 >>> plantarch.enableLeafAngleDistributionTracking(
4453 ... plant_id, 2.0, 1.5, 0.5, 0.0, 180.0)
4454 >>> plantarch.advanceTime(plant_id, 20)
4455 """
4456 multi = not isinstance(plant_ids, int) or isinstance(plant_ids, bool)
4457 ids = self._validatePlantIdList(plant_ids) if multi else [
4458 self._validatePlantIdentifier(plant_ids)]
4459
4461 try:
4462 if multi:
4463 plantarch_wrapper.enablePlantLeafAngleDistributionTrackingMulti(
4464 self._plantarch_ptr, ids, beta_mu_inclination, beta_nu_inclination,
4465 eccentricity, ellipse_rotation_degrees, lambda_degrees)
4466 else:
4467 plantarch_wrapper.enablePlantLeafAngleDistributionTracking(
4468 self._plantarch_ptr, ids[0], beta_mu_inclination, beta_nu_inclination,
4469 eccentricity, ellipse_rotation_degrees, lambda_degrees)
4470 except Exception as e:
4472 f"Failed to enable leaf angle distribution tracking for {ids}: {e}")
4473
4474 def enableLeafElevationAngleDistributionTracking(self, plant_id: int,
4475 beta_mu_inclination: float,
4476 beta_nu_inclination: float,
4477 lambda_degrees: float) -> None:
4478 """
4479 Steer leaf inclination toward a Beta distribution as the plant grows, leaving azimuth
4480 to the procedural model.
4481
4482 The inclination-only counterpart of :meth:`enableLeafAngleDistributionTracking`.
4483
4484 Args:
4485 plant_id: ID of the plant instance
4486 beta_mu_inclination: Mean parameter of the Beta inclination distribution
4487 beta_nu_inclination: Shape parameter of the Beta inclination distribution
4488 lambda_degrees: How strongly to favour filling the distribution over keeping each
4489 leaf near the angle the model gave it
4490
4491 Raises:
4492 ValueError: If ``plant_id`` is not a non-negative int
4493 PlantArchitectureError: If the plant does not exist
4494 RuntimeError: If the native library predates helios-core v1.3.87
4495 """
4496 plant_id = self._validatePlantIdentifier(plant_id)
4497
4499 try:
4500 plantarch_wrapper.enablePlantLeafElevationAngleDistributionTracking(
4501 self._plantarch_ptr, plant_id, beta_mu_inclination, beta_nu_inclination,
4502 lambda_degrees)
4503 except Exception as e:
4505 f"Failed to enable leaf elevation angle distribution tracking for "
4506 f"plant {plant_id}: {e}")
4507
4508 def enableLeafAzimuthAngleDistributionTracking(self, plant_id: int, eccentricity: float,
4509 ellipse_rotation_degrees: float,
4510 lambda_degrees: float) -> None:
4511 """
4512 Steer leaf azimuth toward an ellipsoidal distribution as the plant grows, leaving
4513 inclination to the procedural model.
4514
4515 The azimuth-only counterpart of :meth:`enableLeafAngleDistributionTracking`.
4516
4517 Args:
4518 plant_id: ID of the plant instance
4519 eccentricity: Eccentricity of the ellipse defining the azimuth distribution
4520 ellipse_rotation_degrees: Rotation of that ellipse (degrees)
4521 lambda_degrees: How strongly to favour filling the distribution over keeping each
4522 leaf near the angle the model gave it
4523
4524 Raises:
4525 ValueError: If ``plant_id`` is not a non-negative int
4526 PlantArchitectureError: If the plant does not exist
4527 RuntimeError: If the native library predates helios-core v1.3.87
4528 """
4529 plant_id = self._validatePlantIdentifier(plant_id)
4530
4532 try:
4533 plantarch_wrapper.enablePlantLeafAzimuthAngleDistributionTracking(
4534 self._plantarch_ptr, plant_id, eccentricity, ellipse_rotation_degrees,
4535 lambda_degrees)
4536 except Exception as e:
4538 f"Failed to enable leaf azimuth angle distribution tracking for "
4539 f"plant {plant_id}: {e}")
4540
4541 def disableLeafAngleDistributionTracking(self, plant_id: int) -> None:
4542 """
4543 Stop steering a plant's leaf angles toward a prescribed distribution.
4544
4545 Leaves already steered keep the orientation they have reached; leaves emerging
4546 afterward are left where the procedural model puts them.
4547
4548 Args:
4549 plant_id: ID of the plant instance
4550
4551 Raises:
4552 ValueError: If ``plant_id`` is not a non-negative int
4553 PlantArchitectureError: If the plant does not exist
4554 RuntimeError: If the native library predates helios-core v1.3.87
4555 """
4556 plant_id = self._validatePlantIdentifier(plant_id)
4557
4559 try:
4560 plantarch_wrapper.disablePlantLeafAngleDistributionTracking(
4561 self._plantarch_ptr, plant_id)
4562 except Exception as e:
4564 f"Failed to disable leaf angle distribution tracking for "
4565 f"plant {plant_id}: {e}")
4566
4567 def isLeafAngleDistributionTrackingEnabled(self, plant_id: int) -> bool:
4568 """
4569 Whether a plant's leaf angles are being steered toward a prescribed distribution.
4570
4571 Args:
4572 plant_id: ID of the plant instance
4573
4574 Returns:
4575 True if tracking is in effect for this plant
4576
4577 Raises:
4578 ValueError: If ``plant_id`` is not a non-negative int
4579 PlantArchitectureError: If the plant does not exist
4580 RuntimeError: If the native library predates helios-core v1.3.87
4581 """
4582 plant_id = self._validatePlantIdentifier(plant_id)
4583
4585 try:
4586 return plantarch_wrapper.isPlantLeafAngleDistributionTrackingEnabled(
4587 self._plantarch_ptr, plant_id)
4588 except Exception as e:
4590 f"Failed to query leaf angle distribution tracking for "
4591 f"plant {plant_id}: {e}")
4592
4593 def getPetioleLength(self, plant_id: int, shoot_id: int, node_index: int,
4594 petiole_index: Optional[int] = None) -> float:
4595 """
4596 Current length of a phytomer's petioles, measured along the centerline.
4597
4598 This is the length right now, not the mature length the petiole is growing toward,
4599 so it rises as the petiole elongates. Contrast the leaf readers, which report the
4600 size a leaf is expanding toward. The length is an arclength rather than a
4601 base-to-tip distance, so a petiole drooping under its leaves reports the same
4602 length as a rigid one of the same age.
4603
4604 With ``petiole_index`` omitted, returns the mean over every petiole on the
4605 phytomer. Petioles at one node are parallel structures rather than segments in
4606 series, so their lengths are not additive and the mean is the meaningful summary.
4607 A phytomer with no petiole -- a leafless woody type, or one whose leaf has been
4608 shed -- reports 0.0.
4609
4610 Args:
4611 plant_id: ID of the plant instance
4612 shoot_id: Shoot index within the plant
4613 node_index: Phytomer index within the shoot
4614 petiole_index: Petiole within the phytomer; ``None`` for the phytomer mean
4615
4616 Returns:
4617 Current petiole arclength in meters
4618
4619 Raises:
4620 ValueError: If any identifier is not a non-negative int
4621 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4622 RuntimeError: If the native library predates helios-core v1.3.87
4623 """
4624 self._validateShootIdentifiers(plant_id, shoot_id)
4625 node_index = self._validateNodeIndex(node_index)
4626 if petiole_index is not None:
4627 petiole_index = self._validatePetioleIndex(petiole_index)
4628
4630 try:
4631 return plantarch_wrapper.getPetioleLength(
4632 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index)
4633 except Exception as e:
4635 f"Failed to get the petiole length of node {node_index} of shoot "
4636 f"{shoot_id} of plant {plant_id}: {e}")
4637
4638 def scalePetioleMaxLength(self, plant_id: int, shoot_id: int, node_index: int,
4639 scale_factor: float) -> None:
4640 """
4641 Scale the fully-elongated length every petiole on a phytomer is growing toward.
4642
4643 The petiole counterpart of internode max-length scaling. The petiole's present
4644 length is left where it is and only its target changes, so a phytomer creation
4645 function can give a leaf born on a young plant a shorter final petiole without
4646 moving the petiole that is already there.
4647
4648 Args:
4649 plant_id: ID of the plant instance
4650 shoot_id: Shoot index within the plant
4651 node_index: Phytomer index within the shoot
4652 scale_factor: Factor to scale the fully-elongated length by; must be positive
4653
4654 Raises:
4655 ValueError: If an identifier is invalid or ``scale_factor`` is not positive
4656 PlantArchitectureError: If the plant, shoot or node does not exist
4657 RuntimeError: If the native library predates helios-core v1.3.87
4658 """
4659 self._validateShootIdentifiers(plant_id, shoot_id)
4660 node_index = self._validateNodeIndex(node_index)
4661 scale_factor = self._validateScaleFactor(scale_factor)
4662
4664 try:
4665 plantarch_wrapper.scalePetioleMaxLength(
4666 self._plantarch_ptr, plant_id, shoot_id, node_index, scale_factor)
4667 except Exception as e:
4669 f"Failed to scale the petiole max length of node {node_index} of shoot "
4670 f"{shoot_id} of plant {plant_id}: {e}")
4671
4672 def setPetioleScaleFraction(self, plant_id: int, shoot_id: int, node_index: int,
4673 petiole_index: int,
4674 petiole_scale_factor_fraction: float) -> None:
4675 """
4676 Set one petiole's current length as a fraction of its fully-elongated length,
4677 leaving the leaves it carries at the size they are.
4678
4679 A petiole is a stem segment rather than part of the blade and goes on extending
4680 after the blade has finished expanding, which is why its growth is driven by the
4681 shoot's internode rate rather than the leaf expansion rate. The leaves ride out
4682 along the petiole as it lengthens without changing size.
4683
4684 Args:
4685 plant_id: ID of the plant instance
4686 shoot_id: Shoot index within the plant
4687 node_index: Phytomer index within the shoot
4688 petiole_index: Petiole within the phytomer
4689 petiole_scale_factor_fraction: Fraction of the fully-elongated length
4690 (1.0 for a fully-elongated petiole)
4691
4692 Raises:
4693 ValueError: If any identifier is not a non-negative int
4694 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4695 RuntimeError: If the native library predates helios-core v1.3.87
4696 """
4697 self._validateShootIdentifiers(plant_id, shoot_id)
4698 node_index = self._validateNodeIndex(node_index)
4699 petiole_index = self._validatePetioleIndex(petiole_index)
4700
4702 try:
4703 plantarch_wrapper.setPetioleScaleFraction(
4704 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4705 petiole_scale_factor_fraction)
4706 except Exception as e:
4708 f"Failed to set the petiole scale fraction of node {node_index} of shoot "
4709 f"{shoot_id} of plant {plant_id}: {e}")
4710
4711 def setPetioleAndLeafScaleFraction(self, plant_id: int, shoot_id: int, node_index: int,
4712 petiole_index: int,
4713 petiole_scale_factor_fraction: float,
4714 leaf_scale_factor_fraction: float) -> None:
4715 """
4716 Set a petiole's length and its leaves' size together, each as a fraction of its own
4717 fully-elongated value.
4718
4719 The two fractions are applied in one pass, so the leaves are scaled, re-seated
4720 along the rescaled petiole and bent under their new weight once rather than twice.
4721 Use this rather than the two single-fraction calls when advancing both.
4722
4723 Args:
4724 plant_id: ID of the plant instance
4725 shoot_id: Shoot index within the plant
4726 node_index: Phytomer index within the shoot
4727 petiole_index: Petiole within the phytomer
4728 petiole_scale_factor_fraction: Fraction of the fully-elongated petiole length
4729 leaf_scale_factor_fraction: Fraction of the fully-elongated leaf scale factor
4730
4731 Raises:
4732 ValueError: If any identifier is not a non-negative int
4733 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4734 RuntimeError: If the native library predates helios-core v1.3.87
4735 """
4736 self._validateShootIdentifiers(plant_id, shoot_id)
4737 node_index = self._validateNodeIndex(node_index)
4738 petiole_index = self._validatePetioleIndex(petiole_index)
4739
4741 try:
4742 plantarch_wrapper.setPetioleAndLeafScaleFraction(
4743 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4744 petiole_scale_factor_fraction, leaf_scale_factor_fraction)
4745 except Exception as e:
4747 f"Failed to set the petiole and leaf scale fractions of node {node_index} "
4748 f"of shoot {shoot_id} of plant {plant_id}: {e}")
4749
4750 def scaleLeafSizeMax(self, plant_id: int, shoot_id: int, node_index: int,
4751 scale_factor: float) -> None:
4752 """
4753 Scale the size every leaf on a phytomer is expanding toward, leaving the blades
4754 where they are.
4755
4756 The blade's present size is untouched and only its target changes, so the expansion
4757 fraction moves the other way: a fully-expanded leaf given a larger target becomes a
4758 partly-expanded leaf of the same size and goes on growing on the next
4759 :meth:`advanceTime`. This is what hands a leaf built from measured geometry back to
4760 the growth model still the size it was measured.
4761
4762 A factor small enough to put the target below the leaf's present size is the one
4763 case in which the blade does move: the leaf is taken down to the new target, and the
4764 leaflets of a compound leaf are then re-seated along the petiole, discarding a
4765 placement prescribed by :meth:`setPetioleLeafGeometry`.
4766
4767 Args:
4768 plant_id: ID of the plant instance
4769 shoot_id: Shoot index within the plant
4770 node_index: Phytomer index within the shoot
4771 scale_factor: Factor to scale the mature leaf size by; must be positive
4772
4773 Raises:
4774 ValueError: If an identifier is invalid or ``scale_factor`` is not positive
4775 PlantArchitectureError: If the plant, shoot or node does not exist
4776 RuntimeError: If the native library predates helios-core v1.3.87
4777 """
4778 self._validateShootIdentifiers(plant_id, shoot_id)
4779 node_index = self._validateNodeIndex(node_index)
4780 scale_factor = self._validateScaleFactor(scale_factor)
4781
4783 try:
4784 plantarch_wrapper.scaleLeafSizeMax(
4785 self._plantarch_ptr, plant_id, shoot_id, node_index, scale_factor)
4786 except Exception as e:
4788 f"Failed to scale the max leaf size of node {node_index} of shoot "
4789 f"{shoot_id} of plant {plant_id}: {e}")
4790
4791 def setLeafNormal(self, plant_id: int, shoot_id: int, node_index: int,
4792 petiole_index: int, leaf_index: int, target_normal: vec3) -> None:
4793 """
4794 Re-aim one leaf so its blade faces a given direction.
4795
4796 The roll and pitch that carry the blade onto ``target_normal`` are applied as a
4797 single rotation about the leaf's own base, so the leaf stays attached to its petiole
4798 and keeps the azimuth of the petiole it hangs from. The angles are recorded on the
4799 phytomer, which is what makes the new orientation survive a
4800 :meth:`writePlantStructureXML` / :meth:`readPlantStructureXML` round trip --
4801 rotating the leaf object directly through the Context changes the geometry without
4802 changing the record and is silently lost on reload.
4803
4804 Args:
4805 plant_id: ID of the plant instance
4806 shoot_id: Shoot index within the plant
4807 node_index: Phytomer index within the shoot
4808 petiole_index: Petiole within the phytomer
4809 leaf_index: Leaf within the petiole
4810 target_normal: Direction the blade should face, in world coordinates. Need not
4811 be normalized.
4812
4813 Raises:
4814 ValueError: If an identifier is invalid, or ``target_normal`` is not a vec3
4815 PlantArchitectureError: If the leaf has no geometry, the blade's facet normals
4816 cancel, or the target cannot be reached by a roll-pitch pair
4817 RuntimeError: If the native library predates helios-core v1.3.87
4818
4819 Example:
4820 >>> from pyhelios.types import vec3
4821 >>> plantarch.setLeafNormal(plant_id, 0, 3, 0, 0, vec3(0, 0, 1))
4822 """
4823 self._validateShootIdentifiers(plant_id, shoot_id)
4824 node_index = self._validateNodeIndex(node_index)
4825 petiole_index = self._validatePetioleIndex(petiole_index)
4826 leaf_index = self._validatePetioleIndex(leaf_index, name="Leaf index")
4827 if not isinstance(target_normal, vec3):
4828 raise ValueError(
4829 f"Target normal must be a vec3, got {type(target_normal).__name__}")
4830
4832 try:
4833 plantarch_wrapper.setLeafNormal(
4834 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index,
4835 leaf_index, target_normal.x, target_normal.y, target_normal.z)
4836 except Exception as e:
4838 f"Failed to set the normal of leaf {leaf_index} on petiole "
4839 f"{petiole_index} of node {node_index} of shoot {shoot_id} "
4840 f"of plant {plant_id}: {e}")
4841
4842 def bendPetioleUnderLeafWeight(self, plant_id: int, shoot_id: int, node_index: int,
4843 petiole_index: int) -> None:
4844 """
4845 Bend one petiole, and the leaves it carries, under the weight of its leaflets.
4846
4847 The petiole is bent as a tapered cantilever clamped at its insertion, for the leaf's
4848 current size and the petiole's age. The bent shape is always computed from the
4849 recorded undeformed rest shape rather than the current shape, so repeated calls do
4850 not accumulate and creep the petiole downward. The insertion stays clamped, so the
4851 petiole keeps leaving the stem at its generated pitch and the droop appears beyond it
4852 as curvature along the length.
4853
4854 This is normally driven by the growth model from
4855 ``PhytomerParameters.petiole.flexibility``; call it directly only to re-bend a petiole
4856 after changing its geometry yourself. It does nothing for a rigid petiole (flexibility
4857 left at zero), a petiole whose centerline was prescribed, one carrying a prescribed
4858 leaf, or when neither the load nor the compliance has changed since the last call.
4859
4860 Args:
4861 plant_id: ID of the plant instance
4862 shoot_id: Shoot index within the plant
4863 node_index: Phytomer index within the shoot
4864 petiole_index: Petiole within the phytomer
4865
4866 Raises:
4867 ValueError: If any identifier is not a non-negative int
4868 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4869 RuntimeError: If the native library predates helios-core v1.3.87
4870 """
4871 self._validateShootIdentifiers(plant_id, shoot_id)
4872 node_index = self._validateNodeIndex(node_index)
4873 petiole_index = self._validatePetioleIndex(petiole_index)
4874
4876 try:
4877 plantarch_wrapper.bendPetioleUnderLeafWeight(
4878 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index)
4879 except Exception as e:
4881 f"Failed to bend petiole {petiole_index} of node {node_index} of shoot "
4882 f"{shoot_id} of plant {plant_id}: {e}")
4883
4884 def recordPetioleRestShape(self, plant_id: int, shoot_id: int, node_index: int,
4885 petiole_index: int) -> None:
4886 """
4887 Record one petiole's current centerline as its undeformed rest shape.
4888
4889 :meth:`bendPetioleUnderLeafWeight` always bends from the recorded rest shape, so a
4890 petiole whose centerline has been replaced wholesale -- by
4891 :meth:`setPetioleNodePositions`, for instance -- must have its new shape recorded
4892 before it will droop from it. This also marks the petiole as needing to be bent
4893 again, so the next bend is not skipped as redundant.
4894
4895 Args:
4896 plant_id: ID of the plant instance
4897 shoot_id: Shoot index within the plant
4898 node_index: Phytomer index within the shoot
4899 petiole_index: Petiole within the phytomer
4900
4901 Raises:
4902 ValueError: If any identifier is not a non-negative int
4903 PlantArchitectureError: If the plant, shoot, node or petiole does not exist
4904 RuntimeError: If the native library predates helios-core v1.3.87
4905 """
4906 self._validateShootIdentifiers(plant_id, shoot_id)
4907 node_index = self._validateNodeIndex(node_index)
4908 petiole_index = self._validatePetioleIndex(petiole_index)
4909
4911 try:
4912 plantarch_wrapper.recordPetioleRestShape(
4913 self._plantarch_ptr, plant_id, shoot_id, node_index, petiole_index)
4914 except Exception as e:
4916 f"Failed to record the rest shape of petiole {petiole_index} of node "
4917 f"{node_index} of shoot {shoot_id} of plant {plant_id}: {e}")
4918
4919 @staticmethod
4920 def _validatePlantIdentifier(plant_id) -> int:
4921 """Reject a non-int or negative plant ID, returning it as a plain int."""
4922 if isinstance(plant_id, bool) or not isinstance(plant_id, int):
4923 raise ValueError(
4924 f"Plant ID must be a non-negative int, got {type(plant_id).__name__}")
4925 if plant_id < 0:
4926 raise ValueError("Plant ID must be non-negative")
4927 return int(plant_id)
4928
4929 @classmethod
4930 def _validatePlantIdList(cls, plant_ids) -> List[int]:
4931 """Coerce a sequence of plant IDs, rejecting an empty or malformed one."""
4932 if isinstance(plant_ids, (str, bytes)) or not hasattr(plant_ids, '__iter__'):
4933 raise ValueError(
4934 f"Plant IDs must be an int or a sequence of ints, got "
4935 f"{type(plant_ids).__name__}")
4936 ids = [cls._validatePlantIdentifier(p) for p in plant_ids]
4937 if not ids:
4938 raise ValueError("Plant ID list must not be empty")
4939 return ids
4940
4941 @staticmethod
4942 def _validateNodeIndex(node_index, name: str = "Node index") -> int:
4943 """Reject a non-int or negative phytomer index."""
4944 if isinstance(node_index, bool) or not isinstance(node_index, int):
4945 raise ValueError(
4946 f"{name} must be a non-negative int, got {type(node_index).__name__}")
4947 if node_index < 0:
4948 raise ValueError(f"{name} must be non-negative")
4949 return int(node_index)
4950
4951 @classmethod
4952 def _validatePetioleIndex(cls, petiole_index, name: str = "Petiole index") -> int:
4953 """Reject a non-int or negative petiole/leaf index."""
4954 return cls._validateNodeIndex(petiole_index, name=name)
4955
4956 @staticmethod
4957 def _validateScaleFactor(scale_factor, name: str = "Scale factor") -> float:
4958 """Reject a non-numeric or non-positive scale factor."""
4959 if isinstance(scale_factor, bool) or not isinstance(scale_factor, (int, float)):
4960 raise ValueError(
4961 f"{name} must be a positive number, got {type(scale_factor).__name__}")
4962 if not scale_factor > 0:
4963 raise ValueError(f"{name} must be positive, got {scale_factor}")
4964 return float(scale_factor)
4965
4966 def is_available(self) -> bool:
4967 """
4968 Check if PlantArchitecture is available in current build.
4969
4970 Returns:
4971 True if plugin is available, False otherwise
4972 """
4974
4975
4976# Convenience function
4977def create_plant_architecture(context: Context) -> PlantArchitecture:
4978 """
4979 Create PlantArchitecture instance with context.
4980
4981 Args:
4982 context: Helios Context
4983
4984 Returns:
4985 PlantArchitecture instance
4986
4987 Example:
4988 >>> context = Context()
4989 >>> plantarch = create_plant_architecture(context)
4990 """
4991 return PlantArchitecture(context)
Raised when PlantArchitecture operations fail.
High-level interface for plant architecture modeling and procedural plant generation.
List[int] getAllLeafUUIDs(self)
Get UUIDs of every leaf primitive in the model.
None clearGrowthFrames(self, int plant_id)
Clear stored growth animation frames for a plant.
None writePlantMeshVertices(self, int plant_id, Union[str, Path] filename)
Write all plant mesh vertices to file for external processing.
None removeShootLeaves(self, int plant_id, int shoot_id)
Remove all leaves from a single shoot.
None setCollisionRelevantOrgans(self, bool include_internodes=False, bool include_leaves=True, bool include_petioles=False, bool include_flowers=False, bool include_fruit=False)
Specify which plant organs participate in collision detection.
None appendAttractionPoints(self, List[vec3] points, Optional[int] plant_id=None)
Add to the current attraction point set.
None pruneBranch(self, int plant_id, int shoot_id, int node_index)
Prune a shoot at a node, removing that node and everything distal to it.
bool is_available(self)
Check if PlantArchitecture is available in current build.
None registerGrowthFrame(self, int plant_id, float min_segment_length=0.001)
Capture a snapshot of the plant's geometry as a growth animation frame.
List[int] _childShootIDsOrEmpty(self, int plant_id, int shoot_id)
Return a shoot's child IDs, or an empty list if it no longer resolves.
bool isPlantDormant(self, int plant_id)
Check whether a plant is dormant.
int addShootFromNodePositions(self, int plant_id, int parent_shoot_id, int parent_node_index, List[vec3] node_positions, List[float] node_radii, str shoot_type_label, Optional[str] growth_shoot_type_label=None, int petiole_index=0)
Add a shoot whose internode geometry is prescribed by measured node positions.
List[int] pruneShootSubtree(self, int plant_id, int shoot_id, bool include_self=True)
Prune a shoot and everything growing off it.
None removeShootVegetativeBuds(self, int plant_id, int shoot_id)
Mark every vegetative bud on a single shoot as dead.
List[int] getPathToRoot(self, int plant_id, int shoot_id)
Get the chain of shoots connecting a shoot to the base stem shoot.
int getShootRank(self, int plant_id, int shoot_id)
Get the branching rank of a shoot.
int _validatePlantIdentifier(plant_id)
Reject a non-int or negative plant ID, returning it as a plain int.
List[int] getPlantLeafObjectIDs(self, int plant_id)
Get object IDs for all leaf objects on a specific plant.
List[int] _pruneShallowest(self, int plant_id, target_shoot_ids)
Prune every target that something shallower has not already removed.
None removePlantLeaves(self, int plant_id)
Remove all leaves from every shoot on a plant.
None setPetioleNodePositions(self, int plant_id, int shoot_id, int node_index, int petiole_index, List[vec3] node_positions, List[float] node_radii)
Prescribe the path of a petiole on an existing phytomer from measured node positions.
None scalePetioleMaxLength(self, int plant_id, int shoot_id, int node_index, float scale_factor)
Scale the fully-elongated length every petiole on a phytomer is growing toward.
None bendPetioleUnderLeafWeight(self, int plant_id, int shoot_id, int node_index, int petiole_index)
Bend one petiole, and the leaves it carries, under the weight of its leaflets.
List[int] getAllFlowerUUIDs(self)
Get UUIDs of every flower primitive in the model.
bool isLeafAngleDistributionTrackingEnabled(self, int plant_id)
Whether a plant's leaf angles are being steered toward a prescribed distribution.
None makePlantDormant(self, int plant_id)
Force a plant into a dormant state immediately.
None disableAttractionPoints(self, Optional[int] plant_id=None)
Stop steering growth toward attraction points.
None advanceTime(self, float dt, Optional[int] plant_id=None, Optional[List[int]] plant_ids=None, Optional[int] years=None)
Advance time for plant growth and development.
List[int] pruneShootsByRank(self, int plant_id, int min_rank)
Prune every shoot at or above a given branching rank.
Dict[int, List[int]] getShootHierarchyMap(self, int plant_id)
Get the parent-to-children structure of a plant.
List[float] getShootInternodeRadii(self, int plant_id, int shoot_id)
Get the per-vertex woody internode radii of a shoot.
None setPlantNitrogenParameters(self, int plant_id, Union[dict, NitrogenParameters] parameters)
Set nitrogen-model parameters for a plant.
List[str] listShootTypeLabels(self, Optional[str] plant_model=None, Optional[int] plant_id=None)
Get the shoot type labels defined for a plant model.
List[int] getAllPlantObjectIDs(self, int plant_id)
Get all object IDs for a specific plant.
int _validatePetioleIndex(cls, petiole_index, str name="Petiole index")
Reject a non-int or negative petiole/leaf index.
List[int] getChildShootIDs(self, int plant_id, int shoot_id)
Get the shoots that grew directly out of a shoot.
int addChildShoot(self, int plant_id, int parent_shoot_id, int parent_node_index, int current_node_number, AxisRotation shoot_base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label, int petiole_index=0)
Add a child shoot at an axillary bud position on a parent shoot.
None disableMessages(self)
Suppress standard output from the plantarchitecture plugin.
None _validate_attraction_points(points)
Reject point sets the native layer would misread or silently ignore.
List[int] buildPlantCanopyFromLibrary(self, vec3 canopy_center, vec2 plant_spacing, int2 plant_count, float age, float germination_rate=1.0, Optional[dict] build_parameters=None)
Build a canopy of regularly spaced plants from the currently loaded library model.
None defineShootType(self, str shoot_type_label, Union[dict, ShootParameters] parameters)
Define a custom shoot type with specified parameters.
_validateNodesAndRadii(node_positions, node_radii, str positions_name, str radii_name)
Validate a measured node path and return it as plain lists for the ctypes layer.
bool isShootPruned(self, int plant_id, int shoot_id)
Report whether a shoot has been pruned away entirely.
List[float] getPlantLeafAreas(self, int plant_id)
Get the built one-sided surface area of every leaf on a plant.
List[int] getTerminalShootIDs(self, int plant_id)
Get the plant's terminal shoots – those carrying no child shoots.
None enableSolidObstacleAvoidance(self, List[int] obstacle_UUIDs, float avoidance_distance=0.5, bool enable_fruit_adjustment=False, bool enable_obstacle_pruning=False)
Enable hard obstacle avoidance for specified geometry.
List[int] getAllDescendantShootIDs(self, int plant_id, int shoot_id)
Get every shoot descending from a shoot.
bool _isPrunedOrGone(self, int plant_id, int shoot_id)
Whether a shoot has been pruned away or no longer resolves at all.
BudState _validateBudState(state)
Coerce a BudState (or its int value) and reject anything else.
None disableLeafAngleDistributionTracking(self, int plant_id)
Stop steering a plant's leaf angles toward a prescribed distribution.
None scaleLeafSizeMax(self, int plant_id, int shoot_id, int node_index, float scale_factor)
Scale the size every leaf on a phytomer is expanding toward, leaving the blades where they are.
List[int] _validatePlantIdList(cls, plant_ids)
Coerce a sequence of plant IDs, rejecting an empty or malformed one.
Dict[int, List[int]] getShootIDsByRank(self, int plant_id)
Group a plant's shoot IDs by branching rank.
List[float] _plantFloatVector(self, str wrapper_fn_name, int plant_id, str description)
Shared body for the per-organ built-geometry queries.
List[float] getPlantLeafInclinations(self, int plant_id)
Get the inclination angle of every leaf on a plant.
float getPlantHeight(self, int plant_id)
Get the height of a plant in meters.
List[float] getPlantInternodeLengths(self, int plant_id)
Get the built length of every internode on a plant.
int getGrowthFrameCount(self, int plant_id)
Get the number of registered growth frames for a plant.
List[int] pruneTerminalShoots(self, int plant_id, int stride=2)
Thin a plant by pruning every stride-th terminal shoot.
None deletePlantInstance(self, int plant_id)
Delete a plant instance and all associated geometry.
List[int] readPlantStructureXML(self, Union[str, Path] filename, bool quiet=False)
Load plant structure from XML file.
None enableMessages(self)
Re-enable standard output from the plantarchitecture plugin.
int getShootDepth(self, int plant_id, int shoot_id)
Get the number of shoots between a shoot and the base stem shoot.
List[int] getAllInternodeUUIDs(self)
Get UUIDs of every internode primitive in the model.
setCancelFlag(self, cancel_flag)
Register an external cancellation flag polled during long plant builds.
None breakPlantDormancy(self, int plant_id)
Break dormancy for all shoots on a plant, returning it to an active state.
List[int] getAllPlantIDs(self)
Get IDs of every plant instance in the model.
List[int] getAllFruitUUIDs(self)
Get UUIDs of every fruit primitive in the model.
None _removeShootOrgans(self, str wrapper_fn_name, int plant_id, int shoot_id, str organ_description)
Shared body for the three shoot-level organ removal methods.
__exit__(self, exc_type, exc_val, exc_tb)
Context manager exit - cleanup resources.
int getPlantLeafCount(self, int plant_id)
Get the number of leaf objects on a plant.
float getPlantAge(self, int plant_id)
Get the current age of a plant in days.
int buildPlantInstanceFromLibrary(self, vec3 base_position, float age, Optional[dict] build_parameters=None)
Build a plant instance from the currently loaded library model.
getCurrentShootParameters(self, str shoot_type_label, bool return_typed=False)
Get current shoot parameters for a shoot type.
None writePlantStructureXML(self, int plant_id, Union[str, Path] filename)
Save plant structure to XML file for later loading.
List[vec3] getPlantLeafBases(self, int plant_id)
Get the attachment base position of every leaf on a specific plant.
None harvestPlant(self, int plant_id)
Harvest a plant by removing its flowers and fruit.
None setStaticObstacles(self, List[int] target_UUIDs)
Mark geometry as static obstacles for collision detection optimization.
List[int] getAllShootIDs(self, int plant_id)
Get the IDs of all shoots belonging to a plant.
None enableGroundClipping(self, float ground_height=0.0)
Enable automatic removal of plant organs that fall below the ground plane.
List[int] getAllPetioleUUIDs(self)
Get UUIDs of every petiole primitive in the model.
List[int] getAllPlantUUIDs(self, int plant_id, bool include_hidden=False)
Get all primitive UUIDs for a specific plant.
List[int] getPlantPeduncleObjectIDs(self, int plant_id)
Get object IDs for all peduncle objects on a specific plant.
List[int] getPlantPetioleObjectIDs(self, int plant_id)
Get object IDs for all petiole objects on a specific plant.
None loadPlantModelFromLibrary(self, str plant_label)
Load a plant model from the built-in library.
None disableCollisionDetection(self)
Disable collision detection for plant growth.
None setPlantPhenologicalThresholds(self, int plant_id, float time_to_dormancy_break, float time_to_flower_initiation, float time_to_flower_opening, float time_to_fruit_set, float time_to_fruit_maturity, float time_to_dormancy, float max_leaf_lifespan=1e6, bool is_evergreen=False)
Set phenological timing thresholds for plant developmental stages.
getDefaultNitrogenParameters(self, bool return_typed=False)
Get a default-constructed set of nitrogen-model parameters.
getDefaultCarbohydrateParameters(self, bool return_typed=False)
Get a default-constructed set of carbohydrate-model parameters.
List[int] getPlantFlowerObjectIDs(self, int plant_id)
Get object IDs for all flower (inflorescence) objects on a specific plant.
int addBaseStemShoot(self, int plant_id, int current_node_number, AxisRotation base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label)
Add a base stem shoot to a plant instance (main trunk/stem).
float getPlantMaxAge(self, int plant_id)
Get the maximum age of a plant, beyond which it stops growing.
int _validateNodeIndex(node_index, str name="Node index")
Reject a non-int or negative phytomer index.
None enableLeafAzimuthAngleDistributionTracking(self, int plant_id, float eccentricity, float ellipse_rotation_degrees, float lambda_degrees)
Steer leaf azimuth toward an ellipsoidal distribution as the plant grows, leaving inclination to the ...
None setPlantCarbohydrateParameters(self, int plant_id, Union[dict, CarbohydrateParameters] parameters)
Set carbohydrate-model parameters for a plant.
None setPetioleLeafGeometry(self, int plant_id, int shoot_id, int node_index, int petiole_index, List[vec3] leaf_bases, List[AxisRotation] leaf_rotations, List[float] leaf_sizes)
Prescribe the base position, orientation and size of every leaf on a petiole.
None setPetioleAndLeafScaleFraction(self, int plant_id, int shoot_id, int node_index, int petiole_index, float petiole_scale_factor_fraction, float leaf_scale_factor_fraction)
Set a petiole's length and its leaves' size together, each as a fraction of its own fully-elongated v...
List[int] getAllObjectIDs(self)
Get object IDs of every plant compound object in the model.
None _validateShootIdentifiers(int plant_id, int shoot_id)
Reject non-int and negative plant/shoot identifiers.
Dict[str, Any] getShoot(self, int plant_id, int shoot_id)
Get a read-only view of a shoot's topology.
List[int] getPlantCollisionRelevantObjectIDs(self, int plant_id)
Get object IDs of collision-relevant geometry for a specific plant.
bool isShootGeometryPrescribed(self, int plant_id, int shoot_id)
Report whether a shoot's existing geometry was prescribed by the caller rather than generated.
None setPetioleScaleFraction(self, int plant_id, int shoot_id, int node_index, int petiole_index, float petiole_scale_factor_fraction)
Set one petiole's current length as a fraction of its fully-elongated length, leaving the leaves it c...
None enableLeafAngleDistributionTracking(self, plant_ids, float beta_mu_inclination, float beta_nu_inclination, float eccentricity, float ellipse_rotation_degrees, float lambda_degrees)
Steer leaf inclination and azimuth toward a prescribed distribution as the plant grows.
None optionalOutputObjectData(self, Union[str, List[str]] object_data_labels)
Enable optional output object data to be written to the Context.
List[int] getShootChildIDs(self, int plant_id, int shoot_id)
Get the child shoot IDs of a shoot (flattened across parent node indices).
None setPlantMaxAge(self, int plant_id, float max_age)
Set the maximum age of a plant, beyond which it stops growing.
int getShootVegetativeBudCount(self, int plant_id, int shoot_id, Optional[BudState] state=None)
Count a shoot's axillary vegetative buds, summed over all phytomers and petioles.
int appendShoot(self, int plant_id, int parent_shoot_id, int current_node_number, AxisRotation base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label)
Append a shoot to the end of an existing shoot.
None updateAttractionPoints(self, List[vec3] points, Optional[int] plant_id=None)
Replace the current attraction point set.
None enableSoftCollisionAvoidance(self, Optional[List[int]] target_object_UUIDs=None, Optional[List[int]] target_object_IDs=None, bool enable_petiole_collision=False, bool enable_fruit_collision=False)
Enable soft collision avoidance for procedural plant growth.
None setLeafNormal(self, int plant_id, int shoot_id, int node_index, int petiole_index, int leaf_index, vec3 target_normal)
Re-aim one leaf so its blade faces a given direction.
None terminateApicalBud(self, int plant_id, int shoot_id)
Stop a shoot's apex from adding any further phytomers.
_check_context_alive(self)
Raise if the owning Context has been destroyed (see Context.check_context_alive).
List[int] getAllUUIDs(self)
Get UUIDs of every plant primitive in the model.
setProgressCallback(self, callback)
Set a callback to receive progress updates during long-running operations.
None writePlantGrowthUSD(self, int plant_id, Union[str, Path] filename, float seconds_per_frame=1.0)
Export all registered growth frames as a time-sampled USD animation file.
List[str] getAvailablePlantModels(self)
Get list of all available plant models in the library.
None setShootInternodeLengthMax(self, int plant_id, int shoot_id, float internode_length_max)
Set the target length of internodes grown at the apex of an existing shoot.
None disablePlantPhenology(self, int plant_id)
Disable phenological progression for a plant.
List[int] getPlantFruitObjectIDs(self, int plant_id)
Get object IDs for all fruit objects on a specific plant.
List[int] _liveChildShootIDs(self, int plant_id, int shoot_id)
Child shoot IDs that have not been pruned away, ascending.
None removeShootFloralBuds(self, int plant_id, int shoot_id)
Kill all floral buds on a single shoot.
None writeQSMCylinderFile(self, int plant_id, Union[str, Path] filename)
Export plant structure in TreeQSM cylinder format.
int addPlantInstance(self, vec3 base_position, float current_age)
Create an empty plant instance for custom plant building.
None recordPetioleRestShape(self, int plant_id, int shoot_id, int node_index, int petiole_index)
Record one petiole's current centerline as its undeformed rest shape.
List[tuple] getShootInternodeVertices(self, int plant_id, int shoot_id)
Get the woody internode polyline vertices of a shoot as a list of (x, y, z) tuples.
__del__(self)
Destructor to ensure C++ resources freed even without 'with' statement.
int getParentShootID(self, int plant_id, int shoot_id)
Get the ID of the shoot a shoot grew from.
None setAttractionParameters(self, float view_half_angle_deg, float look_ahead_distance, float attraction_weight, float obstacle_reduction_factor=0.75, Optional[int] plant_id=None)
Tune how strongly attraction points steer growth.
None setPetioleLeafCount(self, int plant_id, int shoot_id, int node_index, int petiole_index, int leaf_count)
Change the number of leaves (leaflets) on one petiole of an existing phytomer.
float getPlantLeafArea(self, int plant_id)
Get the total leaf area of a plant in m².
float getPetioleLength(self, int plant_id, int shoot_id, int node_index, Optional[int] petiole_index=None)
Current length of a phytomer's petioles, measured along the centerline.
None writePlantStructureUSD(self, int plant_id, Union[str, Path] filename, float elastic_modulus=5e9, float wood_density=800.0, float damping_ratio=0.1, float static_friction=0.5, float dynamic_friction=0.3, float restitution=0.1, float organ_spring_stiffness=10.0, float organ_spring_damping=1.0, float leaf_mass_per_area=0.05, float fruit_mass=0.01, float flower_mass=0.002, int solver_position_iterations=32, float min_segment_length=0.001)
Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
None enableLeafElevationAngleDistributionTracking(self, int plant_id, float beta_mu_inclination, float beta_nu_inclination, float lambda_degrees)
Steer leaf inclination toward a Beta distribution as the plant grows, leaving azimuth to the procedur...
_shootScalarQuery(self, str wrapper_fn_name, int plant_id, int shoot_id, str description)
Shared body for the per-shoot hierarchy accessors.
__init__(self, Context context)
Initialize PlantArchitecture with a Helios context.
List[int] getAllPeduncleUUIDs(self)
Get UUIDs of every peduncle primitive in the model.
float _validateScaleFactor(scale_factor, str name="Scale factor")
Reject a non-numeric or non-positive scale factor.
None setSoftCollisionAvoidanceParameters(self, float view_half_angle_deg=80.0, float look_ahead_distance=0.1, int sample_count=256, float inertia_weight=0.4)
Configure parameters for soft collision avoidance algorithm.
None enableAttractionPoints(self, List[vec3] points, Optional[int] plant_id=None, Optional[float] view_half_angle_deg=None, float look_ahead_distance=0.1, float attraction_weight=0.6)
Steer shoot growth toward a set of target points.
State of a vegetative or floral bud, mirroring the C++ BudState enum.
None _validate_build_parameters(Optional[dict] build_parameters, Optional[str] plant_model)
Reject build parameter keys the loaded plant model will not read.
validate_vec3(value, name, func)
validate_int2(value, name, func)
str _resolve_user_path(Union[str, Path] filepath)
Convert relative paths to absolute paths before changing working directory.
validate_vec2(value, name, func)
PlantArchitecture create_plant_architecture(Context context)
Create PlantArchitecture instance with context.
is_plantarchitecture_available()
Check if PlantArchitecture plugin is available for use.
_plantarchitecture_working_directory()
Context manager that temporarily changes working directory to where PlantArchitecture assets are loca...