Repository: cogent3/cogent3
Pull request #2353 (2025-06-13 05:18:28)

Method to truly root tree nodes
## Summary by Sourcery

Provide a clearer rooting API by renaming root() to get_root(), deprecating the old alias, adding a rooted() method for rerooting, and adjusting code, tests, and changelog accordingly.

New Features:
- Introduce TreeNode.get_root() as the non‐misleading method for retrieving a tree’s root
- Add TreeNode.rooted() to produce a new tree rerooted at a specified edge, tip, or internal node

Enhancements:
- Deprecate the old TreeNode.root() alias in favor of get_root()
- Replace internal calls to root() with get_root()
- Simplify name_unnamed_nodes() by using a list comprehension

Documentation:
- Update changelog to document deprecation of TreeNode.root()

Tests:
- Update existing root() tests to use get_root()
- Add comprehensive tests for the new rooted() method


Patch:
diff --git a/changelog.d/20250604_092738_Gavin.Huttley.md b/changelog.d/20250604_092738_Gavin.Huttley.md
index 3a1f8921f..a7f35389d 100644
--- a/changelog.d/20250604_092738_Gavin.Huttley.md
+++ b/changelog.d/20250604_092738_Gavin.Huttley.md
@@ -8,12 +8,16 @@ Uncomment the section that is right (remove the HTML comment wrapper).
 ### Contributors
 
 - rmcar17, fixed display of support in dendrograms
-- GavinHuttley, MPI rank check fix, plus modified support display for dendrograms
+- GavinHuttley, assorted
 
 
 ### Enhancements
 
-- A bullet item for the Enhancements category.
+- PhyloNode.rooted() and TreeNode.rooted() now return trees that bifurcate
+  at a named edge. Addresses an issue rasied by @xonq -- thanks!
+- Dendrogram(support_is_percent=True) argument means that thresholds and support
+  statistics on dendrograms are treated as percentages. These are rounded to the
+  nearest integer for display. This is now the default.
 
 ### Bug fixes
 
@@ -21,9 +25,6 @@ Uncomment the section that is right (remove the HTML comment wrapper).
   when running using MPI. This was affecting the ability to create
   data stores when running with MPI.
 - fix for displaying support statistics on dendrograms
-- Dendrogram(support_is_percent=True) argument means that thresholds and support
-  statistics on dendrograms are treated as percentages. These are rounded to the
-  nearest integer for display. This is now the default.
 
 <!--
 ### Documentation
@@ -31,12 +32,13 @@ Uncomment the section that is right (remove the HTML comment wrapper).
 - A bullet item for the Documentation category.
 
 -->
-<!--
+
 ### Deprecations
 
-- A bullet item for the Deprecations category.
+- Deprecate TreeNode.root() in favour of TreeNode.get_root(). Also
+  applies to PhyloNode too.
+
 
--->
 <!--
 ### Discontinued
 
diff --git a/src/cogent3/core/tree.py b/src/cogent3/core/tree.py
index fb4b9e461..32b6fb702 100644
--- a/src/cogent3/core/tree.py
+++ b/src/cogent3/core/tree.py
@@ -38,6 +38,7 @@ from itertools import combinations
 from operator import or_
 from random import choice, shuffle
 
+import typing_extensions
 from numpy import argsort, ceil, log, zeros
 
 from cogent3._version import __version__
@@ -46,6 +47,7 @@ from cogent3.parse.cogent3_json import load_from_json
 from cogent3.parse.newick import parse_string as newick_parse_string
 from cogent3.parse.tree_xml import parse_string as tree_xml_parse_string
 from cogent3.phylo.tree_distance import get_tree_distance_measure
+from cogent3.util import warning as c3warn
 from cogent3.util.io import atomic_write, get_format_suffixes, open_
 from cogent3.util.misc import get_object_provenance
 
@@ -461,13 +463,71 @@ class TreeNode:
             curr = curr._parent
         return result
 
-    def root(self):
-        """Returns root of the tree self is in. Dynamically calculated."""
+    def get_root(self) -> typing_extensions.Self:
+        """Returns root of the tree self is in."""
         curr = self
         while curr._parent is not None:
             curr = curr._parent
         return curr
 
+    @c3warn.deprecated_callable(
+        "2025.9", reason="misleading method name", new="get_root()"
+    )
+    def root(self) -> typing_extensions.Self:
+        return self.get_root()
+
+    def rooted(self, edge_name: str) -> typing_extensions.Self:
+        """Returns a new tree with split at edge_name
+
+        Parameters
+        ----------
+        edge_name
+            name of the edge to split at. The length of edge_name will be
+            halved. The new tree will have two children.
+        """
+        tree = self.deepcopy()
+        if self.name != "root":
+            msg = (
+                f"cannot apply from non-root node {self.name!r}, "
+                "use self.get_root() first"
+            )
+            raise TreeError(msg)
+
+        if edge_name == "root":
+            if len(self.children) == 2:
+                return tree
+
+            msg = "cannot root at existing root"
+            raise TreeError(msg)
+
+        tree.source = None
+        node = tree.get_node_matching_name(edge_name)
+        is_tip = node.is_tip()
+        has_length = hasattr(node, "length")
+        # we put tips on the right
+        right_name = edge_name if is_tip else f"{edge_name}-R"
+        left_name = f"{edge_name}-root" if is_tip else f"{edge_name}-L"
+        length = getattr(node, "length", 0.0) / 2
+        parent = node.parent
+        parent.children.remove(node)
+        node.parent = None
+        left = node.unrooted_deepcopy()
+        right = parent.unrooted_deepcopy()
+        if is_tip and left.is_tip():
+            left.name = right_name
+            right.name = left_name
+        else:
+            left.name = left_name
+            right.name = right_name
+
+        if has_length:
+            left.length = length
+            right.length = length
+
+        result = self.__class__(name="root", children=[left, right])
+        result.source = self.source
+        return result
+
     def isroot(self):
         """Returns True if root of a tree, i.e. no parent."""
         return self.is_root()
@@ -676,10 +736,7 @@ class TreeNode:
         Internal nodes are often unnamed and so this function assigns a
         value for referencing."""
         # make a list of the names that are already in the tree
-        names_in_use = []
-        for node in self.traverse():
-            if node.name:
-                names_in_use.append(node.name)
+        names_in_use = [node.name for node in self.traverse() if node.name]
         # assign unique names to the Data property of nodes where Data = None
         name_index = 1
         for node in self.traverse():
@@ -1796,7 +1853,7 @@ class PhyloNode(TreeNode):
 
     def total_length(self):
         """returns the sum of all branch lengths in tree"""
-        root = self.root()
+        root = self.get_root()
         if root is None:
             msg = "no root to this tree!"
             raise ValueError(msg)


Test patch:
diff --git a/tests/test_core/test_tree.py b/tests/test_core/test_tree.py
index 4730f0775..0575495dd 100644
--- a/tests/test_core/test_tree.py
+++ b/tests/test_core/test_tree.py
@@ -902,7 +902,7 @@ def test_root(tree_nodes, tree_root):
     """TreeNode root() should find root of tree"""
     nodes, root = tree_nodes, tree_root
     for i in list(nodes.values()):
-        assert i.root() is root
+        assert i.get_root() is root
 
 
 def test_children(tree_nodes):
@@ -2630,3 +2630,58 @@ def test_source_attr(cls):
 def test_dataset_tree_source():
     tr = get_dataset("mammal-tree")
     assert tr.source.endswith("murphy.tree")
+
+
+def test_rooted_is_rooted():
+    treestring = "((Human,Chimpanzee),Gorilla)"
+    tree = make_tree(treestring=treestring)
+    rooted = tree.rooted("root")
+    assert rooted.same_topology(tree)
+
+
+def test_rooted_with_root():
+    treestring = "(Human,Chimpanzee,Gorilla)"
+    tree = make_tree(treestring=treestring)
+    with pytest.raises(TreeError):
+        # cannot root at root with 3 children
+        tree.rooted("root")
+
+
+def test_rooted_from_nonroot():
+    treestring = "(Human,Chimpanzee,Gorilla)"
+    tree = make_tree(treestring=treestring)
+    child = tree.children[0]
+    with pytest.raises(TreeError):
+        # cannot root if node is not the root
+        child.rooted("Gorilla")
+
+
+def test_rooted_with_tip():
+    treestring = "((((Rhesus:0.02,HowlerMon:0.0487):0.0115,Orangutan:0.008)abcd:0.003,Human:0.006):0.00013,Gorilla:0.0025,Chimpanzee:0.003)"
+    tree = make_tree(treestring=treestring)
+    tip_name = "HowlerMon"
+    rooted = tree.rooted(tip_name)
+    assert len(rooted.children) == 2
+    assert all(child.length == 0.0487 / 2 for child in rooted.children)
+    assert {c.name for c in rooted.children} == {tip_name, f"{tip_name}-root"}
+
+
+@pytest.mark.parametrize("constructor", [PhyloNode, TreeNode])
+def test_rooted_with_internal(constructor):
+    treestring = "((((Rhesus:0.02,HowlerMon:0.0487):0.0115,Orangutan:0.008)abcd:0.003,Human:0.006):0.00013,Gorilla:0.0025,Chimpanzee:0.003)"
+    tree = DndParser(treestring, constructor=constructor)
+    tree.name = "root"  # have to force this given construction via DndParser
+    node_name = "abcd"
+    got = tree.rooted(node_name)
+    ts1 = "(Human,(Gorilla,Chimpanzee))"
+    ts2 = "((Rhesus,HowlerMon),Orangutan)"
+    child1, child2 = got.children
+    child1.parent = None
+    child1.name = "root"
+    child2.parent = None
+    child2.name = "root"
+    ts1, ts2 = (ts1, ts2) if "Human" in child1.get_tip_names() else (ts2, ts1)
+    expect1 = make_tree(treestring=ts1)
+    expect2 = make_tree(treestring=ts2)
+    assert child1.same_topology(expect1)
+    assert child2.same_topology(expect2)