![]() |
0.1.33
|
PlantArchitecture provides advanced plant structure and architecture modeling with a comprehensive library of 30 procedural plant models. This plugin enables time-based plant growth simulation, procedural plant generation, and plant community modeling for scientific applications including agriculture, forestry, and ecological research.
The plugin includes pre-built models for major agricultural crops (bean, cowpea, maize, rice, soybean, wheat), fruit trees (almond, apple, olive, walnut), and other plant species with biologically-accurate growth parameters and morphological characteristics.
PlantArchitecture is included in default PyHelios builds. To build explicitly:
PlantArchitecture includes 28 scientifically-validated plant models:
Field Crops:
"bean" - Common bean with climbing growth habit"cowpea" - Cowpea with determinate growth pattern"maize" - Corn with C4 photosynthetic characteristics"rice" - Rice with tillering growth pattern"sorghum" - Sorghum grain crop"soybean" - Soybean with determinate/indeterminate varieties"wheat" - Wheat with tiller developmentTrees:
"almond" - Almond tree with seasonal growth patterns"almond_aldrich" - Almond, Aldrich cultivar"almond_wood_colony" - Almond, wood colony training"apple" - Apple tree with standard varieties"apple_fruitingwall" - Apple fruiting wall (specialized high-density training system)"easternredbud" - Ornamental tree"olive" - Olive tree with Mediterranean characteristics"pistachio" - Pistachio with alternating bearing patterns"walnut" - Walnut tree with complex branchingVegetables:
"asparagus" - Asparagus perennial vegetable crop"butterlettuce" - Lettuce with rosette growth form"capsicum" - Bell pepper with bush growth habit"cherrytomato" - Cherry tomato variant"strawberry" - Strawberry with runner propagation"sugarbeet" - Sugar beet root crop"tomato" - Tomato with determinate/indeterminate growthWeeds:
"bindweed" - Invasive vine species"cheeseweed" - Common weed species"groundcherryweed" - Weed species related to tomato and tomatillo"puncturevine" - Prostrate weed speciesVines and Ornamentals:
"bougainvillea" - Ornamental flowering vine with vibrant bracts"grapevine_VSP" - Grapevine with vertical shoot positioned trellis"grapevine_Wye" - Grapevine with Wye trellis (quadrilateral)Beyond loading a named library model, PyHelios exposes the underlying parameter structures so shoot growth, phenology, and the carbohydrate/nitrogen resource models can be inspected and customized.
pyhelios.plant_architecture_params provides a typed, discoverable mirror of the native nested ShootParameters / PhytomerParameters / LeafPrototype structures (plus the flat CarbohydrateParameters and NitrogenParameters). Random distributions are expressed with RandomParameterFloat / RandomParameterInt (.constant(...), .uniform(...), etc.), and every object round-trips with from_dict() / to_dict().
A shoot type's child shoot types — the labels it can branch into and their probabilities — round-trip through getCurrentShootParameters() / defineShootType() as of helios-core 1.3.84, under the child_shoot_types key. Before that they could be written but not read, so a read-modify-write cycle silently erased a shoot type's branching topology. One case still cannot be expressed: an explicitly empty list, which the native defineChildShootTypes() rejects, leaves whatever the shoot type being replaced already carried.
InflorescenceParameters.inflorescence_maturity_period (helios-core 1.3.85) sets how many days an inflorescence takes to expand from its initial quarter size to full size. Its default of -1 defers to the plant-level fruit-maturity threshold from setPlantPhenologicalThresholds(), which is the right clock for a fruit but not for an inflorescence that finishes elongating long before the fruit it subtends even sets: the maize library model gives its tassel 6 days, roughly two months ahead of the ear's grain fill.
getCurrentShootParameters() returns a plain nested dict by default; pass return_typed=True to get a ShootParameters object. The returned structure surfaces the full phytomer_parameters sub-structure (internode, petiole, leaf, peduncle, inflorescence, and the leaf prototype). defineShootType() accepts either a nested dict or a ShootParameters.
Shoot type labels are species-specific — bean defines unifoliate/trifoliate, almond defines trunk/scaffold/proleptic/sylleptic. There is no generic "stem" type. Rather than guessing, list them:
getAvailablePlantModels() lists the species names those take.
A leaf blade is treated as a cantilever loaded by its own weight: it points along the shoot's growing direction while it is small and stiff, and bends over as it grows and its self-weight moment increases. Five LeafPrototype parameters control this.
| Parameter | Default | Effect |
|---|---|---|
flexibility | 0.0 | Dimensionless bending compliance. 0 is a rigid blade; larger values droop more for the same size. |
flexibility_taper | 1.0 | How much more compliant the blade is at its tip than at its base. 1 is uniform stiffness; roughly 10-150 gives the straight-base/curved-tip shape of a grass blade. |
flexibility_aging | 0.0 | Timescale in days over which a mature blade keeps softening after it has stopped growing. 0 disables ageing, so droop follows from leaf size alone. |
flexibility_aging_max | 4.0 | Ceiling on the ageing multiplier, so an old leaf cannot hang straight down. |
longitudinal_curvature_exponent | 4.0 | How the longitudinal curvature is distributed along the blade. Around 2 gives a continuously arcing blade; the tip deflection itself is unchanged by this. |
The petiole (including the rachis of a compound leaf) is bent as a tapered cantilever clamped at its insertion, loaded by the weight of the leaflets attached along it. Two PetioleParameters fields control it, and they are independent of the LeafPrototype parameters above: those bend the blade under its own weight, these bend the stalk that carries it.
| Parameter | Default | Effect |
|---|---|---|
flexibility | 0.0 | Dimensionless bending compliance. 0 keeps the petiole at the shape it was created with; larger values arch it further toward the ground for the same leaflet load. Normalized so that a straight, horizontal, untapered petiole carrying its full-grown leaf weight at the tip turns through this many radians from base to tip, independent of its length. |
flexibility_aging | 0.0 | Timescale in days over which the compliance grows with the phytomer's age: the effective compliance is flexibility * (1 + age / flexibility_aging). A petiole therefore goes on lowering after its leaf has stopped growing, bounded only by the geometry of hanging straight down. 0 disables ageing. |
The insertion stays clamped, so the petiole keeps leaving the stem at its generated pitch and the droop appears beyond it as curvature along the length. Bending is inextensible: it shortens the base-to-tip chord but leaves the centerline arclength alone, so getPetioleLength() reports the same value for a drooping petiole as for a rigid one of the same age.
Growth drives the bend automatically. Call it yourself only after changing a petiole's geometry by hand:
| Method | Purpose |
|---|---|
bendPetioleUnderLeafWeight(plant_id, shoot_id, node_index, petiole_index) | Re-bend one petiole for its leaves' current size and its own age |
recordPetioleRestShape(plant_id, shoot_id, node_index, petiole_index) | Record the petiole's current centerline as the undeformed shape the bend starts from |
The bend is always computed from the recorded rest shape rather than the current shape, so repeated calls cannot accumulate and creep the petiole downward. A petiole whose centerline was replaced wholesale — by setPetioleNodePositions(), for instance — therefore needs recordPetioleRestShape() before it will droop from its new shape. Both are no-ops for a rigid petiole, one whose centerline was prescribed, or one carrying a leaf posed by setPetioleLeafGeometry().
leaf_buckle_length and leaf_buckle_angle are deprecated as of helios-core 1.3.84. They bent a leaf by a fixed angle at a fixed station along its length to approximate the same self-weight droop that is now modelled directly. Existing code keeps working — a buckle value is converted to an equivalent flexibility — but only while flexibility is left at zero, so setting both means the buckle pair is ignored. Setting either to a non-zero value raises a DeprecationWarning. Set flexibility instead.A custom shoot type only affects plants you assemble yourself. buildPlantInstanceFromLibrary() calls a hard-coded builder for the species that uses that species' own shoot types, so it ignores defineShootType() entirely — including a redefinition of an existing label such as "trunk". To build geometry that actually uses your parameters, use addPlantInstance() followed by addBaseStemShoot() with your shoot type label:
Growing a custom-built plant destroys its geometry. Calling
advanceTime()on a plant assembled withaddBaseStemShoot()deletes its leaves and petioles. Build the plant at the age you want and query it directly, rather than building young and growing it forward.
setPlantPhenologicalThresholds() controls the timing of the developmental stages. Pass is_evergreen=True for species that retain leaves through dormancy rather than shedding them at senescence:
Note that max_leaf_lifespan is the eighth parameter and is_evergreen the ninth; passing a boolean positionally in the eighth slot silently sets the leaf lifespan instead.
A plant that never has setPlantPhenologicalThresholds() called on it — one built through the manual API, or restored from a plant structure XML file written before phenology was recorded — schedules no phenology at all. It grows without entering dormancy and without flower or fruit stages, rather than being defoliated. disablePlantPhenology(plant_id) puts a plant back into that state explicitly, which is useful for a plant that had thresholds set earlier:
Avoid calling it on a plant that already has fruiting buds: helios-core sets dd_to_fruit_maturity to -1 here rather than to the 1e6 used for the no-phenology default, and that field is a divisor in the fruit-growth branch of advanceTime(), so a later time step can compute a negative fruit scale factor. A plant that never had thresholds set is already in the no-phenology state and does not need this call.
Phenological thresholds are written to and read back from plant structure XML by writePlantStructureXML() and readPlantStructureXML(), so a restored plant keeps the timing it was built with. The tags are optional on read, so files written before they existed still load and fall back to scheduling no phenology.
setPlantMaxAge() sets the age in days beyond which advanceTime() stops advancing a plant and its geometry becomes static. getPlantMaxAge() reads it back.
The default is 999 days. Every plant model in the library sets its own value as part of its builder, but a plant assembled manually with addPlantInstance() keeps the default and so silently stops growing after 999 days — a long simulation that appears to plateau for no reason is usually this. Setting a maximum age below the plant's current age is permitted and freezes the plant at its current form.
getDefaultCarbohydrateParameters() and getDefaultNitrogenParameters() return the C++ default-constructed template (a flat dict, or a typed object with return_typed=True) to modify and apply to a plant instance via setPlantCarbohydrateParameters() / setPlantNitrogenParameters(). The native API has no per-plant getter for these, so the get methods return the default template rather than the values currently in effect on a specific plant.
See docs/examples/plantarch_phytomer_parameters_sample.py for a complete, runnable example.
Reproductive organs appear later than you may expect. Maize sets its ears at day 58, so a 45-day canopy like the one above is vegetative and
getPlantFruitObjectIDs()returns an empty list. Build at 60 days or later to get fruit. Other species have their own thresholds — checksetPlantPhenologicalThresholds()for the model you are using.
getPlantLeafObjectIDs() returns the object ID of every leaf on a plant, and getPlantLeafBases() returns each leaf's attachment base position (where it joins its petiole, not the leaf centroid).
Do not pair the two results positionally.
getPlantLeafObjectIDs()andgetPlantLeafBases()are built by independent traversals of the shoot tree, so element i of one is not guaranteed to describe the same leaf as element i of the other. helios-core keeps an internalgetPlantLeafObjectIDsAndBases()that gathers both in a single traversal for exactly this reason, but it is protected and not reachable from PyHelios. If you need the correspondence, derive the position from the object ID instead — e.g. viacontext.getObjectPrimitiveUUIDs(leaf_id)and the primitive vertices.
Four further getters cover the remaining organ types, all with the same signature and return type:
| Method | Organ |
|---|---|
getPlantLeafObjectIDs(plant_id) | Leaves |
getPlantPetioleObjectIDs(plant_id) | Petioles (stalks attaching leaves to the stem) |
getPlantPeduncleObjectIDs(plant_id) | Peduncles (stalks bearing flowers and fruit) |
getPlantFlowerObjectIDs(plant_id) | Flowers / inflorescences |
getPlantFruitObjectIDs(plant_id) | Fruit |
Each returns object IDs that are a subset of getAllPlantObjectIDs(), and the five sets are mutually disjoint — no object is both a leaf and a fruit — so they can be used to partition a plant by organ type.
An empty list is a normal result, not a failure. The reproductive organs — peduncles, flowers and fruit — exist only once a plant reaches the corresponding growth stage, so a plant built at a young age returns
[]for them. Flowers additionally disappear as they set fruit, so a mature plant can legitimately report many fruit and no flowers. If you expect an organ and get none, advance the plant further withadvanceTime()rather than treating the empty list as an error.
Different plant models have unique biological characteristics:
PlantArchitecture can cut plants after they have been built: removing branches, stripping leaves, harvesting fruit, and killing buds so an axis stops producing new growth. These are the same operations the Helios plant library uses internally to shape trained architectures such as VSP grapevine and espalier apple. Pruned plants keep growing normally when advanceTime() is called afterwards.
| Method | Effect |
|---|---|
pruneBranch(plant_id, shoot_id, node_index) | Cut a shoot at a node, removing that node, everything distal to it, and every child shoot attached at or above it |
harvestPlant(plant_id) | Remove all flowers and fruit from a plant. Leaves are not removed |
removePlantLeaves(plant_id) | Remove all leaves from every shoot on a plant |
removeShootLeaves(plant_id, shoot_id) | Remove all leaves from one shoot |
removeShootVegetativeBuds(plant_id, shoot_id) | Mark a shoot's vegetative buds dead, so it can no longer throw new laterals. Despite the name nothing is removed – the buds stay in place with BudState.DEAD |
removeShootFloralBuds(plant_id, shoot_id) | Kill a shoot's floral buds, deleting its flower and fruit geometry |
terminateApicalBud(plant_id, shoot_id) | Stop a shoot's apex adding phytomers. Its vegetative buds are unaffected |
getShootVegetativeBudCount(plant_id, shoot_id, state=None) | Count a shoot's vegetative buds, optionally filtered by BudState |
pruneBranch() cuts at a node index within a shoot. Node 0 removes the whole shoot; a higher index heads the shoot back and keeps the nodes below the cut.
The cut is recursive: pruning a shoot also removes every shoot descended from it, so there is no need to walk the branch system yourself.
Note: A pruned shoot currently keeps its ID in
getAllShootIDs()with anode_countof 0 rather than disappearing. Traverse withgetShoot()and treatnode_count == 0as "nothing left here" rather than relying on either behavior.
harvestPlant() removes reproductive organs only. This matches the C++ implementation; note that the upstream Helios documentation for harvestPlant incorrectly states that it also removes leaves.
Stripping leaves and killing buds on a shoot is how the plant library builds trunks, cordons and canes that stay bare:
A plant rebuilt from measured geometry – a QSM, a digitized skeleton – carries buds on every node of every branch. Growing it forward without intervention breaks all of them at once, so a fresh flush appears along the whole interior of the tree instead of only at last year's growth.
Two independent operations control this, and both are needed:
terminateApicalBud() kills only the apical meristem, so the shoot stops adding nodes at its tip but its axillary buds are untouched. removeShootVegetativeBuds() does the reverse: it marks every vegetative bud BudState.DEAD – dead buds are skipped when dormancy breaks – while leaving the apex free to extend. Neither implies the other.
Before relying on the result, check that some live buds survived, or the plant cannot grow at all:
Count live buds, not dead ones. BudState.DEAD marks both buds that were killed and buds that have already broken into a child shoot, so a dead-bud count is not a count of what you killed. Buds are never erased from a shoot – only their state changes – so the unfiltered count stays constant and makes a stable denominator.
These queries walk a plant's branching structure. All of them omit shoots that have been pruned away.
| Method | Returns |
|---|---|
getParentShootID(plant_id, shoot_id) | ID of the shoot this one grew from, or -1 for the base stem |
getShootRank(plant_id, shoot_id) | Botanical branching order (base stem is 0) |
getShootDepth(plant_id, shoot_id) | Number of steps through the shoot tree to the base stem |
getPathToRoot(plant_id, shoot_id) | Shoot IDs from this shoot to the base stem, inclusive |
getChildShootIDs(plant_id, shoot_id) | Direct children, ordered by the node they attach to |
getAllDescendantShootIDs(plant_id, shoot_id) | Every shoot descended from a shoot, depth-first, excluding the shoot itself |
getShootIDsByRank(plant_id) | Dict mapping branching rank to the shoot IDs at that rank |
getShootHierarchyMap(plant_id) | Dict mapping each shoot with children to those children |
getTerminalShootIDs(plant_id) | Shoots carrying no child shoots |
isShootPruned(plant_id, shoot_id) | Whether a shoot was pruned away entirely |
Rank is not depth. A shoot created by appendShoot() continues its parent's axis rather than branching from it, so it keeps the parent's rank while its depth increases. Use getShootRank() for botanical branching order and getShootDepth() for distance through the shoot tree.
Pruned shoots keep their IDs. pruneBranch() with node_index=0 empties a shoot but leaves it in the plant's tree so that shoot IDs stay stable. Such a shoot is still returned by getAllShootIDs() but has no geometry and cannot be queried for taper, so filter it out when iterating:
A shoot built with addBaseStemShoot(), appendShoot() or addChildShoot() is extrapolated from its base rotation and the shoot type's curvature and tortuosity, so it cannot follow a measured path. helios-core 1.3.85 adds three methods that build organs from caller-supplied geometry instead, for reconstructing a plant from a QSM, a digitized skeleton, photogrammetry, or a segmented point cloud.
| Method | Effect |
|---|---|
addShootFromNodePositions(plant_id, parent_shoot_id, parent_node_index, node_positions, node_radii, shoot_type_label, growth_shoot_type_label=None, petiole_index=0) | Build one continuous shoot through the supplied internode node positions; parent_shoot_id=-1 starts a base stem |
setPetioleNodePositions(plant_id, shoot_id, node_index, petiole_index, node_positions, node_radii) | Prescribe the centerline of one petiole on an existing phytomer |
setPetioleLeafGeometry(plant_id, shoot_id, node_index, petiole_index, leaf_bases, leaf_rotations, leaf_sizes) | Prescribe the base position, orientation and size of every leaf on a petiole |
setPetioleLeafCount(plant_id, shoot_id, node_index, petiole_index, leaf_count) | Change the number of leaves (leaflets) on one petiole, rebuilding them procedurally |
setShootInternodeLengthMax(plant_id, shoot_id, internode_length_max) | Target length of internodes grown at the apex of an existing shoot |
isShootGeometryPrescribed(plant_id, shoot_id) | Whether a shoot was built from prescribed node positions |
helios-core 1.3.87 adds per-phytomer control over what an organ is growing toward, which is what hands an organ built from measured geometry back to the growth model at the size it was measured.
| Method | Effect |
|---|---|
getPetioleLength(plant_id, shoot_id, node_index, petiole_index=None) | Current petiole arclength (m); the phytomer mean when petiole_index is omitted |
scalePetioleMaxLength(plant_id, shoot_id, node_index, scale_factor) | Scale the fully-elongated length every petiole on the phytomer grows toward, leaving present lengths alone |
setPetioleScaleFraction(plant_id, shoot_id, node_index, petiole_index, fraction) | Set one petiole's length as a fraction of fully elongated, leaving its leaves' size alone |
setPetioleAndLeafScaleFraction(plant_id, shoot_id, node_index, petiole_index, petiole_fraction, leaf_fraction) | Set petiole length and leaf size together, each as its own fraction, in one pass |
scaleLeafSizeMax(plant_id, shoot_id, node_index, scale_factor) | Scale the size every leaf on the phytomer is expanding toward, leaving the blades where they are |
setLeafNormal(plant_id, shoot_id, node_index, petiole_index, leaf_index, target_normal) | Re-aim one leaf's blade at a world-space direction |
getPetioleLength() reports the length right now, not the mature length the petiole is growing toward, so it rises as the petiole elongates — the opposite convention to the leaf readers, which report the size a leaf is expanding toward. It is an arclength rather than a base-to-tip distance. Petioles at one node are parallel structures rather than segments in series, so their lengths are not additive and the no-index form returns their mean; a phytomer with no petiole reports 0.0.
A petiole elongates on its shoot's internode rate rather than the leaf expansion rate, which is why setPetioleScaleFraction() and the leaf scale fraction are separate knobs. Use setPetioleAndLeafScaleFraction() when advancing both, so the leaves are scaled, re-seated along the rescaled petiole and re-bent once rather than twice.
scaleLeafSizeMax() moves the target and leaves the blade alone, so the expansion fraction moves the other way: a fully-expanded leaf given a larger target becomes a partly-expanded leaf of the same size and goes on growing on the next advanceTime(). It differs from scaleLeafPrototypeScale(), which rescales the blade itself and leaves the fraction alone.
scaleLeafSizeMax() does move the blade: the leaf is taken down to the new target, and a compound leaf's leaflets are then re-seated along the petiole, discarding a placement prescribed by setPetioleLeafGeometry(). Raising the target — the case the method exists for — never re-seats anything.setLeafNormal() records the roll and pitch it solved for on the phytomer, which is what makes the new orientation survive a writePlantStructureXML() / readPlantStructureXML() round trip. Rotating the leaf object directly through the Context changes the geometry without changing that record, and is silently lost on reload.
setPetioleLeafCount() exists because the leaflet count is otherwise fixed by the shoot type's leaf.leaves_per_petiole for every phytomer, so a measured compound leaf with a different number of leaflets could not be prescribed. Call it before setPetioleLeafGeometry() for the same petiole, whose leaf_count must match the number of leaves on the petiole.
setShootInternodeLengthMax() governs how a shoot grows after its prescribed portion: a shoot built by addShootFromNodePositions() otherwise grows toward the mean of its prescribed internode lengths, which is wrong for a measured seedling whose stem is mostly hypocotyl. Note that this value is not saved by writePlantStructureXML(), so it must be set again after readPlantStructureXML().
setPetioleLeafGeometry() now applies roll, pitch and yaw as intrinsic rotations in the leaf's rest frame on its petiole, the same way for every leaf. Previously they went through the procedural placement chain, so a single leaf's roll flipped sign on alternate nodes, a lateral leaflet's roll was mirrored by side, a terminal leaflet ignored its roll and yaw, and yaw turned about the world vertical. Angles fitted or tuned against an earlier release must be re-derived.N+1 node positions define N phytomers, rendered as a single tube, with the usual buds, petioles and leaves. Prescribed geometry is created fully elongated and is left alone by advanceTime(): measured internodes are not re-scaled or re-curved, prescribed petioles are not stretched, and prescribed leaves are exempt from expansion and from self-weight droop. Growth continues normally from the tip of the last measured internode, and prescribed radii act as a lower bound that the pipe model may thicken but never thins.
Separate growth type. Building measured wood calls for zero curvature and tortuosity, a node cap at least as large as the longest measured branch, and often a girth area factor of zero so the measured radii are kept. None of those describe how the plant should grow afterwards: a shoot inheriting them extends perfectly straight and never reaches its node cap. Pass growth_shoot_type_label to take the node caps, the gravitropic curvature of new phytomers, and the type of the shoots the buds produce from a different shoot type. girth_area_factor and bud-break probability are deliberately still read from the build type, which also remains the label the shoot reports. The XML round trip carries the growth type across.
Library species scale young phytomers. The prescribed geometry is protected from the growth model, not from the shoot type's phytomer-creation hook, which runs on every phytomer after it is built. Bean's hook, for example, scales each new internode by min(1, 0.2 + 0.8 * age / 10), so a reconstruction on a plant created at age 0 comes out at a fifth of its measured size. Create the plant instance at an age where the hook's scale is 1 (10 days for bean), or build on a shoot type of your own defined with defineShootType(), which carries no hook.
XML round trip (helios-core 1.3.85). writePlantStructureXML() records the prescribed node positions and radii, and readPlantStructureXML() rebuilds the shoot from them, so the measured shape is reproduced exactly. The phytomer count is not: the writer emits the subdivided node list (one node per internode.length_segments), and the reader creates one phytomer per written segment, so a reloaded shoot has length_segments times as many phytomers, each correspondingly shorter, and a prescribed child shoot fails to reload because its saved parent_node_index no longer names the node it was attached to. The two coincide only when length_segments is 1.
Units and frames. leaf_rotations are AxisRotation(pitch, yaw, roll) in radians, relative to the petiole and internode axes rather than to world axes. The chain that places a leaf includes the petiole's own azimuth and a size-dependent correction and is not invertible, so there is no exact conversion from a world-frame blade orientation; fit by forward evaluation, reading the built geometry back from the Context. The number of leaves on a petiole is fixed when the phytomer is created (leaves_per_petiole on the shoot type); supplying a different number raises an error rather than adding or removing leaves. Rebuilding a leaf discards primitive data attached to it, except the object label and material.
getCurrentShootParameters() reports a shoot type: the distributions a parameter is drawn from. These queries report what the plant was actually built with, one entry per organ, measured from the geometry in the Context. The distinction matters for calibration because a random parameter caches its first draw and a shoot holds a copy of its type's parameters, so a plant can be built with no variation at all while its parameters describe a wide spread.
| Method | Returns |
|---|---|
getPlantLeafAreas(plant_id) | Present one-sided area (m²) of each leaf, shoot by shoot then phytomer by phytomer (the order of getPlantLeafObjectIDs()); leaves without geometry are omitted |
getPlantInternodeLengths(plant_id) | Length (m) of each internode along its built node positions, one per phytomer |
getPlantLeafInclinations(plant_id) | Angle (degrees) between each blade and the horizontal from its area-weighted normal, folded to [0, 90]; blades whose facet normals cancel are omitted |
helios-core 1.3.87 steers a plant's leaf angles toward a prescribed distribution as it grows. Each leaf is given a target as it emerges and turns onto it while it expands, so a fully grown leaf never moves again: the plant matches the distribution at every stage without the leaves shifting about from one timestep to the next.
| Method | Effect |
|---|---|
enableLeafAngleDistributionTracking(plant_ids, beta_mu, beta_nu, eccentricity, ellipse_rotation_degrees, lambda_degrees) | Steer both inclination and azimuth; accepts one plant ID or a sequence |
enableLeafElevationAngleDistributionTracking(plant_id, beta_mu, beta_nu, lambda_degrees) | Steer inclination only, leaving azimuth to the model |
enableLeafAzimuthAngleDistributionTracking(plant_id, eccentricity, ellipse_rotation_degrees, lambda_degrees) | Steer azimuth only, leaving inclination to the model |
disableLeafAngleDistributionTracking(plant_id) | Stop steering |
isLeafAngleDistributionTrackingEnabled(plant_id) | Whether a plant is being steered |
Inclination follows a Beta distribution whose mean is (pi/2) * beta_nu / (beta_mu + beta_nu), so a large beta_nu gives an erectophile canopy and a large beta_mu a planophile one. Azimuth follows an ellipsoidal distribution set by eccentricity (0 is uniform) and the ellipse's rotation.
lambda_degrees trades filling the distribution against keeping each leaf near the angle the procedural model gave it. Targets are deliberately not drawn independently per leaf, which would reproduce the distribution while destroying the arrangement the model generated: each emerging leaf takes the bin that minimizes its angular distance from the model's angle, minus lambda_degrees times how far that bin sits below its share of the plant's leaf area. Zero leaves the plant unchanged; values of order 180 match the distribution as closely as the growing plant allows.
Passing a list realizes the distribution over the canopy as a whole, so an individual plant within it need not follow the distribution on its own:
setPlantLeafAngleDistribution() re-aims every leaf of a finished plant in one shot. Enabling tracking on a plant already being tracked replaces the target, so the target may be varied over the plant's life. Disabling it leaves already-steered leaves at the orientation they reached; leaves emerging afterward are left where the procedural model puts them.The matching CDFs are available from pyhelios.Global for laying out a prescribed distribution yourself, or for checking one a canopy actually realized:
| Function | Returns |
|---|---|
Global.evaluateBetaDistributionCDF(theta, mu, nu) | Probability that a Beta-distributed inclination is at most theta (radians from vertical) |
Global.invertBetaDistributionCDF(probability, mu, nu) | Inclination (radians, in [0, pi/2]) at a given cumulative probability |
Global.evaluateEllipsoidalAzimuthCDF(phi, e, phi0_degrees) | Probability that an ellipsoidal azimuth is at most phi |
Global.invertEllipsoidalAzimuthCDF(probability, e, phi0_degrees) | Azimuth (radians, in [0, 2*pi)) at a given cumulative probability |
theta saturates outside [0, pi/2] rather than erroring, while mu/nu must be positive and probability/e must lie in [0, 1].
Three convenience methods apply pruneBranch() across a branch system. Each returns the list of shoot IDs it actually cut, and each cuts only the shallowest shoot on every pruned axis – pruneBranch() recursion removes the rest, so no shoot is cut twice.
| Method | Effect |
|---|---|
pruneShootsByRank(plant_id, min_rank) | Remove every shoot at or above a branching rank. min_rank must be at least 1 |
pruneShootSubtree(plant_id, shoot_id, include_self=True) | Remove a branch system; with include_self=False the shoot is kept and only its children are cut |
pruneTerminalShoots(plant_id, stride=2) | Thin the canopy by cutting every stride-th tip |
min_rank=0 is rejected, because cutting rank 0 destroys the plant. To remove a whole plant use deletePlantInstance(); to cut the base stem deliberately, call pruneBranch() directly.
Pruning also happens on its own during advanceTime() when it is configured:
enableGroundClipping(ground_height) removes organs that grow below the ground planeenableSolidObstacleAvoidance(uuids, ..., enable_obstacle_pruning=True) removes organs that penetrate solid obstaclesBuild the plant first, then enable collision. Collision hooks run while each phytomer is constructed, so enabling avoidance before
buildPlantInstanceFromLibrary()changes how the initial geometry is assembled. Only growth that happens after the call is steered.
PlantArchitecture integrates advanced collision detection capabilities to enable realistic plant growth that responds to obstacles and other plants. The collision detection system uses cone-based ray tracing to guide plant growth away from obstacles while maintaining natural plant architecture.
The collision detection system provides two primary modes:
Both modes use a "perception cone" at the shoot apex to detect obstacles and guide growth direction. Ray-tracing calculations determine objects within the cone's field of view, allowing the plant to react appropriately.
The perception cone is the fundamental mechanism for collision detection. Key parameters control its behavior:
Soft collision avoidance guides plant growth to minimize collisions while maintaining natural architecture. Growth direction is adjusted toward the largest gap detected within the perception cone.
Basic Usage:
Customized Parameters:
Target-Specific Collision Detection:
Hard obstacle avoidance strictly prevents plant growth through solid boundaries. When an obstacle is detected within the avoidance distance, growth is redirected perpendicular to the obstacle surface.
Basic Usage:
With Fruit Adjustment:
Collision detection can be computationally expensive. Several optimization techniques improve performance:
Mark non-moving geometry as static to enable BVH (Bounding Volume Hierarchy) optimization:
Key Points:
setStaticObstacles() AFTER enableSoftCollisionAvoidance()Selectively include organ types in collision detection to balance accuracy and performance:
Default Configuration:
Recommendations:
Retrieve which objects are participating in collision detection for visualization or debugging:
Turn off collision detection when not needed:
Realistic scenario combining all collision detection features:
Collision detection computational cost scales with:
Performance Tips:
Example Performance Settings:
Pattern 1: Dense Canopy with Self-Avoidance
Pattern 2: Greenhouse with Infrastructure
Pattern 3: Field with Ground Clipping
Problem: Plants still collide despite collision detection
enableSoftCollisionAvoidance() was called before advanceTime()sample_count for better detection accuracyinertia_weight for more responsive avoidanceProblem: Poor performance with collision detection
setStaticObstacles()sample_count (try 128 or 64)view_half_angle_deg (try 60° or 50°)advanceTime()Problem: setStaticObstacles() fails
enableSoftCollisionAvoidance() is called FIRSTProblem: Unnatural plant growth patterns
inertia_weight for smoother growth (try 0.5-0.6)look_ahead_distance to react to closer obstacles onlyComplete working examples are available in docs/examples/:
plantarch_collision_sample.py: Comprehensive collision detection examples including:Run the examples:
PlantArchitecture provides comprehensive file I/O capabilities to save and load plant structures, export geometry for external processing, and integrate with biomechanical analysis tools. These features enable plant structure persistence, library creation, and interoperability with other software.
The following file I/O methods are available:
writePlantStructureXML(): Save complete plant structure to XML for later loadingreadPlantStructureXML(): Load saved plant structures from XML fileswritePlantMeshVertices(): Export all mesh vertices for external processingwriteQSMCylinderFile(): Export to TreeQSM format for biomechanical analysiswritePlantStructureUSD(): Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics simulationregisterGrowthFrame() / writePlantGrowthUSD() / clearGrowthFrames() / getGrowthFrameCount(): Capture and export per-step growth snapshots as a time-sampled USD animation file (importable into Blender)All methods work with both string paths and pathlib.Path objects, and correctly handle relative/absolute paths.
XML format preserves complete plant architecture including shoot structure, organ properties, and growth state. This enables:
Basic Usage:
Important Notes:
loadPlantModelFromLibrary()) before calling readPlantStructureXML()quiet=True parameter to suppress console output during loadingQuiet Mode:
Export all vertex coordinates from plant geometry for external processing such as:
Basic Usage:
Output Format:
x y zExample Applications:
Export plant structure in TreeQSM (Quantitative Structure Model) format for biomechanical analysis and structural modeling. TreeQSM is widely used in forestry and biomechanics research.
Basic Usage:
TreeQSM Format Details:
The exported file contains tab-separated values with the following information for each cylinder:
Reference: Raumonen et al. (2013) "Fast Automatic Precision Tree Models from Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
Use Cases:
Export the plant structure as a PhysX articulation in USDA (ASCII USD) format for NVIDIA IsaacSim physics simulation. Each tube segment becomes a capsule-shaped rigid link connected by spherical joints whose local frames encode the rest-pose orientation. Spring/damper drives are derived from beam bending stiffness (K = E*I/L). Leaves, fruits, and flowers are represented as mass bodies attached by spring links.
Basic Usage:
Capture per-step plant geometry snapshots during a growth simulation and export them as a time-sampled USDA animation that imports directly into Blender. Organs that appear during growth are toggled visible at the appropriate frame.
Basic Usage:
This export is visual-only — no physics prims, joints, or collision shapes are written. For physics simulation, use writePlantStructureUSD() instead.
All file I/O methods accept both string paths and pathlib.Path objects, and correctly handle relative/absolute paths while preserving the user's working directory.
Using pathlib.Path:
Path Features:
pathlib.Path and string pathsSave plants at different growth stages to build reusable libraries:
Using the Library:
Save and load entire canopies for complex scene persistence:
Workflow 1: Growth Time Series
Workflow 2: External Analysis Pipeline
File I/O operations include comprehensive error handling:
Common Errors:
ValueError: Invalid parameters (negative plant ID, empty filename)PlantArchitectureError: File operation failed (permissions, missing file, invalid XML)readPlantStructureXML() (must call loadPlantModelFromLibrary() first)Complete working examples are available in docs/examples/:
plantarch_file_io_sample.py: Comprehensive file I/O examples including:Run the examples: