Repository: cogent3/cogent3
Pull request #2362 (2025-06-18 05:40:07)

ENH: added tip_to_root_distances method to TreeNode and PhyloTree
[NEW] returns {tipname: cumulative sum of lengths to root, ...}. Can provide
    a list of tip names to the select.

## Summary by Sourcery

Introduce tip_to_root_distances to compute cumulative lengths from each tip to the root with optional filtering and default length fallback

New Features:
- Add tip_to_root_distances method to TreeNode for counting edges to root with default lengths
- Add tip_to_root_distances method to PhyloTree for summing actual branch lengths to root

Tests:
- Add tests to verify tip_to_root_distances output for both tree classes
- Add tests for selective tip name filtering and error raising on missing names


Patch:
diff --git a/src/cogent3/core/tree.py b/src/cogent3/core/tree.py
index 2c8da148a..39a338023 100644
--- a/src/cogent3/core/tree.py
+++ b/src/cogent3/core/tree.py
@@ -1666,6 +1666,40 @@ class TreeNode:
             known_weight += 1
         return edge.unrooted_deepcopy()
 
+    def tip_to_root_distances(
+        self, names: list[str] | None = None, default_length: float = 1
+    ) -> dict[str, float]:
+        """returns the cumulative sum of lengths from each tip to the root
+
+        Parameters
+        ----------
+        names
+            list of tip names to calculate distances for, defaults to all
+        default_length
+            value to use for edges that do not have a length attribute
+
+        Notes
+        -----
+        For the tree node this counts the number of edges from each tip to the root.
+        """
+        tips = list(self.tips())
+        if names is not None:
+            tips = [t for t in tips if t.name in names]
+
+        if not tips:
+            msg = f"No tips matching in {names!r}"
+            raise TreeError(msg)
+
+        dists = {}
+        for tip in tips:
+            node = tip
+            cum_sum = 0.0
+            while node.parent is not None:
+                cum_sum += default_length
+                node = node.parent
+            dists[tip.name] = cum_sum
+        return dists
+
     def same_topology(self, other):
         """Tests whether two trees have the same topology."""
         tip_names = self.get_tip_names()
@@ -2328,6 +2362,36 @@ class PhyloNode(TreeNode):
         name, attrs = split_name_and_support(token)
         return (name, {"support": attrs}) if attrs else (name, {})
 
+    def tip_to_root_distances(
+        self, names: list[str] | None = None, default_length: float = 1
+    ) -> dict[str, float]:
+        """returns the cumulative sum of lengths from each tip to the root
+
+        Parameters
+        ----------
+        names
+            list of tip names to calculate distances for, defaults to all
+        default_length
+            value to use for edges that no length value
+        """
+        tips = list(self.tips())
+        if names is not None:
+            tips = [t for t in tips if t.name in names]
+
+        if not tips:
+            msg = f"No tips matching in {names!r}"
+            raise TreeError(msg)
+
+        dists = {}
+        for tip in tips:
+            node = tip
+            cum_sum = 0.0
+            while node.parent is not None:
+                cum_sum += node.length or default_length
+                node = node.parent
+            dists[tip.name] = cum_sum
+        return dists
+
 
 def split_name_and_support(name_field: str | None) -> tuple[str | None, float | None]:
     """Handle cases in the Newick format where an internal node name field


Test patch:
diff --git a/tests/test_core/test_tree.py b/tests/test_core/test_tree.py
index 56eaff15d..4a7b16bc2 100644
--- a/tests/test_core/test_tree.py
+++ b/tests/test_core/test_tree.py
@@ -1687,6 +1687,44 @@ def test__find_midpoint_nodes(phylo_nodes, phylo_root):
     assert result == (nodes["f"], nodes["c"])
 
 
+def test_tip_to_root_distances_phlylo():
+    treestring = "(a:1,b:4,((c:3,d:2,e:1):5):2);"
+    tree = make_tree(treestring)
+    expect = {"a": 1, "b": 4, "c": 10, "d": 9, "e": 8}
+    got = tree.tip_to_root_distances()
+    assert got == expect
+    got = tree.tip_to_root_distances(
+        names=[
+            "c",
+            "a",
+        ]
+    )
+    assert got == {"c": 10, "a": 1}
+
+
+def test_tip_to_root_distances_tree():
+    treestring = "(a:1,b:4,((c:3,d:2,e:2):5):2);"
+    tree = DndParser(treestring, constructor=TreeNode)
+    expect = {"a": 1, "b": 1, "c": 3, "d": 3, "e": 3}
+    got = tree.tip_to_root_distances()
+    assert got == expect
+    got = tree.tip_to_root_distances(
+        names=[
+            "c",
+            "a",
+        ]
+    )
+    assert got == {"c": 3, "a": 1}
+
+
+@pytest.mark.parametrize("cls", [TreeNode, PhyloNode])
+def test_tip_to_root_distances_missing_names(cls):
+    treestring = "(a:1,b:4,((c:3,d:2,e:2):5):2);"
+    tree = DndParser(treestring, constructor=cls)
+    with pytest.raises(TreeError):
+        tree.tip_to_root_distances(names=["x"])
+
+
 def test_root_at_midpoint(phylo_nodes, phylo_root):
     """root_at_midpoint performs midpoint rooting"""
     nodes, tree = phylo_nodes, phylo_root