0.1.33
Loading...
Searching...
No Matches
plant_architecture_params.py
Go to the documentation of this file.
1"""Typed model for Helios plant architecture parameters.
2
3This module provides discoverable, validated dataclasses mirroring the nested
4C++ ``ShootParameters`` / ``PhytomerParameters`` / ``LeafPrototype`` structures
5(plus the flat ``CarbohydrateParameters`` / ``NitrogenParameters``). The objects
6serialize to/from the plain ``dict`` JSON transport used by the PlantArchitecture
7wrapper, so they can be used interchangeably with the raw-dict API.
8
9Canonical usage builds a typed object from the values currently defined in the
10native library, mutates it, and applies it back -- this avoids any drift between
11Python-side defaults and the C++ defaults::
12
13 from pyhelios import Context, PlantArchitecture
14 from pyhelios.plant_architecture_params import ShootParameters, RandomParameterFloat
15
16 with Context() as ctx:
17 pa = PlantArchitecture(ctx)
18 pa.loadPlantModelFromLibrary("almond")
19 sp = ShootParameters.from_dict(pa.getCurrentShootParameters("trunk"))
20 sp.phytomer_parameters.leaf.pitch = RandomParameterFloat.uniform(40, 50)
21 pa.defineShootType("trunk2", sp)
22
23Notes
24-----
25* Every numeric leaf/shoot/phytomer field is a :class:`RandomParameterFloat` or
26 :class:`RandomParameterInt` carrying a distribution and its parameters.
27* Prototype functions (leaf/flower/fruit) are referenced by *name* -- a string
28 naming a built-in Helios prototype (e.g. ``"AlmondFlowerPrototype"``). An empty
29 string / ``None`` means "unset". The C++ ``shared_ptr<Phytomer>`` creation and
30 callback functions are not exposable from Python and are not represented here.
31* Child shoot types round-trip in both directions as of helios-core 1.3.84; see
32 :attr:`ShootParameters.child_shoot_types`.
33"""
34
35from __future__ import annotations
36
37import warnings
38from dataclasses import dataclass, field
39from enum import IntEnum
40from typing import Any, Dict, List, Optional, Tuple
41
42__all__ = [
43 "BudState",
44 "RandomParameterFloat",
45 "RandomParameterInt",
46 "RandomParameter",
47 "LeafPrototype",
48 "InternodeParameters",
49 "PetioleParameters",
50 "LeafParameters",
51 "PeduncleParameters",
52 "InflorescenceParameters",
53 "PhytomerParameters",
54 "ShootParameters",
55 "CarbohydrateParameters",
56 "NitrogenParameters",
57 "LEAF_EXPANSION_RATE_UNSET",
58]
59
60
61#: Sentinel value of :attr:`ShootParameters.leaf_expansion_rate_max` meaning "expand leaves at
62#: the shoot's internode elongation rate". The native comparison is against zero rather than
63#: this exact value, so any negative rate is treated as unset; a rate is a non-negative
64#: quantity, so no negative value can be confused with one a user intended.
65LEAF_EXPANSION_RATE_UNSET = -1.0
66
67
68# --------------------------------------------------------------------------- #
69# Bud state
70# --------------------------------------------------------------------------- #
71class BudState(IntEnum):
72 """State of a vegetative or floral bud, mirroring the C++ ``BudState`` enum.
73
74 The values are the wire format passed to the native library, so they match the
75 C++ enumerators (``BUD_DORMANT`` through ``BUD_DEAD``) one for one.
76
77 ``DEAD`` means the bud will produce nothing further. It is set both when a bud is
78 killed -- by :meth:`~pyhelios.PlantArchitecture.PlantArchitecture.removeShootVegetativeBuds`
79 or by a failed bud-break draw -- and when a bud has *already broken into a child
80 shoot*. A count of dead buds is therefore not a count of killed buds; to test
81 whether a shoot can still grow, count the live states instead.
82 """
83
84 DORMANT = 0
85 ACTIVE = 1
86 FLOWER_CLOSED = 2
87 FLOWER_OPEN = 3
88 FRUITING = 4
89 DEAD = 5
90
91
92# --------------------------------------------------------------------------- #
93# Random parameters
94# --------------------------------------------------------------------------- #
95@dataclass
97 """A float-valued parameter with a sampling distribution.
98
99 Use the classmethod constructors (:meth:`constant`, :meth:`uniform`,
100 :meth:`normal`, :meth:`weibull`) rather than constructing directly.
101 """
102
103 distribution: str = "constant"
104 parameters: List[float] = field(default_factory=lambda: [0.0])
105
106 @classmethod
107 def constant(cls, value: float) -> "RandomParameterFloat":
108 return cls("constant", [float(value)])
109
110 @classmethod
111 def uniform(cls, min_val: float, max_val: float) -> "RandomParameterFloat":
112 if min_val > max_val:
113 raise ValueError(f"min_val ({min_val}) must be <= max_val ({max_val})")
114 return cls("uniform", [float(min_val), float(max_val)])
115
116 @classmethod
117 def normal(cls, mean: float, std_dev: float) -> "RandomParameterFloat":
118 if std_dev < 0:
119 raise ValueError(f"std_dev ({std_dev}) must be >= 0")
120 return cls("normal", [float(mean), float(std_dev)])
121
122 @classmethod
123 def weibull(cls, shape: float, scale: float) -> "RandomParameterFloat":
124 if shape <= 0:
125 raise ValueError(f"shape ({shape}) must be > 0")
126 if scale <= 0:
127 raise ValueError(f"scale ({scale}) must be > 0")
128 return cls("weibull", [float(shape), float(scale)])
129
130 def to_dict(self) -> Dict[str, Any]:
131 return {"distribution": self.distribution, "parameters": [float(p) for p in self.parameters]}
132
133 @classmethod
134 def from_dict(cls, d: Dict[str, Any]) -> "RandomParameterFloat":
135 return cls(str(d["distribution"]), [float(p) for p in d["parameters"]])
136
137
138@dataclass
140 """An int-valued parameter with a sampling distribution."""
141
142 distribution: str = "constant"
143 parameters: List[int] = field(default_factory=lambda: [0])
144
145 @classmethod
146 def constant(cls, value: int) -> "RandomParameterInt":
147 return cls("constant", [int(value)])
148
149 @classmethod
150 def uniform(cls, min_val: int, max_val: int) -> "RandomParameterInt":
151 if min_val > max_val:
152 raise ValueError(f"min_val ({min_val}) must be <= max_val ({max_val})")
153 return cls("uniform", [int(min_val), int(max_val)])
154
155 @classmethod
156 def discrete(cls, values: List[int]) -> "RandomParameterInt":
157 if not values:
158 raise ValueError("values list cannot be empty")
159 return cls("discretevalues", [int(v) for v in values])
160
161 def to_dict(self) -> Dict[str, Any]:
162 # The native JSON transport encodes int distribution parameters as floats.
163 return {"distribution": self.distribution, "parameters": [float(p) for p in self.parameters]}
164
165 @classmethod
166 def from_dict(cls, d: Dict[str, Any]) -> "RandomParameterInt":
167 return cls(str(d["distribution"]), [int(round(float(p))) for p in d["parameters"]])
168
169
170# Backward-compatible name for the float-valued parameter, retained from the
171# original dict-returning ``PlantArchitecture.RandomParameter`` helper.
172RandomParameter = RandomParameterFloat
173
174
175# --------------------------------------------------------------------------- #
176# Helpers for color / vec3 (encoded as plain tuples on the Python side)
177# --------------------------------------------------------------------------- #
178Color = Tuple[float, float, float]
179Vec3 = Tuple[float, float, float]
180
181
182def _rpf(d: Dict[str, Any], key: str, default: RandomParameterFloat) -> RandomParameterFloat:
183 return RandomParameterFloat.from_dict(d[key]) if key in d else default
184
185
186def _rpi(d: Dict[str, Any], key: str, default: RandomParameterInt) -> RandomParameterInt:
187 return RandomParameterInt.from_dict(d[key]) if key in d else default
188
189
190def _color_to_dict(c: Color) -> Dict[str, float]:
191 return {"r": float(c[0]), "g": float(c[1]), "b": float(c[2])}
192
193
194def _color_from_dict(d: Dict[str, Any], default: Color) -> Color:
195 return (float(d.get("r", default[0])), float(d.get("g", default[1])), float(d.get("b", default[2])))
196
197
198def _vec3_to_dict(v: Vec3) -> Dict[str, float]:
199 return {"x": float(v[0]), "y": float(v[1]), "z": float(v[2])}
200
201
202def _vec3_from_dict(d: Dict[str, Any], default: Vec3) -> Vec3:
203 return (float(d.get("x", default[0])), float(d.get("y", default[1])), float(d.get("z", default[2])))
204
205
206# --------------------------------------------------------------------------- #
207# Leaf prototype
208# --------------------------------------------------------------------------- #
209@dataclass
211 leaf_aspect_ratio: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(1.0))
212 midrib_fold_fraction: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
213 longitudinal_curvature: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
214 lateral_curvature: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
215 petiole_roll: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
216 wave_period: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
217 wave_amplitude: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
218 longitudinal_curvature_exponent: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(4.0))
219 flexibility: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
220 flexibility_taper: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(1.0))
221 flexibility_aging: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
222 flexibility_aging_max: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(4.0))
223 #: Deprecated since helios-core 1.3.84; superseded by :attr:`flexibility`. Setting a non-zero
224 #: value emits a DeprecationWarning. The value is still honoured natively -- it is converted to
225 #: an equivalent ``flexibility`` -- but only when ``flexibility`` itself is left at zero.
226 leaf_buckle_length: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
227 #: Deprecated since helios-core 1.3.84; superseded by :attr:`flexibility`. See
228 #: :attr:`leaf_buckle_length`.
229 leaf_buckle_angle: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
230 leaf_offset: Vec3 = (0.0, 0.0, 0.0)
231 subdivisions: int = 1
232 unique_prototypes: int = 1
233 build_petiolule: bool = False
234 OBJ_model_file: str = ""
235 leaf_texture_file: Dict[int, str] = field(default_factory=dict)
236 prototype_function: Optional[str] = None
237
238 def _warn_if_deprecated_buckle_set(self) -> None:
239 """Warn when a deprecated buckle parameter carries a non-zero value.
240
241 helios-core 1.3.84 replaced the fixed-station kink these produced with the
242 continuous self-weight droop of :attr:`flexibility`. The buckle values are still
243 converted to an equivalent flexibility natively, but only while :attr:`flexibility`
244 is zero, so a plant setting both silently ignores the buckle pair.
245 """
246 for name in ("leaf_buckle_length", "leaf_buckle_angle"):
247 param = getattr(self, name)
248 if any(v != 0.0 for v in param.parameters):
249 warnings.warn(
250 f"LeafPrototype.{name} is deprecated since helios-core 1.3.84 and is "
251 "superseded by LeafPrototype.flexibility, which bends the leaf continuously "
252 "under its own weight as it grows rather than kinking it at a fixed station. "
253 "Set flexibility (and optionally flexibility_taper / flexibility_aging) instead; "
254 "the buckle value is converted to an equivalent flexibility only while "
255 "flexibility is left at zero.",
256 DeprecationWarning,
257 stacklevel=3,
258 )
259
260 def to_dict(self) -> Dict[str, Any]:
262 return {
263 "leaf_aspect_ratio": self.leaf_aspect_ratio.to_dict(),
264 "midrib_fold_fraction": self.midrib_fold_fraction.to_dict(),
265 "longitudinal_curvature": self.longitudinal_curvature.to_dict(),
266 "lateral_curvature": self.lateral_curvature.to_dict(),
267 "petiole_roll": self.petiole_roll.to_dict(),
268 "wave_period": self.wave_period.to_dict(),
269 "wave_amplitude": self.wave_amplitude.to_dict(),
270 "longitudinal_curvature_exponent": self.longitudinal_curvature_exponent.to_dict(),
271 "flexibility": self.flexibility.to_dict(),
272 "flexibility_taper": self.flexibility_taper.to_dict(),
273 "flexibility_aging": self.flexibility_aging.to_dict(),
274 "flexibility_aging_max": self.flexibility_aging_max.to_dict(),
275 "leaf_buckle_length": self.leaf_buckle_length.to_dict(),
276 "leaf_buckle_angle": self.leaf_buckle_angle.to_dict(),
277 "leaf_offset": _vec3_to_dict(self.leaf_offset),
278 "subdivisions": int(self.subdivisions),
279 "unique_prototypes": int(self.unique_prototypes),
280 "build_petiolule": bool(self.build_petiolule),
281 "OBJ_model_file": self.OBJ_model_file,
282 "leaf_texture_file": {str(k): v for k, v in self.leaf_texture_file.items()},
283 "prototype_function": self.prototype_function or "",
284 }
285
286 @classmethod
287 def from_dict(cls, d: Dict[str, Any]) -> "LeafPrototype":
288 base = cls()
289 tex = d.get("leaf_texture_file", {})
290 return cls(
291 leaf_aspect_ratio=_rpf(d, "leaf_aspect_ratio", base.leaf_aspect_ratio),
292 midrib_fold_fraction=_rpf(d, "midrib_fold_fraction", base.midrib_fold_fraction),
293 longitudinal_curvature=_rpf(d, "longitudinal_curvature", base.longitudinal_curvature),
294 lateral_curvature=_rpf(d, "lateral_curvature", base.lateral_curvature),
295 petiole_roll=_rpf(d, "petiole_roll", base.petiole_roll),
296 wave_period=_rpf(d, "wave_period", base.wave_period),
297 wave_amplitude=_rpf(d, "wave_amplitude", base.wave_amplitude),
298 longitudinal_curvature_exponent=_rpf(d, "longitudinal_curvature_exponent", base.longitudinal_curvature_exponent),
299 flexibility=_rpf(d, "flexibility", base.flexibility),
300 flexibility_taper=_rpf(d, "flexibility_taper", base.flexibility_taper),
301 flexibility_aging=_rpf(d, "flexibility_aging", base.flexibility_aging),
302 flexibility_aging_max=_rpf(d, "flexibility_aging_max", base.flexibility_aging_max),
303 leaf_buckle_length=_rpf(d, "leaf_buckle_length", base.leaf_buckle_length),
304 leaf_buckle_angle=_rpf(d, "leaf_buckle_angle", base.leaf_buckle_angle),
305 leaf_offset=_vec3_from_dict(d["leaf_offset"], base.leaf_offset) if "leaf_offset" in d else base.leaf_offset,
306 subdivisions=int(d.get("subdivisions", base.subdivisions)),
307 unique_prototypes=int(d.get("unique_prototypes", base.unique_prototypes)),
308 build_petiolule=bool(d.get("build_petiolule", base.build_petiolule)),
309 OBJ_model_file=str(d.get("OBJ_model_file", base.OBJ_model_file)),
310 leaf_texture_file={int(k): str(v) for k, v in tex.items()},
311 prototype_function=(d.get("prototype_function") or None),
312 )
313
314
315# --------------------------------------------------------------------------- #
316# Phytomer sub-structures
317# --------------------------------------------------------------------------- #
318@dataclass
320 pitch: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(20.0))
321 phyllotactic_angle: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(137.5))
322 radius_initial: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.001))
323 max_vegetative_buds_per_petiole: RandomParameterInt = field(default_factory=lambda: RandomParameterInt.constant(0))
324 max_floral_buds_per_petiole: RandomParameterInt = field(default_factory=lambda: RandomParameterInt.constant(0))
325 color: Color = (0.0, 0.0, 0.0)
326 image_texture: str = ""
327 length_segments: int = 1
328 radial_subdivisions: int = 7
330 def to_dict(self) -> Dict[str, Any]:
331 return {
332 "pitch": self.pitch.to_dict(),
333 "phyllotactic_angle": self.phyllotactic_angle.to_dict(),
334 "radius_initial": self.radius_initial.to_dict(),
335 "max_vegetative_buds_per_petiole": self.max_vegetative_buds_per_petiole.to_dict(),
336 "max_floral_buds_per_petiole": self.max_floral_buds_per_petiole.to_dict(),
337 "color": _color_to_dict(self.color),
338 "image_texture": self.image_texture,
339 "length_segments": int(self.length_segments),
340 "radial_subdivisions": int(self.radial_subdivisions),
341 }
342
343 @classmethod
344 def from_dict(cls, d: Dict[str, Any]) -> "InternodeParameters":
345 base = cls()
346 return cls(
347 pitch=_rpf(d, "pitch", base.pitch),
348 phyllotactic_angle=_rpf(d, "phyllotactic_angle", base.phyllotactic_angle),
349 radius_initial=_rpf(d, "radius_initial", base.radius_initial),
350 max_vegetative_buds_per_petiole=_rpi(d, "max_vegetative_buds_per_petiole", base.max_vegetative_buds_per_petiole),
351 max_floral_buds_per_petiole=_rpi(d, "max_floral_buds_per_petiole", base.max_floral_buds_per_petiole),
352 color=_color_from_dict(d["color"], base.color) if "color" in d else base.color,
353 image_texture=str(d.get("image_texture", base.image_texture)),
354 length_segments=int(d.get("length_segments", base.length_segments)),
355 radial_subdivisions=int(d.get("radial_subdivisions", base.radial_subdivisions)),
356 )
357
358
359@dataclass
361 petioles_per_internode: int = 1
362 pitch: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(90.0))
363 radius: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.001))
364 length: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.05))
365 curvature: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
366 taper: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
367 color: Color = (0.0, 0.0, 0.0)
368 length_segments: int = 1
369 radial_subdivisions: int = 7
370 #: Dimensionless bending compliance: the petiole arches toward the ground under its
371 #: leaflets' weight as the leaf grows and the petiole ages. Normalized so a straight,
372 #: horizontal, untapered petiole carrying its full-grown leaf weight at the tip bends
373 #: by this many radians there, independent of length. Zero keeps the petiole rigid.
374 flexibility: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
375 #: Timescale in days over which the compliance grows with age: the effective compliance
376 #: is ``flexibility * (1 + age / flexibility_aging)``, so a petiole goes on lowering
377 #: after its leaf has stopped growing. Zero disables ageing.
378 flexibility_aging: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
379
380 def to_dict(self) -> Dict[str, Any]:
381 return {
382 "petioles_per_internode": int(self.petioles_per_internode),
383 "pitch": self.pitch.to_dict(),
384 "radius": self.radius.to_dict(),
385 "length": self.length.to_dict(),
386 "curvature": self.curvature.to_dict(),
387 "taper": self.taper.to_dict(),
388 "color": _color_to_dict(self.color),
389 "length_segments": int(self.length_segments),
390 "radial_subdivisions": int(self.radial_subdivisions),
391 "flexibility": self.flexibility.to_dict(),
392 "flexibility_aging": self.flexibility_aging.to_dict(),
393 }
394
395 @classmethod
396 def from_dict(cls, d: Dict[str, Any]) -> "PetioleParameters":
397 base = cls()
398 return cls(
399 petioles_per_internode=int(d.get("petioles_per_internode", base.petioles_per_internode)),
400 pitch=_rpf(d, "pitch", base.pitch),
401 radius=_rpf(d, "radius", base.radius),
402 length=_rpf(d, "length", base.length),
403 curvature=_rpf(d, "curvature", base.curvature),
404 taper=_rpf(d, "taper", base.taper),
405 color=_color_from_dict(d["color"], base.color) if "color" in d else base.color,
406 length_segments=int(d.get("length_segments", base.length_segments)),
407 radial_subdivisions=int(d.get("radial_subdivisions", base.radial_subdivisions)),
408 flexibility=_rpf(d, "flexibility", base.flexibility),
409 flexibility_aging=_rpf(d, "flexibility_aging", base.flexibility_aging),
410 )
411
412
413@dataclass
414class LeafParameters:
415 leaves_per_petiole: RandomParameterInt = field(default_factory=lambda: RandomParameterInt.constant(1))
416 pitch: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
417 yaw: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
418 roll: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
419 leaflet_offset: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
420 leaflet_scale: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(1.0))
421 #: Relative size of the intercalary leaflets of an interruptedly pinnate compound leaf,
422 #: as a fraction of the major leaflet just distal to them. Zero gives a simply pinnate
423 #: leaf whose leaflets shrink monotonically from the tip.
424 intercalary_leaflet_scale: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
425 prototype_scale: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.05))
426 prototype: LeafPrototype = field(default_factory=LeafPrototype)
428 def to_dict(self) -> Dict[str, Any]:
429 return {
430 "leaves_per_petiole": self.leaves_per_petiole.to_dict(),
431 "pitch": self.pitch.to_dict(),
432 "yaw": self.yaw.to_dict(),
433 "roll": self.roll.to_dict(),
434 "leaflet_offset": self.leaflet_offset.to_dict(),
435 "leaflet_scale": self.leaflet_scale.to_dict(),
436 "intercalary_leaflet_scale": self.intercalary_leaflet_scale.to_dict(),
437 "prototype_scale": self.prototype_scale.to_dict(),
438 "prototype": self.prototype.to_dict(),
439 }
440
441 @classmethod
442 def from_dict(cls, d: Dict[str, Any]) -> "LeafParameters":
443 base = cls()
444 return cls(
445 leaves_per_petiole=_rpi(d, "leaves_per_petiole", base.leaves_per_petiole),
446 pitch=_rpf(d, "pitch", base.pitch),
447 yaw=_rpf(d, "yaw", base.yaw),
448 roll=_rpf(d, "roll", base.roll),
449 leaflet_offset=_rpf(d, "leaflet_offset", base.leaflet_offset),
450 leaflet_scale=_rpf(d, "leaflet_scale", base.leaflet_scale),
451 intercalary_leaflet_scale=_rpf(d, "intercalary_leaflet_scale", base.intercalary_leaflet_scale),
452 prototype_scale=_rpf(d, "prototype_scale", base.prototype_scale),
453 prototype=LeafPrototype.from_dict(d["prototype"]) if "prototype" in d else base.prototype,
454 )
455
456
457@dataclass
459 length: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.05))
460 radius: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.001))
461 pitch: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
462 roll: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
463 curvature: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
464 color: Color = (0.0, 0.0, 0.0)
465 length_segments: int = 3
466 radial_subdivisions: int = 7
468 def to_dict(self) -> Dict[str, Any]:
469 return {
470 "length": self.length.to_dict(),
471 "radius": self.radius.to_dict(),
472 "pitch": self.pitch.to_dict(),
473 "roll": self.roll.to_dict(),
474 "curvature": self.curvature.to_dict(),
475 "color": _color_to_dict(self.color),
476 "length_segments": int(self.length_segments),
477 "radial_subdivisions": int(self.radial_subdivisions),
478 }
479
480 @classmethod
481 def from_dict(cls, d: Dict[str, Any]) -> "PeduncleParameters":
482 base = cls()
483 return cls(
484 length=_rpf(d, "length", base.length),
485 radius=_rpf(d, "radius", base.radius),
486 pitch=_rpf(d, "pitch", base.pitch),
487 roll=_rpf(d, "roll", base.roll),
488 curvature=_rpf(d, "curvature", base.curvature),
489 color=_color_from_dict(d["color"], base.color) if "color" in d else base.color,
490 length_segments=int(d.get("length_segments", base.length_segments)),
491 radial_subdivisions=int(d.get("radial_subdivisions", base.radial_subdivisions)),
492 )
493
494
495@dataclass
497 flowers_per_peduncle: RandomParameterInt = field(default_factory=lambda: RandomParameterInt.constant(1))
498 flower_offset: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
499 pitch: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
500 roll: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
501 flower_prototype_scale: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0075))
502 fruit_prototype_scale: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0075))
503 fruit_gravity_factor_fraction: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
504 unique_prototypes: int = 1
505 #: Days for the inflorescence to expand from its initial quarter size to full size (helios-core
506 #: 1.3.85+). A non-positive value (the default, -1) defers to the plant-level fruit-maturity
507 #: threshold set by ``setPlantPhenologicalThresholds()``. Set it where the inflorescence finishes
508 #: elongating on a different schedule from the fruit, as a maize tassel does (6 days).
509 inflorescence_maturity_period: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(-1.0))
510 flower_prototype_function: Optional[str] = None
511 fruit_prototype_function: Optional[str] = None
513 def to_dict(self) -> Dict[str, Any]:
514 return {
515 "flowers_per_peduncle": self.flowers_per_peduncle.to_dict(),
516 "flower_offset": self.flower_offset.to_dict(),
517 "pitch": self.pitch.to_dict(),
518 "roll": self.roll.to_dict(),
519 "flower_prototype_scale": self.flower_prototype_scale.to_dict(),
520 "fruit_prototype_scale": self.fruit_prototype_scale.to_dict(),
521 "fruit_gravity_factor_fraction": self.fruit_gravity_factor_fraction.to_dict(),
522 "unique_prototypes": int(self.unique_prototypes),
523 "inflorescence_maturity_period": self.inflorescence_maturity_period.to_dict(),
524 "flower_prototype_function": self.flower_prototype_function or "",
525 "fruit_prototype_function": self.fruit_prototype_function or "",
526 }
527
528 @classmethod
529 def from_dict(cls, d: Dict[str, Any]) -> "InflorescenceParameters":
530 base = cls()
531 return cls(
532 flowers_per_peduncle=_rpi(d, "flowers_per_peduncle", base.flowers_per_peduncle),
533 flower_offset=_rpf(d, "flower_offset", base.flower_offset),
534 pitch=_rpf(d, "pitch", base.pitch),
535 roll=_rpf(d, "roll", base.roll),
536 flower_prototype_scale=_rpf(d, "flower_prototype_scale", base.flower_prototype_scale),
537 fruit_prototype_scale=_rpf(d, "fruit_prototype_scale", base.fruit_prototype_scale),
538 fruit_gravity_factor_fraction=_rpf(d, "fruit_gravity_factor_fraction", base.fruit_gravity_factor_fraction),
539 unique_prototypes=int(d.get("unique_prototypes", base.unique_prototypes)),
540 inflorescence_maturity_period=_rpf(d, "inflorescence_maturity_period", base.inflorescence_maturity_period),
541 flower_prototype_function=(d.get("flower_prototype_function") or None),
542 fruit_prototype_function=(d.get("fruit_prototype_function") or None),
543 )
544
545
546@dataclass
548 internode: InternodeParameters = field(default_factory=InternodeParameters)
549 petiole: PetioleParameters = field(default_factory=PetioleParameters)
550 leaf: LeafParameters = field(default_factory=LeafParameters)
551 peduncle: PeduncleParameters = field(default_factory=PeduncleParameters)
552 inflorescence: InflorescenceParameters = field(default_factory=InflorescenceParameters)
554 def to_dict(self) -> Dict[str, Any]:
555 return {
556 "internode": self.internode.to_dict(),
557 "petiole": self.petiole.to_dict(),
558 "leaf": self.leaf.to_dict(),
559 "peduncle": self.peduncle.to_dict(),
560 "inflorescence": self.inflorescence.to_dict(),
561 }
562
563 @classmethod
564 def from_dict(cls, d: Dict[str, Any]) -> "PhytomerParameters":
565 base = cls()
566 return cls(
567 internode=InternodeParameters.from_dict(d["internode"]) if "internode" in d else base.internode,
568 petiole=PetioleParameters.from_dict(d["petiole"]) if "petiole" in d else base.petiole,
569 leaf=LeafParameters.from_dict(d["leaf"]) if "leaf" in d else base.leaf,
570 peduncle=PeduncleParameters.from_dict(d["peduncle"]) if "peduncle" in d else base.peduncle,
571 inflorescence=InflorescenceParameters.from_dict(d["inflorescence"]) if "inflorescence" in d else base.inflorescence,
572 )
573
574
575# --------------------------------------------------------------------------- #
576# Shoot parameters
577# --------------------------------------------------------------------------- #
578# Top-level ShootParameters fields, with their RandomParameter kind. Keeping this
579# as a table avoids 20+ near-identical lines in both to_dict and from_dict.
580_SHOOT_RPF_FIELDS = (
581 ("girth_area_factor", 0.0),
582 ("insertion_angle_tip", 20.0),
583 ("insertion_angle_decay_rate", 0.0),
584 ("internode_length_max", 0.02),
585 ("internode_length_min", 0.002),
586 ("internode_length_decay_rate", 0.0),
587 ("base_roll", 0.0),
588 ("base_yaw", 0.0),
589 ("gravitropic_curvature", 0.0),
590 ("tortuosity", 0.0),
591 ("phyllochron_min", 2.0),
592 ("elongation_rate_max", 0.2),
593 ("leaf_expansion_rate_max", LEAF_EXPANSION_RATE_UNSET),
594 ("vegetative_bud_break_probability_min", 0.0),
595 ("vegetative_bud_break_probability_max", 1.0),
596 ("vegetative_bud_break_probability_decay_rate", -0.5),
597 ("flower_bud_break_probability", 0.0),
598 ("fruit_set_probability", 0.0),
599 ("vegetative_bud_break_time", 5.0),
600)
601_SHOOT_RPI_FIELDS = (
602 ("max_nodes", 10),
603 ("max_nodes_per_season", 9999),
604 ("max_terminal_floral_buds", 0),
605)
606_SHOOT_BOOL_FIELDS = (
607 ("flowers_require_dormancy", False),
608 ("growth_requires_dormancy", False),
609 ("determinate_shoot_growth", True),
610)
611
612
613@dataclass
614class ShootParameters:
615 phytomer_parameters: PhytomerParameters = field(default_factory=PhytomerParameters)
616
617 # Geometric / growth RandomParameter_float fields
618 girth_area_factor: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
619 insertion_angle_tip: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(20.0))
620 insertion_angle_decay_rate: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
621 internode_length_max: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.02))
622 internode_length_min: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.002))
623 internode_length_decay_rate: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
624 base_roll: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
625 base_yaw: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
626 gravitropic_curvature: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
627 tortuosity: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
628 phyllochron_min: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(2.0))
629 elongation_rate_max: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.2))
630 #: Maximum relative expansion rate of the shoot's leaves and petioles
631 #: (m * m^-1 * day^-1), in the same form as ``elongation_rate_max``, so 0.1 expands a
632 #: leaf from nothing to full size in ten days regardless of that leaf's size. Setting it
633 #: decouples leaf expansion from internode elongation, which a species whose leaves
634 #: finish expanding before its internodes stop elongating needs. Any negative value --
635 #: the default :data:`LEAF_EXPANSION_RATE_UNSET` -- means leaves expand at the shoot's
636 #: own ``elongation_rate_max``, reproducing the historical single-rate growth. Zero is a
637 #: real rate meaning "leaves never expand", so it differs from the unset sentinel.
638 leaf_expansion_rate_max: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(LEAF_EXPANSION_RATE_UNSET))
639 vegetative_bud_break_probability_min: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
640 vegetative_bud_break_probability_max: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(1.0))
641 vegetative_bud_break_probability_decay_rate: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(-0.5))
642 flower_bud_break_probability: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
643 fruit_set_probability: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(0.0))
644 vegetative_bud_break_time: RandomParameterFloat = field(default_factory=lambda: RandomParameterFloat.constant(5.0))
645
646 # RandomParameter_int fields
647 max_nodes: RandomParameterInt = field(default_factory=lambda: RandomParameterInt.constant(10))
648 max_nodes_per_season: RandomParameterInt = field(default_factory=lambda: RandomParameterInt.constant(9999))
649 max_terminal_floral_buds: RandomParameterInt = field(default_factory=lambda: RandomParameterInt.constant(0))
651 # Boolean flags
652 flowers_require_dormancy: bool = False
653 growth_requires_dormancy: bool = False
654 determinate_shoot_growth: bool = True
656 # Optional child shoot type definition: {"labels": [...], "probabilities": [...]}.
657 # Round-trips in both directions since helios-core 1.3.84 added
658 # ShootParameters::getChildShootTypeLabels()/getChildShootTypeProbabilities(), so
659 # getCurrentShootParameters() populates it. The one value that cannot be applied is an
660 # explicitly empty list: defineChildShootTypes() rejects empty input natively, so an
661 # empty list leaves whatever the shoot type being replaced already carried.
662 child_shoot_types: Optional[Dict[str, Any]] = None
664 def to_dict(self) -> Dict[str, Any]:
665 d: Dict[str, Any] = {"phytomer_parameters": self.phytomer_parameters.to_dict()}
666 for name, _ in _SHOOT_RPF_FIELDS:
667 d[name] = getattr(self, name).to_dict()
668 for name, _ in _SHOOT_RPI_FIELDS:
669 d[name] = getattr(self, name).to_dict()
670 for name, _ in _SHOOT_BOOL_FIELDS:
671 d[name] = bool(getattr(self, name))
672 if self.child_shoot_types is not None:
673 d["child_shoot_types"] = self.child_shoot_types
674 return d
676 @classmethod
677 def from_dict(cls, d: Dict[str, Any]) -> "ShootParameters":
678 kwargs: Dict[str, Any] = {}
679 if "phytomer_parameters" in d:
680 kwargs["phytomer_parameters"] = PhytomerParameters.from_dict(d["phytomer_parameters"])
681 for name, default in _SHOOT_RPF_FIELDS:
682 if name in d:
683 kwargs[name] = RandomParameterFloat.from_dict(d[name])
684 for name, default in _SHOOT_RPI_FIELDS:
685 if name in d:
686 kwargs[name] = RandomParameterInt.from_dict(d[name])
687 for name, default in _SHOOT_BOOL_FIELDS:
688 if name in d:
689 kwargs[name] = bool(d[name])
690 if "child_shoot_types" in d:
691 kwargs["child_shoot_types"] = d["child_shoot_types"]
692 return cls(**kwargs)
693
694 def define_child_shoot_types(self, labels: List[str], probabilities: List[float]) -> None:
695 """Set child shoot types; probabilities must sum to 1 (validated natively)."""
696 if len(labels) != len(probabilities):
697 raise ValueError("labels and probabilities must be the same length")
698 if not labels:
699 raise ValueError("labels and probabilities cannot be empty")
700 self.child_shoot_types = {"labels": list(labels), "probabilities": [float(p) for p in probabilities]}
701
702
703# --------------------------------------------------------------------------- #
704# Flat physiology parameter structs
705# --------------------------------------------------------------------------- #
706def _flat_to_dict(obj: Any, names: Tuple[str, ...]) -> Dict[str, float]:
707 return {n: float(getattr(obj, n)) for n in names}
708
709
710def _flat_from_dict(cls: Any, d: Dict[str, Any], names: Tuple[str, ...]) -> Any:
711 base = cls()
712 kwargs = {n: float(d[n]) for n in names if n in d}
713 for n in names:
714 kwargs.setdefault(n, getattr(base, n))
715 return cls(**kwargs)
716
718_CARB_FIELDS = (
719 "stem_density", "stem_carbon_percentage", "stem_carbohydrate_percentage",
720 "stem_structural_carbon_percentage", "maturity_age", "initial_density_ratio",
721 "shoot_root_ratio", "leaf_total_carbon_percentage", "SLA",
722 "leaf_carbohydrate_percentage", "leaf_carbon_percentage", "total_flower_cost",
723 "fruit_density", "fruit_carbon_percentage", "r_m_w_20", "r_m_r_20",
724 "living_wood_fraction", "growth_respiration_fraction", "carbohydrate_abortion_threshold",
725 "carbohydrate_pruning_threshold", "bud_death_threshold_days", "branch_death_threshold_days",
726 "carbohydrate_phyllochron_threshold", "carbohydrate_vegetative_break_threshold",
727 "carbohydrate_growth_threshold", "starch_sequestration_ratio",
728 "carbohydrate_transfer_threshold_down", "carbohydrate_transfer_threshold_up",
729 "carbon_conductance_down", "carbon_conductance_up",
730)
731
733@dataclass
735 """Flat carbohydrate-model parameters. Defaults mirror the Helios C++ defaults;
736 prefer ``CarbohydrateParameters.from_dict(pa.getDefaultCarbohydrateParameters())``."""
737
738 stem_density: float = 675000.0
739 stem_carbon_percentage: float = 0.457
740 stem_carbohydrate_percentage: float = 1 - (1 / 1.14)
741 stem_structural_carbon_percentage: float = 0.457 - (1 - (1 / 1.14))
742 maturity_age: float = 120.0
743 initial_density_ratio: float = 0.25
744 shoot_root_ratio: float = 3.0
745 leaf_total_carbon_percentage: float = 0.453
746 SLA: float = 9.2 / 10000 / 0.453 * 12.01
747 leaf_carbohydrate_percentage: float = 1 - (1 / 1.13)
748 leaf_carbon_percentage: float = 0.453
749 total_flower_cost: float = 8.33e-4
750 fruit_density: float = 525000.0
751 fruit_carbon_percentage: float = 0.475
752 r_m_w_20: float = 5.25164e-05
753 r_m_r_20: float = 5.25164e-03
754 living_wood_fraction: float = 0.5
755 growth_respiration_fraction: float = 0.211
756 carbohydrate_abortion_threshold: float = 0.1
757 carbohydrate_pruning_threshold: float = 0.025
758 bud_death_threshold_days: float = 2.0
759 branch_death_threshold_days: float = 5.0
760 carbohydrate_phyllochron_threshold: float = 0.05
761 carbohydrate_vegetative_break_threshold: float = 0.05
762 carbohydrate_growth_threshold: float = 0.2
763 starch_sequestration_ratio: float = 0.025
764 carbohydrate_transfer_threshold_down: float = 0.025
765 carbohydrate_transfer_threshold_up: float = 0.04
766 carbon_conductance_down: float = 0.95
767 carbon_conductance_up: float = 0.95 * 0.5
769 def to_dict(self) -> Dict[str, float]:
770 return _flat_to_dict(self, _CARB_FIELDS)
772 @classmethod
773 def from_dict(cls, d: Dict[str, Any]) -> "CarbohydrateParameters":
774 return _flat_from_dict(cls, d, _CARB_FIELDS)
777_NITROGEN_FIELDS = (
778 "target_leaf_N_area", "minimum_leaf_N_area", "root_allocation_fraction",
779 "max_N_accumulation_rate", "leaf_remobilization_efficiency",
780 "remobilization_age_threshold", "fruit_N_area",
782
784@dataclass
786 """Flat nitrogen-model parameters. Prefer
787 ``NitrogenParameters.from_dict(pa.getDefaultNitrogenParameters())``."""
788
789 target_leaf_N_area: float = 1.5
790 minimum_leaf_N_area: float = 0.5
791 root_allocation_fraction: float = 0.15
792 max_N_accumulation_rate: float = 0.1
793 leaf_remobilization_efficiency: float = 0.70
794 remobilization_age_threshold: float = 0.70
795 fruit_N_area: float = 1.0
796
797 def to_dict(self) -> Dict[str, float]:
798 return _flat_to_dict(self, _NITROGEN_FIELDS)
799
800 @classmethod
801 def from_dict(cls, d: Dict[str, Any]) -> "NitrogenParameters":
802 return _flat_from_dict(cls, d, _NITROGEN_FIELDS)
State of a vegetative or floral bud, mirroring the C++ BudState enum.
"CarbohydrateParameters" from_dict(cls, Dict[str, Any] d)
"InflorescenceParameters" from_dict(cls, Dict[str, Any] d)
"InternodeParameters" from_dict(cls, Dict[str, Any] d)
None _warn_if_deprecated_buckle_set(self)
Warn when a deprecated buckle parameter carries a non-zero value.
"PeduncleParameters" from_dict(cls, Dict[str, Any] d)
"PetioleParameters" from_dict(cls, Dict[str, Any] d)
"PhytomerParameters" from_dict(cls, Dict[str, Any] d)
A float-valued parameter with a sampling distribution.
"RandomParameterFloat" from_dict(cls, Dict[str, Any] d)
"RandomParameterFloat" normal(cls, float mean, float std_dev)
"RandomParameterFloat" weibull(cls, float shape, float scale)
"RandomParameterFloat" uniform(cls, float min_val, float max_val)
An int-valued parameter with a sampling distribution.
"RandomParameterInt" from_dict(cls, Dict[str, Any] d)
"RandomParameterInt" discrete(cls, List[int] values)
"RandomParameterInt" uniform(cls, int min_val, int max_val)
None define_child_shoot_types(self, List[str] labels, List[float] probabilities)
Set child shoot types; probabilities must sum to 1 (validated natively).
Vec3 _vec3_from_dict(Dict[str, Any] d, Vec3 default)
Any _flat_from_dict(Any cls, Dict[str, Any] d, Tuple[str,...] names)
Dict[str, float] _flat_to_dict(Any obj, Tuple[str,...] names)
RandomParameterInt _rpi(Dict[str, Any] d, str key, RandomParameterInt default)
Color _color_from_dict(Dict[str, Any] d, Color default)
RandomParameterFloat _rpf(Dict[str, Any] d, str key, RandomParameterFloat default)