diff --git a/app/conf/form/common.yaml b/app/conf/form/common.yaml
index c2863b3..e5bd316 100644
--- a/app/conf/form/common.yaml
+++ b/app/conf/form/common.yaml
@@ -25,16 +25,18 @@ components:
max: 1
step: 0.1
value: 0.5
+ persistence: true
- id: fine-root-threshold-input
param: fine_root_threshold
- label: Fine root threshold ratio
- help: Threshold for classifying a root as a fine root, rather than a structural root (mm)
+ label: Fine root threshold
+ help: Threshold for classifying a root as a fine root, rather than a structural root (cm)
class_name: dash_bootstrap_components.Input
kwargs:
type: number
min: 0
- step: 0.1
- value: 1.5
+ step: 0.01
+ value: 0.06
+ persistence: true
- id: outer-primary-root-num-input
param: outer_root_num
label: Number of outer roots
@@ -46,6 +48,7 @@ components:
max: 20
step: 1
value: 10
+ persistence: true
- id: inner-primary-root-num-input
param: inner_root_num
label: Number of inner roots
@@ -57,6 +60,7 @@ components:
max: 20
step: 1
value: 8
+ persistence: true
- id: min-length-primary-root-input
param: min_primary_length
label: Primary root minimum length
@@ -68,6 +72,7 @@ components:
max: 50
step: 1
value: 20
+ persistence: true
- id: max-length-primary-root-input
param: max_primary_length
label: Primary root maximum length
@@ -79,6 +84,7 @@ components:
max: 50
step: 1
value: 30
+ persistence: true
- id: base-diameter-root-input
param: base_diameter
label: Base root diameter
@@ -87,9 +93,10 @@ components:
kwargs:
type: number
min: 0.001
- max: 10
- step: 0.1
+ max: 1
+ step: 0.01
value: 0.11
+ persistence: true
- id: root-apex-input
param: apex_diameter
label: Apex diameter
@@ -97,10 +104,11 @@ components:
class_name: dash_bootstrap_components.Input
kwargs:
type: number
- min: 0.001
+ min: 0.01
max: 1
- step: 0.1
- value: 0.1
+ step: 0.01
+ value: 0.02
+ persistence: true
- id: diameter-reduction-factor-input
param: diameter_reduction
label: Diameter reduction
@@ -108,10 +116,11 @@ components:
class_name: dash_bootstrap_components.Input
kwargs:
type: number
- min: 0.001
+ min: 0
max: 1
step: 0.1
- value: 0.7
+ value: 0.2
+ persistence: true
- id: min-num-secondary-roots-input
param: min_sec_root_num
label: Minimum number of secondary roots
@@ -120,9 +129,9 @@ components:
kwargs:
type: number
min: 0
- max: 10
step: 1
- value: 3
+ value: 1
+ persistence: true
- id: max-num-secondary-roots-input
param: max_sec_root_num
label: Maximum number of secondary roots
@@ -131,9 +140,10 @@ components:
kwargs:
type: number
min: 0
- max: 10
+ max: 20
step: 1
- value: 4
+ value: 3
+ persistence: true
- id: secondary-root-growth-input
param: growth_sec_root
label: Secondary root growth rate
@@ -143,7 +153,8 @@ components:
type: number
min: 0
step: 0.1
- value: 0.5
+ value: 0.2
+ persistence: true
- id: min-length-secondary-root-input
param: min_sec_root_length
label: Secondary root minimum length
@@ -155,6 +166,7 @@ components:
max: 250
step: 1
value: 100
+ persistence: true
- id: max-length-secondary-root-input
param: max_sec_root_length
label: Secondary root maximum length
@@ -166,6 +178,7 @@ components:
max: 500
step: 1
value: 220
+ persistence: true
- id: segments-per-root-input
param: segments_per_root
label: Segments per root
@@ -176,7 +189,8 @@ components:
min: 1
max: 500
step: 1
- value: 10
+ value: 50
+ persistence: true
- id: root-length-reduction-input
param: length_reduction
label: Length reduction
@@ -188,6 +202,7 @@ components:
max: 1
step: 0.1
value: 0.5
+ persistence: true
- id: root-vary-input
param: root_vary
label: Root segment variance
@@ -199,28 +214,7 @@ components:
max: 360
step: 1
value: 30
- - id: origin-min-input
- param: origin_min
- label: Origin minimum
- help: The minimum distance of the initial primary root from the origin (cm)
- class_name: dash_bootstrap_components.Input
- kwargs:
- type: number
- min: 0.001
- max: 1
- step: 0.01
- value: 0.01
- - id: origin-max-input
- param: origin_max
- label: Origin maximum
- help: The maximum distance of the initial primary root from the origin (cm)
- class_name: dash_bootstrap_components.Input
- kwargs:
- type: number
- min: 0.01
- max: 1
- step: 0.01
- value: 0.1
+ persistence: true
- id: enable-soil-input
param: enable_soil
label: Enable soil
@@ -238,6 +232,7 @@ components:
max: 100
step: 10
value: 10
+ persistence: true
- id: soil-layer-width-input
param: soil_layer_width
label: Soil layer width
@@ -249,6 +244,7 @@ components:
max: 100
step: 10
value: 10
+ persistence: true
- id: soil-n-layers
param: soil_n_layers
label: Number soil layers
@@ -260,6 +256,7 @@ components:
max: 20
step: 1
value: 1
+ persistence: true
- id: soil-n-cols
param: soil_n_cols
label: Number soil columns
@@ -271,6 +268,7 @@ components:
max: 20
step: 1
value: 1
+ persistence: true
- id: random-seed-input
param: random_seed
label: Random seed
@@ -279,6 +277,19 @@ components:
kwargs:
type: number
step: 1
+ persistence: true
+ - id: max-val-attempts-input
+ param: max_val_attempts
+ label: Maximum validation attempts
+ help: The maximum number of attempts to validate each root (for plausibility) within the root system.
+ class_name: dash_bootstrap_components.Input
+ kwargs:
+ type: number
+ min: 0
+ max: 1000
+ step: 1
+ value: 50
+ persistence: true
- id: simulation-tag-input
param: simulation_tag
label: Simulation tag
@@ -287,6 +298,7 @@ components:
kwargs:
type: text
value: default
+ persistence: true
simulation:
collapsible: true
children:
@@ -298,6 +310,11 @@ components:
children: Run model
color: primary
className: me-1
+ - id: download-sim-data-input
+ label: Download
+ help: Download the simulation output data
+ class_name: dash_daq.BooleanSwitch.BooleanSwitch
+ kwargs: {}
- id: save-param-button
label: Save
help: Save current parameter configuration to file
diff --git a/app/pages/generate_root_system.py b/app/pages/generate_root_system.py
index 6d65a81..8f0b2f1 100644
--- a/app/pages/generate_root_system.py
+++ b/app/pages/generate_root_system.py
@@ -4,10 +4,13 @@
# Imports
######################################
+import base64
import os.path as osp
+from datetime import datetime
import dash_bootstrap_components as dbc
import pandas as pd
+import yaml
from dash import ALL, Input, Output, State, callback, dcc, get_app, html, register_page
from deeprootgen.data_model import RootSimulationModel
@@ -117,10 +120,6 @@ def save_param(n_clicks: int, param_inputs: list) -> None:
k = input["param"]
inputs[k] = param_inputs[i]
- from datetime import datetime
-
- import yaml
-
outfile = osp.join(
"outputs", f"{datetime.today().strftime('%Y-%m-%d-%H-%M')}-{PAGE_ID}.yaml"
)
@@ -138,10 +137,6 @@ def save_param(n_clicks: int, param_inputs: list) -> None:
prevent_initial_call=True,
)
def update_output(list_of_contents: list, list_of_names: list) -> tuple:
- import base64
-
- import yaml
-
_, content_string = list_of_contents[0].split(",")
decoded = base64.b64decode(content_string)
input_dict = yaml.safe_load(decoded.decode("utf-8"))
@@ -156,9 +151,12 @@ def update_output(list_of_contents: list, list_of_names: list) -> tuple:
Input({"index": f"{PAGE_ID}-run-sim-button", "type": ALL}, "n_clicks"),
State({"type": f"{PAGE_ID}-parameters", "index": ALL}, "value"),
State({"index": f"{PAGE_ID}-enable-soil-input", "type": ALL}, "on"),
+ State({"index": f"{PAGE_ID}-download-sim-data-input", "type": ALL}, "on"),
prevent_initial_call=True,
)
-def run_root_model(n_clicks: list, form_values: list, enable_soils: list) -> dcc.Graph:
+def run_root_model(
+ n_clicks: list, form_values: list, enable_soils: list, download_data: list
+) -> dcc.Graph:
"""Run and plot the root model.
Args:
@@ -166,7 +164,10 @@ def run_root_model(n_clicks: list, form_values: list, enable_soils: list) -> dcc
Number of times the button has been clicked.
form_values (list):
The form input data.
-
+ enable_soils (list):
+ Enable visualisation of soil data.
+ download_data (list):
+ Whether to download the simulation results data.
Returns:
dcc.Graph: The visualised root model.
"""
@@ -186,18 +187,25 @@ def run_root_model(n_clicks: list, form_values: list, enable_soils: list) -> dcc
input_params = RootSimulationModel.parse_obj(form_inputs)
simulation = RootSystemSimulation(
- simulation_tag=input_params.simulation_tag, random_seed=input_params.random_seed
+ simulation_tag=input_params.simulation_tag,
+ random_seed=input_params.random_seed,
+ visualise=True,
)
results = simulation.run(input_params)
- from datetime import datetime
+ download_data = download_data[0]
+ if download_data:
+ from datetime import datetime
- now = datetime.today().strftime("%Y-%m-%d-%H-%M")
- outfile = osp.join("outputs", f"{now}-nodes.csv")
- df = pd.DataFrame(results.nodes)
- df.to_csv(outfile, index=False)
+ now = datetime.today().strftime("%Y-%m-%d-%H-%M")
+ outfile = osp.join("outputs", f"{now}-nodes.csv")
+ df = pd.DataFrame(results.nodes)
+ df.to_csv(outfile, index=False)
+ download_file = dcc.send_file(outfile)
+ else:
+ download_file = None
- return results.figure, dcc.send_file(outfile)
+ return results.figure, download_file
######################################
diff --git a/deeprootgen/data_model/__init__.py b/deeprootgen/data_model/__init__.py
index dbb168d..6b9623b 100644
--- a/deeprootgen/data_model/__init__.py
+++ b/deeprootgen/data_model/__init__.py
@@ -8,5 +8,7 @@
RootEdgeModel,
RootNodeModel,
RootSimulationModel,
- RootSimulationResults,
+ RootSimulationResultModel,
+ RootType,
+ RootTypeModel,
)
diff --git a/deeprootgen/data_model/simulation_data_models.py b/deeprootgen/data_model/simulation_data_models.py
index e6ee066..8c66446 100644
--- a/deeprootgen/data_model/simulation_data_models.py
+++ b/deeprootgen/data_model/simulation_data_models.py
@@ -5,6 +5,7 @@
"""
+from enum import Enum
from typing import List, Optional
import plotly.graph_objects as go
@@ -16,6 +17,28 @@ class Config:
arbitrary_types_allowed = True
+class RootType(Enum):
+ STRUCTURAL = "structural"
+ FINE = "fine"
+ PRIMARY = "primary"
+ SECONDARY = "secondary"
+ OUTER = "outer"
+ INNER = "inner"
+
+
+class RootTypeModel(BaseModel):
+ """The root type data model for classifying roots.
+
+ Args:
+ BaseModel (BaseModel):
+ The Pydantic Base model class.
+ """
+
+ root_type: str
+ order_type: str
+ position_type: str
+
+
class RootNodeModel(BaseModel):
"""The node data model for the hierarchical graph representation of the root system.
@@ -35,7 +58,11 @@ class RootNodeModel(BaseModel):
segment_rank: Optional[int] = 0
diameter: Optional[float] = 0.0
length: Optional[float] = 0.0
+ root_type: Optional[str] = "base"
+ order_type: Optional[str] = "base"
+ position_type: Optional[str] = "base"
simulation_tag: Optional[str] = "default"
+ invalid_root: Optional[bool] = False
class RootEdgeModel(BaseModel):
@@ -78,13 +105,14 @@ class RootSimulationModel(BaseModel):
segments_per_root: int
length_reduction: float
root_vary: float
- origin_min: float
- origin_max: float
+ origin_min: Optional[float] = 1e-3
+ origin_max: Optional[float] = 1e-2
enable_soil: bool
soil_layer_height: float
soil_layer_width: float
soil_n_layers: int
soil_n_cols: int
+ max_val_attempts: Optional[int] = 50
simulation_tag: Optional[str] = "default"
no_root_zone: Optional[float] = 1e-4
floor_threshold: Optional[float] = 0.4
@@ -92,9 +120,9 @@ class RootSimulationModel(BaseModel):
@pydantic.dataclasses.dataclass(config=Config)
-class RootSimulationResults:
+class RootSimulationResultModel:
"""
- The root system architecture simulation results.
+ The root system architecture simulation results data model.
Args:
Config (Config):
diff --git a/deeprootgen/model/hgraph.py b/deeprootgen/model/hgraph.py
new file mode 100644
index 0000000..1902a96
--- /dev/null
+++ b/deeprootgen/model/hgraph.py
@@ -0,0 +1,247 @@
+"""Contains the hierarchical graph representation of the root system.
+
+This module defines the hierarchical graph representation of the root system.
+This includes integration with NetworkX and PyTorch Geometric.
+
+"""
+
+# mypy: ignore-errors
+
+from typing import List
+
+import networkx as nx
+import numpy as np
+import pandas as pd
+from torch_geometric.data import Data
+from torch_geometric.utils import from_networkx
+
+from ..data_model import RootEdgeModel, RootNodeModel
+from ..spatial import get_transform_matrix, make_homogenous
+
+
+class RootNode:
+ """A node within the hierarchical graph representation of the root system."""
+
+ def __init__(self, G: "RootSystemGraph", node_data: RootNodeModel) -> None:
+ """RootNode constructor.
+
+ Args:
+ G: (RootSystemGraph):
+ The hierarchical graph representation of the root system.
+ node_data (RootNodeModel):
+ The root node data model.
+
+ Returns:
+ RootNode:
+ The RootNode instance.
+ """
+ self.G = G
+ self.node_data = node_data
+
+ def add_child_node(
+ self, child_data: RootNodeModel, new_organ: bool = False
+ ) -> "RootNode":
+ """Add a child node to the hierarchical graph.
+
+ Args:
+ child_data (RootNodeModel):
+ The node data for the child node.
+ new_organ (bool):
+ Whether the new child node belongs to a new plant organ.
+
+ Returns:
+ RootNode:
+ The child node.
+ """
+ if new_organ:
+ organ_id = self.G.increment_organ_id()
+ segment_rank = 1
+ order = self.node_data.order + 1
+ else:
+ organ_id = self.node_data.organ_id
+ segment_rank = self.node_data.segment_rank + 1
+ order = self.node_data.order
+
+ child_data.parent_id = self.node_data.node_id
+ child_data.organ_id = organ_id
+ child_data.plant_id = self.node_data.plant_id
+ child_data.segment_rank = segment_rank
+ child_data.order = order
+
+ child_node = self.G.add_node(child_data)
+ edge_data = RootEdgeModel(
+ parent_id=self.node_data.node_id, child_id=child_node.node_data.node_id
+ )
+ self.G.add_edge(edge_data)
+
+ return child_node
+
+ def as_dict(self) -> dict:
+ """Return the graph node as a dictionary.
+
+ Returns:
+ dict:
+ The node as a dictionary.
+ """
+ return self.node_data.dict()
+
+
+class RootEdge:
+ """An edge within the hierarchical graph representation of the root system."""
+
+ def __init__(self, G: "RootSystemGraph", edge_data: RootEdgeModel) -> None:
+ """RootEdge constructor.
+
+ Args:
+ G: (RootSystemGraph):
+ The hierarchical graph representation of the root system.
+ edge_data (RootEdgeModel):
+ The root edge data model.
+
+ Returns:
+ RootEdge:
+ The RootEdge instance.
+ """
+ self.G = G
+ self.edge_data = edge_data
+
+ def as_dict(self) -> dict:
+ """Return the graph edge as a dictionary.
+
+ Returns:
+ dict:
+ The edge as a dictionary.
+ """
+ return self.edge_data.dict()
+
+
+class RootSystemGraph:
+ """The hierarchical graph representation of the root system."""
+
+ def __init__(self) -> None:
+ """RootSystemGraph constructor.
+
+ Returns:
+ RootSystemSimulation:
+ The RootSystemGraph instance.
+ """
+ self.nodes: List[RootNode] = []
+ self.edges: List[RootEdge] = []
+ self.node_id = 0
+ self.organ_id = 0
+
+ # Base organ node
+ node_data = RootNodeModel()
+ node_data.organ_id = self.increment_organ_id()
+ self.base_node = self.add_node(node_data)
+
+ def add_node(self, node_data: RootNodeModel) -> RootNode:
+ """Construct a new RootNode.
+
+ Args:
+ node_data (RootNodeModel):
+ The root node data model.
+
+ Returns:
+ RootNode:
+ The new RootNode.
+ """
+ node_data.node_id = self.increment_node_id()
+ node = RootNode(self, node_data)
+
+ self.nodes.append(node)
+ return node
+
+ def add_edge(self, edge_data: RootEdgeModel) -> RootEdge:
+ """Construct a new RootEdge.
+
+ Args:
+ node_data (RootEdgeModel):
+ The root edge data model.
+
+ Returns:
+ RootEdge:
+ The new RootEdge.
+ """
+ edge = RootEdge(self, edge_data)
+ self.edges.append(edge)
+ return edge
+
+ def increment_node_id(self) -> int:
+ """Increment the node ID.
+
+ Returns:
+ int:
+ The node ID prior to incrementation.
+ """
+ node_id = self.node_id
+ self.node_id += 1
+ return node_id
+
+ def increment_organ_id(self) -> int:
+ """Increment the organ ID.
+
+ Returns:
+ int:
+ The organ ID prior to incrementation.
+ """
+ organ_id = self.organ_id
+ self.organ_id += 1
+ return organ_id
+
+ def as_dict(self) -> tuple:
+ """Return the graph as a tuple of node and edge lists.
+
+ Returns:
+ tuple:
+ The graph as a tuple of node and edge lists.
+ """
+ nodes = []
+ for n in self.nodes:
+ nodes.append(n.as_dict())
+
+ edges = []
+ for e in self.edges:
+ edges.append(e.as_dict())
+
+ return nodes, edges
+
+ def as_df(self) -> tuple:
+ """Return the graph as a tuple of node and edge dataframes.
+
+ Returns:
+ tuple:
+ The graph as a tuple of node and edge dataframes.
+ """
+ nodes, edges = self.as_dict()
+ node_df = pd.DataFrame(nodes)
+ edge_df = pd.DataFrame(edges)
+ return node_df, edge_df
+
+ def as_networkx(self) -> nx.Graph:
+ """Return the graph as a NetworkX graph.
+
+ Returns:
+ tuple:
+ The graph in NetworkX format.
+ """
+ node_df, edge_df = self.as_df()
+
+ G = nx.from_pandas_edgelist(
+ edge_df, "parent_id", "child_id", create_using=nx.Graph()
+ )
+
+ node_features = node_df.set_index("node_id", drop=False).T.squeeze().to_dict()
+ nx.set_node_attributes(G, node_features, "x")
+ return G
+
+ def as_torch(self) -> Data:
+ """Return the graph as a PyTorch Geometric graph dataset.
+
+ Returns:
+ tuple:
+ The graph as a PyTorch Geometric graph dataset.
+ """
+ G = self.as_networkx()
+ torch_G = from_networkx(G).double()
+ return torch_G
diff --git a/deeprootgen/model/root.py b/deeprootgen/model/root.py
index 14fddf6..eb1c775 100644
--- a/deeprootgen/model/root.py
+++ b/deeprootgen/model/root.py
@@ -6,254 +6,25 @@
# mypy: ignore-errors
-from typing import List
+from typing import Dict, List
-import networkx as nx
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from numpy.random import default_rng
-from torch_geometric.data import Data
-from torch_geometric.utils import from_networkx
from ..data_model import (
- RootEdgeModel,
RootNodeModel,
RootSimulationModel,
- RootSimulationResults,
+ RootSimulationResultModel,
+ RootType,
+ RootTypeModel,
)
from ..spatial import get_transform_matrix, make_homogenous
+from .hgraph import RootNode, RootSystemGraph
from .soil import Soil
-class RootNode:
- """A node within the hierarchical graph representation of the root system."""
-
- def __init__(self, G: "RootSystemGraph", node_data: RootNodeModel) -> None:
- """RootNode constructor.
-
- Args:
- G: (RootSystemGraph):
- The hierarchical graph representation of the root system.
- node_data (RootNodeModel):
- The root node data model.
-
- Returns:
- RootNode:
- The RootNode instance.
- """
- self.G = G
- self.node_data = node_data
-
- def add_child_node(
- self, child_data: RootNodeModel, new_organ: bool = False
- ) -> "RootNode":
- """Add a child node to the hierarchical graph.
-
- Args:
- child_data (RootNodeModel):
- The node data for the child node.
- new_organ (bool):
- Whether the new child node belongs to a new plant organ.
-
- Returns:
- RootNode:
- The child node.
- """
- if new_organ:
- organ_id = self.G.increment_organ_id()
- segment_rank = 1
- order = self.node_data.order + 1
- else:
- organ_id = self.node_data.organ_id
- segment_rank = self.node_data.segment_rank + 1
- order = self.node_data.order
-
- child_data.parent_id = self.node_data.node_id
- child_data.organ_id = organ_id
- child_data.plant_id = self.node_data.plant_id
- child_data.segment_rank = segment_rank
- child_data.order = order
-
- child_node = self.G.add_node(child_data)
- edge_data = RootEdgeModel(
- parent_id=self.node_data.node_id, child_id=child_node.node_data.node_id
- )
- self.G.add_edge(edge_data)
-
- return child_node
-
- def as_dict(self) -> dict:
- """Return the graph node as a dictionary.
-
- Returns:
- dict:
- The node as a dictionary.
- """
- return self.node_data.dict()
-
-
-class RootEdge:
- """An edge within the hierarchical graph representation of the root system."""
-
- def __init__(self, G: "RootSystemGraph", edge_data: RootEdgeModel) -> None:
- """RootEdge constructor.
-
- Args:
- G: (RootSystemGraph):
- The hierarchical graph representation of the root system.
- edge_data (RootEdgeModel):
- The root edge data model.
-
- Returns:
- RootEdge:
- The RootEdge instance.
- """
- self.G = G
- self.edge_data = edge_data
-
- def as_dict(self) -> dict:
- """Return the graph edge as a dictionary.
-
- Returns:
- dict:
- The edge as a dictionary.
- """
- return self.edge_data.dict()
-
-
-class RootSystemGraph:
- """The hierarchical graph representation of the root system."""
-
- def __init__(self) -> None:
- """RootSystemGraph constructor.
-
- Returns:
- RootSystemSimulation:
- The RootSystemGraph instance.
- """
- self.nodes: List[RootNode] = []
- self.edges: List[RootEdge] = []
- self.node_id = 0
- self.organ_id = 0
-
- # Base organ node
- node_data = RootNodeModel()
- node_data.organ_id = self.increment_organ_id()
- self.base_node = self.add_node(node_data)
-
- def add_node(self, node_data: RootNodeModel) -> RootNode:
- """Construct a new RootNode.
-
- Args:
- node_data (RootNodeModel):
- The root node data model.
-
- Returns:
- RootNode:
- The new RootNode.
- """
- node_data.node_id = self.increment_node_id()
- node = RootNode(self, node_data)
-
- self.nodes.append(node)
- return node
-
- def add_edge(self, edge_data: RootEdgeModel) -> RootEdge:
- """Construct a new RootEdge.
-
- Args:
- node_data (RootEdgeModel):
- The root edge data model.
-
- Returns:
- RootEdge:
- The new RootEdge.
- """
- edge = RootEdge(self, edge_data)
- self.edges.append(edge)
- return edge
-
- def increment_node_id(self) -> int:
- """Increment the node ID.
-
- Returns:
- int:
- The node ID prior to incrementation.
- """
- node_id = self.node_id
- self.node_id += 1
- return node_id
-
- def increment_organ_id(self) -> int:
- """Increment the organ ID.
-
- Returns:
- int:
- The organ ID prior to incrementation.
- """
- organ_id = self.organ_id
- self.organ_id += 1
- return organ_id
-
- def as_dict(self) -> tuple:
- """Return the graph as a tuple of node and edge lists.
-
- Returns:
- tuple:
- The graph as a tuple of node and edge lists.
- """
- nodes = []
- for n in self.nodes:
- nodes.append(n.as_dict())
-
- edges = []
- for e in self.edges:
- edges.append(e.as_dict())
-
- return nodes, edges
-
- def as_df(self) -> tuple:
- """Return the graph as a tuple of node and edge dataframes.
-
- Returns:
- tuple:
- The graph as a tuple of node and edge dataframes.
- """
- nodes, edges = self.as_dict()
- node_df = pd.DataFrame(nodes)
- edge_df = pd.DataFrame(edges)
- return node_df, edge_df
-
- def as_networkx(self) -> nx.Graph:
- """Return the graph as a NetworkX graph.
-
- Returns:
- tuple:
- The graph in NetworkX format.
- """
- node_df, edge_df = self.as_df()
-
- G = nx.from_pandas_edgelist(
- edge_df, "parent_id", "child_id", create_using=nx.Graph()
- )
-
- node_features = node_df.set_index("node_id", drop=False).T.squeeze().to_dict()
- nx.set_node_attributes(G, node_features, "x")
- return G
-
- def as_torch(self) -> Data:
- """Return the graph as a PyTorch Geometric graph dataset.
-
- Returns:
- tuple:
- The graph as a PyTorch Geometric graph dataset.
- """
- G = self.as_networkx()
- torch_G = from_networkx(G).double()
- return torch_G
-
-
class RootOrgan:
"""A single root organ within the root system."""
@@ -261,6 +32,7 @@ def __init__(
self,
parent_node: RootNode,
input_parameters: RootSimulationModel,
+ root_type: RootTypeModel,
simulation_tag: str,
rng: np.random.Generator,
) -> None:
@@ -271,6 +43,8 @@ def __init__(
The parent root node.
input_parameters (RootSimulationModel):
The root simulation data model.
+ root_type (RootTypeModel):
+ The root type data.
simulation_tag (str, optional):
A tag to group together multiple simulations.
rng (np.random.Generator):
@@ -284,10 +58,11 @@ def __init__(
self.segments: List[RootNode] = []
self.child_organs: List["RootOrgan"] = []
self.input_parameters = input_parameters
+ self.root_type = root_type
# Diameter
- self.base_diameter = input_parameters.base_diameter * input_parameters.max_order
- self.diameter_reduction = input_parameters.diameter_reduction
+ self.base_diameter = input_parameters.base_diameter
+ self.fine_root_threshold = input_parameters.fine_root_threshold
# Length
self.proot_length_interval = np.array(
@@ -298,8 +73,10 @@ def __init__(
)
self.length_reduction = input_parameters.length_reduction
+ self.reset_transform()
self.simulation_tag = simulation_tag
self.rng = rng
+ self.invalid_root = False
def init_diameters(self, segments_per_root: int, apex_diameter: int) -> np.ndarray:
"""Initialise root diameters for the root organ.
@@ -310,10 +87,10 @@ def init_diameters(self, segments_per_root: int, apex_diameter: int) -> np.ndarr
apex_diameter (int):
The diameter of the root apex.
"""
- base_diameter = self.base_diameter * self.diameter_reduction ** (
+ diameter_reduction = 1 - self.input_parameters.diameter_reduction
+ base_diameter = self.base_diameter * diameter_reduction ** (
self.parent_node.node_data.order
)
-
if base_diameter > apex_diameter:
ub, lb = base_diameter, apex_diameter
else:
@@ -359,6 +136,8 @@ def add_child_node(
parent_node: RootNode,
diameters: np.ndarray,
lengths: np.ndarray,
+ coordinates: np.ndarray,
+ root_type: RootTypeModel,
i: int,
new_organ: bool = False,
) -> RootNode:
@@ -371,6 +150,10 @@ def add_child_node(
The array of segment diameters.
lengths (np.ndarray):
The array of segment lengths.
+ coordinates (np.ndarray):
+ The 3D coordinates.
+ root_type (RootTypeModel):
+ The root type data model.
i (int):
The current array index.
new_organ (bool, optional):
@@ -382,16 +165,71 @@ def add_child_node(
"""
diameter = diameters[i]
length = lengths[i]
+ x, y, z = coordinates[i]
+
node_data = RootNodeModel(
- diameter=diameter, length=length, simulation_tag=self.simulation_tag
+ x=x,
+ y=y,
+ z=z,
+ diameter=diameter,
+ length=length,
+ mode="marker",
+ root_type=root_type.root_type,
+ order_type=root_type.order_type,
+ position_type=root_type.position_type,
+ simulation_tag=self.simulation_tag,
)
child_node = parent_node.add_child_node(node_data, new_organ=new_organ)
+ child_node.organ = self
self.segments.append(child_node)
return child_node
+ def init_segment_coordinates(
+ self, segments_per_root: int, lengths: np.ndarray
+ ) -> np.ndarray:
+ """Initialise the coordinates of the root segments.
+
+ Args:
+ segments_per_root (int):
+ The number of segments for a single root organ.
+ lengths (np.ndarray):
+ The lengths of each root segment.
+
+ Returns:
+ np.ndarray:
+ The 3D root segment coordinates.
+ """
+ coordinates = [np.repeat(0, 3)]
+ root_vary = self.input_parameters.root_vary
+ y_rotations = self.rng.uniform(-root_vary, root_vary, segments_per_root)
+ z_rotations = self.rng.uniform(-root_vary, root_vary, segments_per_root)
+ # noise = self.rng.uniform(1e-4, 1e-3, segments_per_root)
+
+ for i in range(lengths.shape[0]):
+ segment_length = lengths[i]
+ coord = np.array([np.repeat(segment_length, 3)])
+ homogenous_coordinates = make_homogenous(coord)
+ y_rotate = y_rotations[i]
+ z_rotate = z_rotations[i]
+ current_coord = coordinates[i]
+ transformation_matrix = get_transform_matrix(
+ pitch=y_rotate, yaw=z_rotate, translation=current_coord
+ )
+ transformed_coordinates = (
+ transformation_matrix[:-1] @ homogenous_coordinates
+ )
+ coord = transformed_coordinates.T[0]
+ coordinates.append(coord)
+
+ coordinates = np.array(coordinates)
+ coordinates[:, 2] *= -1
+ return coordinates
+
def construct_root(
- self, segments_per_root: int, apex_diameter: int
+ self,
+ segments_per_root: int,
+ apex_diameter: int,
) -> List[RootNode]:
"""Construct all root segments for the root organ.
@@ -407,14 +245,28 @@ def construct_root(
"""
diameters = self.init_diameters(segments_per_root, apex_diameter)
lengths = self.init_lengths(segments_per_root)
-
- child_node = self.add_child_node(
- self.parent_node, diameters=diameters, lengths=lengths, i=0, new_organ=True
+ coordinates = self.init_segment_coordinates(segments_per_root, lengths)
+
+ self.base_node = self.add_child_node(
+ self.parent_node,
+ diameters=diameters,
+ lengths=lengths,
+ coordinates=coordinates,
+ root_type=self.root_type,
+ i=0,
+ new_organ=True,
)
+ child_node = self.base_node
for i in range(1, segments_per_root):
child_node = self.add_child_node(
- child_node, diameters=diameters, lengths=lengths, i=i, new_organ=False
+ child_node,
+ diameters=diameters,
+ lengths=lengths,
+ coordinates=coordinates,
+ root_type=self.root_type,
+ i=i,
+ new_organ=False,
)
return self.segments
@@ -424,6 +276,10 @@ def add_child_organ(
) -> "RootOrgan":
floor = int(len(self.segments) * floor_threshold)
ceiling = int(len(self.segments) * ceiling_threshold)
+ if floor > ceiling:
+ floor, ceiling = ceiling, floor
+ if floor <= 0:
+ floor = 1
indx = self.rng.integers(floor, ceiling)
parent_node = self.segments[indx]
@@ -431,18 +287,332 @@ def add_child_organ(
child_organ = RootOrgan(
parent_node,
input_parameters=self.input_parameters,
+ root_type=self.root_type,
simulation_tag=self.simulation_tag,
rng=self.rng,
)
self.child_organs.append(child_organ)
return child_organ
+ def construct_root_from_parent(
+ self,
+ segments_per_root: int,
+ apex_diameter: int,
+ ) -> List[RootNode]:
+ """Construct root segments for the root organ, inheriting plant properties from the parent organ.
+
+ Args:
+ segments_per_root (int):
+ The number of segments for a single root organ.
+ apex_diameter (int):
+ The diameter of the root apex.
+
+ Returns:
+ List[RootNode]:
+ The root segments for the root organ.
+ """
+ diameters = self.init_diameters(segments_per_root, apex_diameter)
+ lengths = self.init_lengths(segments_per_root)
+ coordinates = self.init_segment_coordinates(segments_per_root, lengths)
+
+ parent_data = self.parent_node.node_data
+ parent_order = parent_data.order
+ diameters += parent_data.diameter * 0.1**parent_order
+ lengths += parent_data.length * 0.1**parent_order
+
+ avg_diameter = diameters.mean(axis=0)
+ if avg_diameter > self.fine_root_threshold:
+ root_type = RootType.STRUCTURAL.value
+ else:
+ root_type = RootType.FINE.value
+
+ root_type = RootTypeModel(
+ root_type=root_type,
+ order_type=RootType.SECONDARY.value,
+ position_type=self.root_type.position_type,
+ )
+
+ self.base_node = self.add_child_node(
+ self.parent_node,
+ diameters=diameters,
+ lengths=lengths,
+ coordinates=coordinates,
+ root_type=root_type,
+ i=0,
+ new_organ=True,
+ )
+ child_node = self.base_node
+
+ for i in range(1, segments_per_root):
+ child_node = self.add_child_node(
+ child_node,
+ diameters=diameters,
+ lengths=lengths,
+ coordinates=coordinates,
+ root_type=root_type,
+ i=i,
+ new_organ=False,
+ )
+
+ return self.segments
+
+ def reset_transform(self) -> np.ndarray:
+ """Reset the transformation matrix.
+
+ Returns:
+ np.ndarray:
+ The reset transformation matrix.
+ """
+ self.transform_matrix = np.eye(4)
+ return self.transform_matrix
+
+ def update_transform(
+ self,
+ roll: float = 0,
+ pitch: float = 0,
+ yaw: float = 0,
+ translation: List[float] = [0, 0, 0],
+ reflect: List[float] = [1, 1, 1, 1],
+ scale: List[float] = [1, 1, 1, 1],
+ ) -> np.ndarray:
+ """Update the transformation matrix.
+
+ Args:
+ roll (float, optional):
+ The roll transform in degrees. Defaults to 0.
+ pitch (float, optional):
+ The pitch transform in degrees. Defaults to 0.
+ yaw (float, optional):
+ The yaw transform in degrees. Defaults to 0.
+ translation (List[float], optional):
+ The translation transform in degrees. Defaults to [0, 0, 0].
+ reflect (List[float], optional):
+ The reflect transform in degrees. Defaults to [1, 1, 1, 1].
+ scale (List[float], optional):
+ The scale transform in degrees. Defaults to [1, 1, 1, 1].
+
+ Returns:
+ np.ndarray:
+ The updated transformation matrix.
+ """
+ transformation_matrix = get_transform_matrix(
+ roll=roll,
+ pitch=pitch,
+ yaw=yaw,
+ translation=translation,
+ reflect=reflect,
+ scale=scale,
+ )
+ self.transform_matrix = self.transform_matrix @ transformation_matrix
+ return self.transform_matrix
+
+ def cascading_update_transform(
+ self,
+ roll: float = 0,
+ pitch: float = 0,
+ yaw: float = 0,
+ translation: List[float] = [0, 0, 0],
+ reflect: List[float] = [1, 1, 1, 1],
+ scale: List[float] = [1, 1, 1, 1],
+ ) -> None:
+ """Update the transformation matrix for the organ and child organs.
+
+ Args:
+ roll (float, optional):
+ The roll transform in degrees. Defaults to 0.
+ pitch (float, optional):
+ The pitch transform in degrees. Defaults to 0.
+ yaw (float, optional):
+ The yaw transform in degrees. Defaults to 0.
+ translation (List[float], optional):
+ The translation transform in degrees. Defaults to [0, 0, 0].
+ reflect (List[float], optional):
+ The reflect transform in degrees. Defaults to [1, 1, 1, 1].
+ scale (List[float], optional):
+ The scale transform in degrees. Defaults to [1, 1, 1, 1].
+ """
+ self.update_transform(
+ roll=roll,
+ pitch=pitch,
+ yaw=yaw,
+ translation=translation,
+ reflect=reflect,
+ scale=scale,
+ )
+
+ for child_organ in self.child_organs:
+ child_organ.cascading_update_transform(
+ roll=roll,
+ pitch=pitch,
+ yaw=yaw,
+ translation=translation,
+ reflect=reflect,
+ scale=scale,
+ )
+
+ def get_coordinates(self) -> np.ndarray:
+ """Get the coordinates of the root segments.
+
+ Returns:
+ np.ndarray:
+ The coordinates of the root segments
+ """
+ coordinates = []
+ for segment in self.segments:
+ node_data = segment.node_data
+ coordinate = [node_data.x, node_data.y, node_data.z]
+ coordinates.append(coordinate)
+
+ coordinates = np.array(coordinates)
+ return coordinates
+
+ def transform(self) -> np.ndarray:
+ """Apply the transformation matrix to the root system coordinates.
+
+ Returns:
+ np.ndarray:
+ The transformation matrix.
+ """
+
+ coordinates = self.get_coordinates()
+ ones_matrix = np.ones((len(coordinates), 1))
+ homogenous_coordinates = np.hstack((coordinates, ones_matrix)).T
+ transformed_coordinates = self.transform_matrix[:-1] @ homogenous_coordinates
+ coordinates = transformed_coordinates.T
+
+ for i, segment in enumerate(self.segments):
+ node_data = segment.node_data
+ node_data.x, node_data.y, node_data.z = coordinates[i]
+
+ return self.reset_transform()
+
+ def cascading_transform(self) -> None:
+ """Apply the transformation matrix for the organ and child organs."""
+ self.transform()
+ for child_organ in self.child_organs:
+ child_organ.cascading_transform()
+
+ def get_parent_origin(self) -> np.ndarray:
+ """Get the origin of the parent node.
+
+ Returns:
+ np.ndarray:
+ The origin.
+ """
+ node_data = self.parent_node.node_data
+ x, y, z = node_data.x, node_data.y, node_data.z
+
+ origin = np.array([x, y, z])
+ return origin
+
+ def get_local_origin(self) -> np.ndarray:
+ """Get the origin of the current root.
+
+ Returns:
+ np.ndarray:
+ The local origin.
+ """
+ node_data = self.segments[0].node_data
+ origin = np.array([node_data.x, node_data.y, node_data.z])
+ return origin
+
+ def get_apex_coordinates(self) -> np.ndarray:
+ """Get the apex coordinates of the current root.
+
+ Returns:
+ np.ndarray:
+ The apex coordinates.
+ """
+ node_data = self.segments[-1].node_data
+ apex = np.array([node_data.x, node_data.y, node_data.z])
+ return apex
+
+ def cascading_to_world_origin(self) -> None:
+ """
+ Translate all child nodes to the world origin.
+ """
+ local_origin = -self.get_local_origin()
+ self.cascading_update_transform(translation=local_origin)
+
+ def set_invalid_root(self) -> None:
+ """Specify that the root is invalid."""
+ self.invalid_root = True
+ for segment in self.segments:
+ segment.node_data.invalid_root = True
+
+ def cascading_set_invalid_root(self) -> None:
+ """Specify that the root and its children are invalid."""
+ self.set_invalid_root()
+ for child in self.child_organs:
+ child.cascading_set_invalid_root()
+
+ def validate(
+ self, no_root_zone: float, pitch: int = 90, max_attempts: int = 50
+ ) -> None:
+ """Validate the plausibility of the root organ.
+
+ Args:
+ no_root_zone (float):
+ The minimum depth threshold for root growth.
+ pitch (int, optional):
+ Pitch in degrees to rotate roots. Defaults to 90.
+ max_attempts (int, optional):
+ Maximum number of validation attempts. Defaults to 50.
+ """
+ if self.invalid_root:
+ return
+
+ def __transform(**kwargs):
+ """Translate to world origin. Apply transform. Translate back to local origin."""
+ local_origin = self.get_local_origin()
+ self.cascading_to_world_origin()
+ self.cascading_transform()
+ self.cascading_update_transform(**kwargs)
+ self.cascading_transform()
+ self.cascading_update_transform(translation=local_origin)
+ self.cascading_transform()
+
+ coin_flip = self.rng.binomial(1, 0.5)
+ if coin_flip == 1:
+ pitch *= -1
+
+ # No upwards growing roots
+ # Gravitropism
+ iter_count = 0
+ current_order = self.segments[0].node_data.order
+ if current_order > 1:
+ while self.get_apex_coordinates()[2] > self.get_local_origin()[2]:
+ if iter_count > max_attempts:
+ return self.cascading_set_invalid_root()
+ __transform(pitch=pitch)
+ iter_count += 1
+
+ # Coordinates above no root zone
+ iter_count = 0
+ coordinates = self.get_coordinates()
+ while np.any(coordinates[:, 2] > no_root_zone):
+ if iter_count > max_attempts:
+ return self.cascading_set_invalid_root()
+ __transform(pitch=pitch)
+ coordinates = self.get_coordinates()
+ iter_count += 1
+
+ # Remove detached roots
+ if current_order > 1:
+ local_origin = np.around(self.get_local_origin())
+ parent_coordinates = np.around(self.get_parent_origin())
+ if np.any(np.not_equal(local_origin, parent_coordinates)):
+ return self.cascading_set_invalid_root()
+
class RootSystemSimulation:
"""The root system architecture simulation model."""
def __init__(
- self, simulation_tag: str = "default", random_seed: int = None
+ self,
+ simulation_tag: str = "default",
+ random_seed: int = None,
+ visualise: bool = False,
) -> None:
"""RootSystemSimulation constructor.
@@ -451,6 +621,8 @@ def __init__(
A tag to group together multiple simulations. Defaults to 'default'.
random_seed (int, optional):
The seed for the random number generator. Defaults to None.
+ visualise (bool, optional):
+ Whether to visualise the results. Defaults to False
Returns:
RootSystemSimulation:
@@ -458,70 +630,289 @@ def __init__(
"""
self.soil: Soil = Soil()
self.G: RootSystemGraph = RootSystemGraph()
- self.organs: List["RootOrgan"] = []
+ self.organs: Dict[str, List[RootOrgan]] = {}
self.simulation_tag = simulation_tag
+ self.visualise = visualise
self.rng = default_rng(random_seed)
- def run(self, input_parameters: RootSimulationModel) -> RootSimulationResults:
- """Run a root system architecture simulation.
+ def get_yaw(self, number_of_roots: int) -> tuple:
+ """Get the yaw for rotating the root organs.
+
+ Args:
+ number_of_roots (int):
+ The number of roots.
+
+ Returns:
+ tuple:
+ The yaw and base yaw.
+ """
+ yaw_base = 360 / number_of_roots
+ return yaw_base, yaw_base * 0.05, yaw_base
+
+ def plot_root_system(self, fig: go.Figure, node_df: pd.DataFrame) -> go.Figure:
+ """Create a visualisation of the root system.
+
+ Args:
+ fig (int):
+ The base plotly figure.
+ node_df (pd.DataFrame):
+ The root node dataframe.
+
+ Returns:
+ go.Figure:
+ The visualisation of the root system.
+ """
+ node_df = node_df.query("invalid_root == False")
+
+ fig.add_trace(
+ go.Scatter3d(
+ name="root",
+ x=node_df["x"],
+ y=node_df["y"],
+ z=node_df["z"],
+ mode="markers",
+ line=dict(color="green", colorscale="brwnyl", width=10),
+ marker=dict(size=7, color="green", colorscale="brwnyl", opacity=1),
+ customdata=np.stack(
+ (
+ node_df.organ_id,
+ node_df.order,
+ node_df.segment_rank,
+ node_df.diameter,
+ node_df.length,
+ node_df.root_type,
+ node_df.order_type,
+ node_df.position_type,
+ node_df.simulation_tag,
+ ),
+ axis=-1,
+ ),
+ hovertemplate="""
+ x: %{x}
+ y: %{y}
+ z: %{z}
+ Organ ID: %{customdata[0]}
+ Order: %{customdata[1]}
+ Segment rank: %{customdata[2]}
+ Diameter: %{customdata[3]}
+ Length: %{customdata[4]}
+ Root type: %{customdata[5]}
+ Order type: %{customdata[6]}
+ Position type: %{customdata[7]}
+ Simulation tag: %{customdata[8]}
""",
+ )
+ )
+ fig.update_traces(connectgaps=False)
+ return fig
+
+ def init_fig(self, input_parameters: RootSimulationModel) -> go.Figure | None:
+ """Initialise the root system figure.
Args:
input_parameters (RootSimulationModel):
The root simulation data model.
Returns:
- dict:
- The simulation results.
+ go.Figure | None:
+ The root system visualisation.
"""
- # Initialise figure (optionally with soil)
- if input_parameters.enable_soil:
- soil_df = self.soil.create_soil_grid(
- input_parameters.soil_layer_height,
- input_parameters.soil_n_layers,
- input_parameters.soil_layer_width,
- input_parameters.soil_n_cols,
+ fig = None
+ if self.visualise:
+ # Initialise figure (optionally with soil)
+ if input_parameters.enable_soil:
+ soil_df = self.soil.create_soil_grid(
+ input_parameters.soil_layer_height,
+ input_parameters.soil_n_layers,
+ input_parameters.soil_layer_width,
+ input_parameters.soil_n_cols,
+ )
+
+ fig = self.soil.create_soil_fig(soil_df)
+ else:
+ fig = go.Figure()
+
+ fig.update_layout(
+ scene=dict(
+ xaxis=dict(title="x"), yaxis=dict(title="y"), zaxis=dict(title="z")
+ )
)
- fig = self.soil.create_soil_fig(soil_df)
- else:
- fig = go.Figure()
+ return fig
- fig.update_layout(
- scene=dict(
- xaxis=dict(title="x"), yaxis=dict(title="y"), zaxis=dict(title="z")
- )
- )
+ def init_organs(
+ self, input_parameters: RootSimulationModel
+ ) -> Dict[str, List[RootOrgan]]:
+ """Initialise the root organs for the simulation.
+ Args:
+ input_parameters (RootSimulationModel):
+ The root simulation data model.
+
+ Returns:
+ Dict[str, List[RootOrgan]]:
+ The initialised root organs.
+ """
+ for order in range(1, input_parameters.max_order + 1):
+ self.organs[order] = []
+
+ order = 1
segments_per_root = input_parameters.segments_per_root
apex_diameter = input_parameters.apex_diameter
- floor_threshold = input_parameters.floor_threshold
- ceiling_threshold = input_parameters.ceiling_threshold
- organ = RootOrgan(
- self.G.base_node,
- input_parameters=input_parameters,
- simulation_tag=self.simulation_tag,
- rng=self.rng,
+ root_type = RootTypeModel(
+ root_type=RootType.STRUCTURAL.value,
+ order_type=RootType.PRIMARY.value,
+ position_type=RootType.OUTER.value,
)
- organ.construct_root(segments_per_root, apex_diameter)
-
- df, _ = self.G.as_df()
-
- fig.add_trace(
- go.Scatter3d(
- x=df["x"],
- y=df["y"],
- z=df["z"],
- mode="markers",
- marker=dict(size=6, color="green", colorscale="brwnyl", opacity=1),
+ for _ in range(input_parameters.outer_root_num):
+ organ = RootOrgan(
+ self.G.base_node,
+ input_parameters=input_parameters,
+ root_type=root_type,
+ simulation_tag=self.simulation_tag,
+ rng=self.rng,
)
+ organ.construct_root(segments_per_root, apex_diameter)
+ self.organs[order].append(organ)
+
+ root_type = RootTypeModel(
+ root_type=RootType.STRUCTURAL.value,
+ order_type=RootType.PRIMARY.value,
+ position_type=RootType.INNER.value,
)
+ for _ in range(input_parameters.inner_root_num):
+ organ = RootOrgan(
+ self.G.base_node,
+ input_parameters=input_parameters,
+ root_type=root_type,
+ simulation_tag=self.simulation_tag,
+ rng=self.rng,
+ )
+ organ.construct_root(segments_per_root, apex_diameter)
+ self.organs[order].append(organ)
+
+ min_sec_root_num = input_parameters.min_sec_root_num
+ max_sec_root_num = input_parameters.max_sec_root_num
+ if min_sec_root_num > max_sec_root_num:
+ min_sec_root_num, max_sec_root_num = max_sec_root_num, min_sec_root_num
+
+ for order in range(2, input_parameters.max_order + 1):
+ prev_order = order - 1
+ growth_sec_root = (1 - input_parameters.growth_sec_root) ** -(order - 2)
+
+ for parent_organ in self.organs[prev_order]:
+ n_secondary_roots = self.rng.integers(
+ min_sec_root_num, max_sec_root_num
+ )
+ n_secondary_roots = int(n_secondary_roots * growth_sec_root)
+ for _ in range(n_secondary_roots):
+ child_organ = parent_organ.add_child_organ(
+ floor_threshold=input_parameters.floor_threshold,
+ ceiling_threshold=input_parameters.ceiling_threshold,
+ )
+ self.organs[order].append(child_organ)
+ child_organ.construct_root_from_parent(
+ segments_per_root,
+ apex_diameter,
+ )
+
+ return self.organs
+
+ def position_secondary_roots(self, input_parameters: RootSimulationModel) -> None:
+ """Position secondary roots about the origin.
- child_organ = organ.add_child_organ(floor_threshold, ceiling_threshold)
- child_organ.construct_root(segments_per_root, apex_diameter)
+ Args:
+ input_parameters (RootSimulationModel):
+ The root simulation data model.
+ """
+ for order in range(2, input_parameters.max_order + 1):
+ for secondary_root in self.organs[order]:
+ yaw = self.rng.uniform(-30, 240)
+ pitch, roll = self.rng.uniform(60, 110, 2)
+ secondary_root.update_transform(yaw=yaw)
+ secondary_root.update_transform(pitch=pitch, roll=-roll)
+ secondary_root.transform()
+ secondary_root.update_transform(pitch=-45, roll=35)
+ secondary_root.transform()
+
+ for order in range(input_parameters.max_order, 1, -1):
+ for secondary_root in self.organs[order]:
+ parent_origin = secondary_root.get_parent_origin()
+ secondary_root.cascading_update_transform(translation=parent_origin)
+ secondary_root.cascading_transform()
+
+ def position_primary_roots(self, input_parameters: RootSimulationModel) -> None:
+ """Position primary roots about the origin.
+
+ Args:
+ input_parameters (RootSimulationModel):
+ The root simulation data model.
+ """
+ position_type = RootType.OUTER.value
+ yaw_base, yaw_noise_base, yaw = self.get_yaw(input_parameters.outer_root_num)
+ for primary_root in self.organs[1]:
+ if primary_root.root_type.position_type != position_type:
+ continue
+ pitch = self.rng.uniform(-20, -15)
+ primary_root.cascading_update_transform(pitch=pitch, yaw=yaw)
+ primary_root.cascading_transform()
+ yaw += yaw_base + self.rng.uniform(-yaw_noise_base, yaw_noise_base)
+
+ position_type = RootType.INNER.value
+ yaw_base, yaw_noise_base, yaw = self.get_yaw(input_parameters.inner_root_num)
+ for primary_root in self.organs[1]:
+ if primary_root.root_type.position_type != position_type:
+ continue
+ pitch = self.rng.uniform(0, 45)
+ primary_root.cascading_update_transform(pitch=pitch, yaw=yaw)
+ primary_root.cascading_transform()
+ yaw += yaw_base + self.rng.uniform(-yaw_noise_base, yaw_noise_base)
+
+ def validate(
+ self,
+ input_parameters: RootSimulationModel,
+ pitch: int = 45,
+ ) -> None:
+ """Validate the plausibility of the root system.
+
+ Args:
+ input_parameters (RootSimulationModel):
+ The root simulation data model.
+ pitch (int, optional):
+ Pitch in degrees to rotate roots. Defaults to 90.
+ """
+ for order in range(1, input_parameters.max_order + 1):
+ for organ in self.organs[order]:
+ organ.validate(
+ input_parameters.no_root_zone,
+ pitch,
+ input_parameters.max_val_attempts,
+ )
+
+ def run(self, input_parameters: RootSimulationModel) -> RootSimulationResultModel:
+ """Run a root system architecture simulation.
+
+ Args:
+ input_parameters (RootSimulationModel):
+ The root simulation data model.
+
+ Returns:
+ dict:
+ The simulation results.
+ """
+ fig = self.init_fig(input_parameters)
+ self.init_organs(input_parameters)
+ self.position_secondary_roots(input_parameters)
+ self.position_primary_roots(input_parameters)
+ self.validate(input_parameters, pitch=60)
nodes, edges = self.G.as_dict()
- results = RootSimulationResults(
+ node_df = pd.DataFrame(nodes)
+
+ if self.visualise:
+ fig = self.plot_root_system(fig, node_df)
+
+ results = RootSimulationResultModel(
nodes=nodes,
edges=edges,
figure=fig,
diff --git a/deeprootgen/model/soil.py b/deeprootgen/model/soil.py
index 2906921..46c8f57 100644
--- a/deeprootgen/model/soil.py
+++ b/deeprootgen/model/soil.py
@@ -44,7 +44,7 @@ def create_soil_grid(
# Create unit hypercube
x = np.linspace(0, 1, n_cols)
y = np.linspace(0, 1, n_layers)
- z = np.linspace(0, 1, 2)
+ z = np.linspace(0, 1, n_cols)
M = np.meshgrid(x, y, z)
grid = [vector.flatten() for vector in M]
@@ -53,8 +53,8 @@ def create_soil_grid(
xv *= voxel_width * (n_cols - 1)
xv -= voxel_width / 2 * (n_cols - 1)
yv *= -voxel_height * (n_layers - 1)
- zv *= voxel_width
- zv -= voxel_width / 2
+ zv *= voxel_width * (n_cols - 1)
+ zv -= voxel_width / 2 * (n_cols - 1)
soil_df = pd.DataFrame({"x": xv, "y": yv, "z": zv})
return soil_df
@@ -73,6 +73,7 @@ def create_soil_fig(self, soil_df: pd.DataFrame) -> go.Figure:
fig = go.Figure(
data=[
go.Scatter3d(
+ name="soil",
x=soil_df["x"],
y=soil_df["z"],
z=soil_df["y"],
diff --git a/docs/source/api_reference/model.rst b/docs/source/api_reference/model.rst
index 616b04f..ce4ba40 100644
--- a/docs/source/api_reference/model.rst
+++ b/docs/source/api_reference/model.rst
@@ -1,6 +1,12 @@
Model
=====
+Graph
+-----
+
+.. automodule:: deeprootgen.model.graph
+ :members:
+
Root
----