![]() |
0.1.39
|
PlantArchitecture provides advanced plant structure and architecture modeling with a comprehensive library of 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 the following plant models (getAvailablePlantModels() returns the same list at runtime):
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_independence" - Almond, Independence 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)Library changes in helios-core 1.3.93 that affect code customizing these models:
apple and apple_fruitingwall have a new "spur" shoot type, which grows from some of the lateral buds on year-old wood and ends each year in a flower cluster; most fruit is now borne on spurs. Flower buds form according to the light the node received the previous summer (see getSeasonPeakLightExposureAtPoint()), so fruit is concentrated in the outer and upper crown.grapevine_VSP and grapevine_Wye fruit cluster was rebuilt as a conical Cabernet Sauvignon cluster built in meters, so their inflorescence.fruit_prototype_scale is now 1 (previously 0.04).sorghum panicle is now procedural (see Sorghum panicle below) and built in meters, so its inflorescence.fruit_prototype_scale is now 1 (previously 0.18).maize, rice, sorghum and wheat have no petiole (see Leaves without a petiole below). Their leaf angle is now set by leaf.pitch, not petiole.pitch, so code that changed the leaf angle of these models through the petiole must change leaf.pitch instead.Custom shoot parameters that set the old fruit_prototype_scale values must be updated.
Some features of a library plant are specific to its species and have no place in the generic shoot and phytomer parameters: the dimensions of a grapevine trellis, the trunk height and scaffold layout of an orchard tree, the size of grape berries and clusters, the number of tillers of a sorghum plant, or the form and color of a sorghum panicle. These are set per plant by passing build_parameters (a dict of name to number) to buildPlantInstanceFromLibrary() or buildPlantCanopyFromLibrary(), so plants of different genotypes can be built side by side. getPlantModelBuildParameterNames(plant_model) lists the names a model accepts (the model need not be loaded); models not listed below accept none.
Tree species (almond, apple, walnut, pistachio)
| Model | Parameter | Default | Range | Meaning |
|---|---|---|---|---|
almond, almond_independence | trunk_height | 0.78 | 0.1-3 m | Trunk height |
num_scaffolds | 4 | 2-8 | Number of scaffold branches | |
scaffold_angle | 52 | 20-70° | Scaffold branch angle from vertical | |
apple | trunk_height | 0.8 | 0.1-3 m | Trunk height |
num_scaffolds | 4 | 2-8 | Number of scaffold branches | |
scaffold_angle | 40 | 20-70° | Scaffold branch angle from vertical | |
pistachio | trunk_height | 1.0 | 0.1-3 m | Trunk height |
num_scaffolds | 4 | 2-8 | Number of scaffold branches, attached in opposite pairs down the top of the trunk | |
scaffold_angle | 57 | 20-70° | Scaffold branch angle from vertical | |
walnut | trunk_height | 0.8 | 0.1-3 m | Trunk height |
num_scaffolds | 4 | 2-8 | Number of scaffold branches | |
scaffold_angle | 40 | 25-60° | Scaffold branch angle from vertical |
Grapevines (grapevine_VSP, grapevine_Wye)
| Model | Parameter | Default | Range | Meaning |
|---|---|---|---|---|
grapevine_VSP | trunk_height | 0.8 | 0.05-1 m | Height of the fruiting wire |
vine_spacing | 2.4 | 0.5-5 m | Plant-to-plant spacing along the row | |
grapevine_Wye | trunk_height | 1.4 | 0.5-2 m | Height of the two cordon wires |
cordon_spacing | 0.6 | 0.2-2 m | Distance across the row between the two cordon wires | |
vine_spacing | 1.8 | 0.5-5 m | Plant-to-plant spacing along the row; each cordon spans half of it | |
catch_wire_height | trunk_height + 0.6 | 0.5-4 m | Height of the top gable wires; must exceed trunk_height | |
| both | berry_diameter | 0.012 | 0.004-0.03 m | Berry diameter; berry size varies by about 7% between berries |
cluster_berry_count | 90 | 10-400 | Mean number of berries in a cluster; each cluster's count varies by up to 17% either side of it |
A grape cluster's size follows from its berries: it grows in proportion to berry diameter and with the cube root of berry count, so the defaults give a cluster about 12 cm long (Cabernet Sauvignon).
Sorghum (sorghum)
| Parameter | Default | Range | Meaning |
|---|---|---|---|
tiller_count | 0 | 0-6 | Number of tillers (see Sorghum tillering below) |
panicle_type | 0.5 | 0-1 | Panicle preset: 0 compact, 0.5 semi-compact, 1 loose |
The sorghum model also accepts the panicle form, grain and glume keys listed under Sorghum panicle below.
As of helios-core 1.3.90 the grapevine_Wye row runs along the y-axis, as for grapevine_VSP (it previously ran along x), and its trunk_height is the height of the cordon wires rather than the trunk length (previously 0.165 m by default, range 0.05-1 m). Existing Wye layouts need to be turned by 90 degrees and their trunk_height revised. A pistachio num_scaffolds above 4 is now honoured; earlier versions accepted 5-8 but built 4.
Validation. Build parameters are checked before any geometry is created:
ValueError listing the names the model does accept.PlantArchitectureError naming the parameter.num_scaffolds, tiller_count, grain_count, branch counts) must be whole numbers; a fractional value such as 3.9 raises PlantArchitectureError rather than being truncated. A parameter that is a mean, such as cluster_berry_count, may be fractional.Plants keep their build parameters. Some parameters, such as the sorghum tiller count and the panicle and grape cluster parameters, are read as the plant grows, so a plant keeps the parameters it was built with for its whole life. getPlantBuildParameters(plant_id) returns them as a dict (parameters that were not given are absent and take the model's default), and writePlantStructureXML() / readPlantStructureXML() save and restore them with the plant.
Plants built with different parameters can share one PlantArchitecture. Flower and fruit prototypes are cached per distinct set of the parameters that shape the flowers and fruit, so plants that differ only in other parameters, such as trellis dimensions or tiller count, share their prototypes.
The sorghum panicle is generated procedurally, with every grain resolved as its own small mesh rather than suggested by a texture. A central rachis bears whorls of primary branches; the distal, fertile part of each branch carries short racemes, and each raceme a row of grains, each partly enclosed by a pair of glumes. The parameters span the range of grain sorghum genotypes, from compact cylindrical heads with short, erect branches to loose, open heads with long, spreading branches that expose the rachis.
Two quantities set most of what distinguishes one head from another. The outline comes from the primary branches: their length at the base, middle and tip of the rachis (a conical head has long basal branches, a cylindrical head roughly equal ones) and their insertion angle. The density comes from the compactness, which draws the grains into a layer a few grains deep just inside the branch envelope: at 0 each grain stays where its raceme places it, and at 1 the head is a closed surface of grain with the branches hidden beneath it.
A default panicle is 25 cm long with 2000 grains and about 44,000 triangles, against about 12,000 for the rest of a mature library sorghum plant; the grain count is the main lever on cost. Lowering grain_subdivisions to 3 or setting glume_length_fraction to 0 (no glumes) trims a further 20%; raising grain_subdivisions to 5 gives rounder grains for close-range imaging at about 10% more triangles. Panicles are copied from three prototypes per parameter set, so neighbouring plants differ in branch layout and grain placement. All panicle primitives carry the object label "fruit".
The panicle is chosen per plant through build parameters. panicle_type is applied first: 0 gives the compact preset, 0.5 the semi-compact defaults below, and 1 the loose preset, with values in between blending linearly between neighbouring presets. Each other key then overrides one field of that preset.
| Build parameter | Semi-compact default | Range | Meaning |
|---|---|---|---|
panicle_type | 0.5 | 0-1 | Preset (compact, semi-compact, loose) |
panicle_length | 0.25 | 0.05-0.6 m | Length from the lowest branch to the tip |
panicle_length_variation | 0.08 | 0-0.3 | Coefficient of variation of panicle length; grain count varies in proportion |
panicle_rachis_length_fraction | 0.85 | 0.3-1 | Fraction of the panicle length spanned by the rachis |
panicle_rachis_curvature | 0 | 0-180° | Total bend of the rachis from base to tip |
panicle_compactness | 0.8 | 0-1 | How strongly grains are drawn into a closed layer over the head |
panicle_branch_count | 70 | 5-200 | Number of primary branches |
panicle_branch_length_base, _middle, _tip | 0.2, 0.21, 0.12 | 0.02-0.6 | Primary branch length at the base, middle and tip of the rachis, as fractions of panicle length |
panicle_branch_length_variation | 0.2 | 0-0.5 | Coefficient of variation of branch length |
panicle_branch_angle_base, panicle_branch_angle_tip | 45, 25 | 0-90° | Branch insertion angle from the rachis at its base and tip |
panicle_branch_upcurve | 15 | 0-90° | Angle through which each branch turns back toward the rachis direction |
panicle_branch_bare_fraction | 0.15 | 0-0.9 | Fraction of each branch, from its base, that bears no racemes |
panicle_splayed_branch_fraction | 0.04 | 0-1 | Fraction of branches that splay out of the head |
panicle_splayed_branch_angle | 65 | 0-120° | Insertion angle of splayed branches |
panicle_branch_radius | 0.0007 | 0.0002-0.003 m | Primary branch radius at its base |
panicle_grains_per_raceme | 5 | 1-20 | Mean number of grains per raceme |
panicle_raceme_length_fraction | 0.2 | 0.02-1 | Raceme length as a fraction of its branch length |
panicle_raceme_angle | 35 | 0-90° | Angle between a raceme and its branch |
grain_count | 2000 | 100-10000 | Mean grains on a panicle of the nominal length |
grain_length, grain_width, grain_thickness | 0.0045, 0.004, 0.0034 | 0.001-0.01 m | Grain dimensions |
grain_size_variation | 0.08 | 0-0.3 | Coefficient of variation of grain size |
grain_color_r, grain_color_g, grain_color_b | 0.78, 0.62, 0.42 | 0-1 | Mean grain color |
grain_color_variation | 0.08 | 0-0.5 | Coefficient of variation of grain brightness |
grain_subdivisions | 4 | 3-12 | Number of facets around a grain |
glume_length_fraction | 0.55 | 0-1.2 | Glume length as a fraction of grain length; 0 omits the glumes |
glume_spread | 5 | 0-90° | Angle by which the glumes gape open from the grain |
glume_color_r, glume_color_g, glume_color_b | 0.45, 0.30, 0.18 | 0-1 | Mean glume color |
Grain and glume color are independent of head type; white, cream, tan, red and brown grain with straw to near-black glumes are all common. The rachis radius, branch color and tube subdivisions are not build parameters, because they are matched to the library peduncle. The peduncle blends from the culm color at its base to the rachis color at the head.
A library sorghum plant has no tillers by default. The tiller_count build parameter (0-6) gives it tillers, which grow from axillary buds of the lowest main-stem leaves as the plant grows:
tiller_count of 2 gives T3 and T4.tiller_count + 1 panicles."tiller" shoot type and can be changed with getCurrentShootParameters("tiller") / defineShootType("tiller", ...).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() under the child_shoot_types key (helios-core 1.3.84+). One case cannot be expressed: an explicitly empty list 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.
ShootParameters.tortuosity is the standard deviation, in degrees per metre, of a random curvature that evolves along the shoot as an Ornstein-Uhlenbeck process in arc length, in addition to the deterministic gravitropic_curvature. Each internode segment is turned by that curvature times its length, so the shape of a shoot does not depend on how finely its internodes are subdivided (internode.length_segments). Library values range from about 10 degrees/m for trunks to about 200 degrees/m for very sinuous shoots. Before helios-core 1.3.89 tortuosity was an angle applied to every internode segment; convert an old value as tortuosity × internode.length_segments / internode_length_max.
ShootParameters.tortuosity_persistence_length (helios-core 1.3.89, default 0.5 m) is the arc length over which that random curvature decorrelates: larger values give long, smooth meanders, smaller values rapid, fine-scale wiggle. Values of zero or less fall back to 0.5 m.
LeafPrototype.petiolule_length (helios-core 1.3.89) sets the length of the petiolule – the stalk joining a leaflet to the rachis, built when LeafPrototype.build_petiolule is true – as a fraction of the leaflet's blade length. It is a dict keyed by compound-leaf index like leaf_texture_file (0 is the terminal leaflet, negative and positive indices the lateral leaflets on either side counted from the tip); a single entry applies to every leaflet, and the default is {0: 0.05}. The petiolule's radius is not a parameter: it is taken from the petiole where the leaflet attaches. Petiolule primitives are labelled "petiolule", drawn with the petiole's material, and not counted as leaf area.
ShootParameters.girth_area_factor is the internode cross-sectional area (cm²) per m² of downstream leaf area. As of helios-core 1.3.89 the downstream leaf area is cumulative: it counts every leaf ever produced above the internode and is not reduced when leaves drop or branches are shed (the pipe model with disused pipes retained), so old basal wood is thicker than its current canopy alone would require and a limb tapers as the square root of the leaf area it has supported. Because the total grows every season, perennial species need a much smaller factor than a model driven by the current canopy would.
Committing a shoot type with defineShootType() stores the values it was given without re-drawing any random parameter, so reading a shoot type and writing it straight back unchanged leaves the model, and the random stream, unchanged (helios-core 1.3.89+). Phytomer parameters are instead re-drawn each time a phytomer is created, so that phytomers on the same shoot vary.
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.
phytomer_parameters.peduncle.yaw (helios-core 1.3.90) is the angle in degrees by which a peduncle is turned about its parent internode, away from the side of the node its leaf is on. At 0, the default, the peduncle leaves the node from the leaf axil; at 180 it is opposite the leaf, as a grapevine cluster is (the grapevine_VSP and grapevine_Wye models set 180).
phytomer_parameters.peduncle.color is, for a terminal inflorescence on a herbaceous stem whose peduncle continues the culm, the color at the top of the peduncle: as of helios-core 1.3.93 the color is blended along the peduncle from the internode color at its base, so it meets the culm without a step. A lateral peduncle, or one on a woody stem, is this color throughout.
A leaf with no petiole, such as a grass leaf, sits directly on the stem. It is defined by a petiole.length of exactly 0 (helios-core 1.3.93+; previously 0 gave a minimal petiole posed like any other). None of the other petiole parameters are then used, and:
leaf.pitch is the angle between the base of the blade and the internode rather than relative to a petiole, and must be zero or negative: a pitch of -20 gives a blade leaving the stem at 20 degrees. A positive pitch raises an error.leaf.roll turns the blade about its midrib, as for a leaf on a petiole.leaves_per_petiole of 1); a compound leaf raises an error.writePlantStructureXML() records which leaves have no petiole. A small positive petiole length instead gives a short petiole, whose leaf is posed from the petiole as usual.
Shoot type labels are species-specific — bean defines unifoliate/trifoliate, almond defines trunk/scaffold/proleptic/sylleptic/spur. 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 |
getPetioleFlexibility(plant_id, shoot_id, node_index) | The flexibility a phytomer's petioles were created with |
setPetioleFlexibility(plant_id, shoot_id, node_index, flexibility) | Replace that flexibility on an existing phytomer; call bendPetioleUnderLeafWeight() afterward to move the geometry |
Changing the flexibility of an existing petiole and re-bending it is reversible, so it can be driven from a state variable such as turgor pressure. bendPetioleUnderLeafWeight() does nothing at a flexibility of exactly zero, so return a bent petiole to its rest shape with a small positive value. A petiole whose leaves have been posed (by setLeafNormal(), setLeafAngleDistribution() or setPetioleLeafGeometry()) is not bent unless include_posed_leaves=True is passed. docs/examples/planthydraulics_wilting_movie.py uses these to make a tomato plant wilt and recover over a day.
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: 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.
As of helios-core 1.3.89, perennial plants that cycle through winter dormancy are shaped by two processes applied once per season, at the dormancy boundary. Neither requires per-species parameters.
Both act only on shoots of type "proleptic" or "sylleptic"; every other shoot type is treated as structural wood (trunk, scaffolds) and is exempt, so a model whose fine shoots use other shoot type names is neither shed nor reprioritized. Pistachio scaffolds use a "scaffold" shoot type as of helios-core 1.3.90 for this reason; they were previously "proleptic" and could be shed whole. In native Helios neither process runs when the carbohydrate model is enabled (which prunes branches on carbon starvation instead); PyHelios does not currently expose enabling the carbohydrate model, so they always apply. Together they reduce the leaf area of the perennial tree models to roughly 55-60% of earlier versions; annuals, eastern redbud and grapevines are unchanged.
getShadowLightExposureAtPoint(plant_id, position) reads a plant's current shadow grid at a world position, returning the local light exposure from 0 (fully shaded) to 1 (unshaded) that drives the shedding decision. It returns 1 before the plant has a canopy. It is intended for inspecting and testing the shedding model, not as a radiation calculation.
getSeasonPeakLightExposureAtPoint(plant_id, position) (helios-core 1.3.93) reads instead the shadow grid captured at the plant's most recent seasonal peak canopy. Through dormancy, when a deciduous plant has no leaves and getShadowLightExposureAtPoint() is 1 everywhere, it still gives the light each point stood in over the past summer. It returns 1 before the plant's first season in leaf, or when the carbohydrate model is active. The apple and apple_fruitingwall models use it to set flower-bud formation: a cluster in deep shade the previous summer forms no flowers, one in partial shade a proportional share, and one in full light all of them.
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. getPlantAvailableNitrogen(plant_id) reports the nitrogen (g N) in a plant's available pool.
helios-core 1.3.88 reworked leaf senescence and remobilization: remobilization_age_threshold was removed, and leaf_remobilization_rate, leaf_senescence_duration_fraction and stress_senescence_advance_fraction were added. setPlantNitrogenParameters() rejects a dict with a key that is not a nitrogen parameter, so a dict written for an earlier release fails instead of silently losing the removed field.
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. 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.
getPlantInternodeObjectIDs(plant_id, shoot_type_label=None) returns the stem counterpart: one tube object per shoot that has geometry, optionally only for shoots of one type (it raises if the plant has no shoot of that type). These are disjoint from the leaf, petiole, peduncle, flower and fruit IDs.
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.
| Method | Returns |
|---|---|
getPlantName(plant_id) | Plant model label the plant was built from, e.g. "bean" |
determinePhenologyStage(plant_id) | "dormant", "vegetative", "reproductive" (has flowers or fruit) or "senescent" (past 90% of the way to dormancy) |
getShootNodeCount(plant_id, shoot_id) | Current number of nodes on one shoot |
getPlantStemHeight(plant_id) | Vertical extent (m) of the main stem only; getPlantHeight() includes everything above it |
getPlantBasePosition(plant_ids) | vec3 for one plant ID, or a list of vec3 for a sequence of IDs |
setPlantBasePosition(plant_id, base_position) | Sets where the stem is rooted, moving an already-built plant (see note) |
getPlantLeafInclinationAngleDistribution(plant_ids, num_bins, normalize=True) | Leaf-area-weighted histogram of blade inclination, num_bins bins over 0–90° |
getPlantLeafAzimuthAngleDistribution(plant_ids, num_bins, normalize=True) | Leaf-area-weighted histogram of blade azimuth, num_bins bins over 0–360° |
The two distributions accept one plant ID or a sequence of IDs pooled into one histogram. With normalize=True the bins sum to 1; with False they hold leaf area (m²). A plant without leaves gives all-zero bins.
setPlantBasePosition()moves an already-built plant (helios-core 1.3.93+). On a plant with no shoots yet (e.g. one fromaddPlantInstance()) it only stores the position, and the first shoot added withaddBaseStemShoot()is placed there;getPlantBasePosition()returns that stored position. On a plant that has been built or grown, it translates the whole plant by the difference between the new and old base positions – its geometry in the Context, the organ positions stored by the model, its per-plant attraction points, and its cached shading state – and later growth continues from the new position. Because the Context objects are moved by this call, do not also translate them withcontext.translateObject(), or the plant moves twice. It cannot be called from inside a phytomer creation or callback function. (Earlier versions changed only the reported position and left the geometry where it was built.)
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; it does not remove 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), build on a shoot type of your own defined with defineShootType(), which carries no hook, or replace the hook with a Python one through setPhytomerCreationFunction() (see Phytomer Creation Functions below).
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: a reloaded shoot has internode.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. Neither happens 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.
A C++ shoot type can carry a phytomer creation function, which the plugin calls once for every new phytomer; library species use one to make the organs of young plants smaller. helios-core 1.3.88 lets a Python function play that role, which is how a model varies a phytomer's growth targets with its rank on the plant.
The function runs after the phytomer is attached to its shoot, so (plant_id, shoot_id, node_index) addresses it through every per-phytomer method. It runs for phytomers built by addBaseStemShoot(), appendShoot() and addChildShoot() as well as during advanceTime(). shoot_node_index is the value a C++ creation function receives, kept for porting C++ models: during growth it equals node_index, but while a shoot's initial phytomers are built it is the shoot's initial node count.
tomato model's mainstem, for example, rescales every new phytomer by plant age, overriding sizes set through the shoot parameters. None restores the function the shoot type had before a Python function was first installed; to build without the library function, define a new shoot type (last point below).defineShootType() keeps its creation function. A new label defined from another type's parameters starts with none, because the parameter dict carries no functions. Give it a library function by name (next section).The creation and callback functions the library models use can be installed on any shoot type by passing their name in place of a Python function. This is how a shoot type cloned from a library model keeps the model's behavior:
| Method | Effect |
|---|---|
setPhytomerCreationFunction(label, name) / setPhytomerCallbackFunction(label, name) | Install the named library function, replacing whatever the type had (including a Python function) |
getPhytomerCreationFunctionName(label) / getPhytomerCallbackFunctionName(label) | Name of the library function the type carries; None if it has none or has a Python function |
PlantArchitecture.getLibraryPhytomerCreationFunctionNames() / getLibraryPhytomerCallbackFunctionNames() | The names accepted |
Names follow the native functions, e.g. "TomatoPhytomerCreationFunction", "AlmondSpurPhytomerCallbackFunction" (grows spurs; needs a spur shoot type in the model) and "GrapevinePhytomerCallbackFunction" (pulls fruit-zone leaves at fruit set). A library function runs natively, so it costs nothing to cross into Python. Many are written for their own model's shoot types and organ layout, and installing one on an unrelated model may do nothing or raise.
A C++ shoot type can also carry a phytomer callback function, which the plugin calls for every phytomer on every advanceTime() sub-step. setPhytomerCallbackFunction() lets a Python function play that role:
advanceTime() splits its interval into sub-steps of at most the shortest phyllochron, so the number of calls is not the number of days.None, exceptions, and which shoots are affected behave as for creation functions above.advanceTime(); for a dormant tree, whose own per-step work is small, it can be several times the run time.Both kinds of function run while the plugin is part-way through walking the plant, so what they may change is limited (helios-core 1.3.90):
pruneBranch() is deferred. Its arguments are checked at once, so an invalid plant, shoot or node raises inside the function, but the cut is applied at the end of the current time step of advanceTime(), or before the build call that ran the function returns. Until then the shoot still reports its uncut node count, including to the functions of phytomers visited later in the same step. Cuts are applied in the order requested; one whose shoot or node an earlier cut already removed does nothing. If the enclosing call ends in an error, the cuts it queued are discarded. Before 1.3.90, pruning from one of these functions could crash or corrupt the plant.advanceTime(), deletePlantInstance() and loadPlantModelFromLibrary() raise PlantArchitectureError.PlantArchitectureError: addBaseStemShoot(), appendShoot(), addChildShoot(), addShootFromNodePositions(), buildPlantInstanceFromLibrary(), buildPlantCanopyFromLibrary() and readPlantStructureXML(). To add structure in response to something a function observed, record it there and make the call between calls to advanceTime().setPlantBasePosition() raises PlantArchitectureError (helios-core 1.3.93), because the shoot being grown holds positions that would not be moved with the plant.As with any exception raised in one of these functions, the error propagates out of the advanceTime() or build call that ran it.
Several library models rely on their own callback: pistachio trains its scaffolds and their secondaries from it, grapevine_VSP and grapevine_Wye pull their fruit-zone leaves, and almond grows its spurs. Installing a Python function on one of their shoot types replaces that behaviour for shoots created afterwards.
helios-core 1.3.88 exposes the phytomer-level state a C++ model reads and writes while it grows a plant, addressed like the petiole methods above by (plant_id, shoot_id, node_index[, petiole_index[, leaf_index]]). An index past the end of its range raises ValueError naming the valid range.
| Method | Effect / returns |
|---|---|
setInternodeMaxLength(plant_id, shoot_id, node_index, length) | Fully-elongated length (m) of one existing internode; setShootInternodeLengthMax() instead sets it for internodes not yet grown |
scaleInternodeMaxLength(plant_id, shoot_id, node_index, scale_factor) | Scale that target |
scaleLeafPrototypeScale(plant_id, shoot_id, node_index, scale_factor, petiole_index=None) | Rescale the phytomer's leaves now, both blades and target, keeping their expansion fraction |
setShootPhyllotacticAngle(plant_id, shoot_id, mean_deg, sd_deg=None) | Phyllotactic angle the shoot's next phytomers are created with |
getPhytomerAge(plant_id, shoot_id, node_index) | Age (days) since the phytomer was created |
getInternodeLength(...), getInternodeRadius(...) | Present internode length (m) and base radius (m) |
getInternodeNodePositions(...) | Internode node positions, base to tip |
getInternodeAxisVector(..., stem_fraction) | Unit internode direction at a fraction of its length |
getPetioleAxisVector(..., stem_fraction, petiole_index) | Unit petiole direction at a fraction of its length |
getPetioleVertices(..., petiole_index), getPetioleRadii(..., petiole_index) | Petiole centerline and its radius at each vertex |
getPhytomerLeafObjectIDs(...) | Leaf object IDs, one list per petiole |
getLeafBasePosition(..., petiole_index, leaf_index) | Where a leaf (leaflet) attaches to its petiole |
A shrinking target takes effect at once and a growing one on later advanceTime() calls: setInternodeMaxLength() below an internode's present length shortens it immediately, while a larger target leaves it where it is to grow toward. scaleLeafPrototypeScale() is the leaf counterpart that moves the blades immediately; scaleLeafSizeMax() moves only the target.
Each new phytomer draws its phyllotactic angle from its shoot's own copy of the shoot parameters, so setShootPhyllotacticAngle() before a time step sets the angle of the phytomers created in that step, touching no existing phytomer, other shoot or shoot type. Calling it before every short step drives a node-by-node angle sequence the shoot parameters cannot express. With sd_deg given the angle is drawn from a normal distribution, which consumes a draw from the Context random generator for every new phytomer even when sd_deg is 0, as C++ normalDistribution(mean, 0) does; omit it for a constant angle.
docs/examples/plantarch_tomato_calibrated_sample.py uses all of these to grow a calibrated tomato from seed and measure every leaf, porting a C++ calibration program.
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 what a plant is built with is one finite sample of those distributions: each phytomer draws its own phytomer parameters once, when it is created (helios-core 1.3.89+), and a few phytomers can sit far from the spread their parameters describe. resamplePhytomerParameters() draws such a sample directly from a set of phytomer parameters, without building geometry.
| Method | Returns |
|---|---|
getPlantLeafAreas(plant_id) | Present one-sided blade area (m²) of each leaf, shoot by shoot then phytomer by phytomer (the order of getPlantLeafObjectIDs()); leaves without geometry are omitted. A leaflet's petiolule is not counted (helios-core 1.3.89+), as in every leaf-area result |
getLeafBladeArea(leaf_object_id) | One-sided blade area (m²) of a single leaf object, excluding any petiolule; Context.getObjectArea() on the same object includes it (helios-core 1.3.89+) |
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 |
setLeafAngleDistribution(plant_ids, beta_mu, beta_nu, eccentricity=None, ellipse_rotation_degrees=0) | Re-aim the leaves of a finished plant in one shot; azimuth is left alone unless eccentricity is given |
setLeafAngleDistribution() moves leaves that already exist rather than steering new ones. Each leaf takes the angle at its own rank in the target distribution, so it can be called repeatedly with changing parameters without scrambling the canopy.
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.Library distributions (helios-core 1.3.88). A library plant model may declare a leaf inclination distribution, and plants built from it are then steered toward it automatically: cowpea (mu 1.398, nu 1.574) and easternredbud (mu 1.00, nu 2.20) do, so their leaf angles differ from earlier releases.
| Method | Effect |
|---|---|
getPlantModelLeafInclinationDistribution(plant_model_name) | (mu, nu) the model declares, (0.0, 0.0) if none |
setPlantModelLeafInclinationDistribution(plant_model_name, beta_mu, beta_nu) | Set it for plants built afterwards; (0, 0) clears it, restoring the unsteered angles |
doesPlantModelDeclareLeafInclinationDistribution(plant_model_name) | Whether the model declares one |
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 loadinggetPlantBuildParameters())Quiet 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 to its parent by a joint whose local frames encode the rest-pose orientation. Every joint locks translation, limits bending to ±60 degrees and carries a spring on each rotation axis, with stiffness derived from beam bending stiffness (K = E*I/L) and damping proportional to it (damping_time_constant). 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: