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 ----