Source code for codonadaptpy.phylo_visualization

"""
CodonAdaptPy ETE3 Phylogenetic Visualization

This module converts ETE3 tree objects into publication-ready Matplotlib
figures. ETE3 supplies topology parsing, rooting, traversal, pruning, and node
metadata; Matplotlib supplies stable headless rendering to PNG and vector PDF
without requiring ETE3's optional Qt graphical stack.

The plotting methods preserve branch lengths, show optional support values,
use colorblind-safe group encodings, and distinguish deterministic time-tree
and lineage-through-time outputs from Bayesian chronograms or skyline plots.

Classes:
    - ETE3TreePlotter: Rectangular ML trees, time-scaled trees, root-to-tip
      regressions, and lineage-through-time trajectory figures.

:Created: July 20, 2026
:Updated: July 20, 2026
:Author: Naveen Duhan
:Version: 1.0.2
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any


[docs] class ETE3TreePlotter: """Render ETE3 phylogenies with consistent publication styling.""" COLORS = ("#0072B2", "#E69F00", "#009E73", "#CC79A7", "#7A7A7A", "#D55E00") MARKERS = ("o", "s", "^", "D", "P", "X") @staticmethod def _matplotlib() -> tuple[Any, Any]: """Import and configure Matplotlib only when a plot is requested.""" try: import matplotlib as mpl import matplotlib.pyplot as plt except ImportError as exc: raise RuntimeError("Phylogenetic plotting requires matplotlib; install the 'plot' extra.") from exc mpl.rcParams.update( { "font.family": "sans-serif", "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"], "font.size": 8, "axes.labelsize": 9, "axes.titlesize": 10, "xtick.labelsize": 7, "ytick.labelsize": 7, "pdf.fonttype": 42, "ps.fonttype": 42, } ) return plt, mpl @staticmethod def _import_ete3() -> Any: """Import ETE3 Tree with Python >= 3.13 cgi compatibility.""" import sys import types if "cgi" not in sys.modules: dummy_cgi = types.ModuleType("cgi") dummy_cgi.escape = ( lambda text, quote=True: text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") ) sys.modules["cgi"] = dummy_cgi from ete3 import Tree return Tree @classmethod def _tree(cls, tree: str | Path | Any) -> Any: """Load or copy a tree through ETE3.""" try: Tree = cls._import_ete3() except (ImportError, ModuleNotFoundError) as exc: raise RuntimeError("Tree plotting requires ete3; install the 'phylo' extra.") from exc tree_object: Any = tree if hasattr(tree_object, "traverse"): return tree_object.copy(method="deepcopy") try: return Tree(str(tree), format=0) except Exception: return Tree(str(tree), format=1) @staticmethod def _coordinates(tree: Any, *, time_scaled: bool = False) -> tuple[dict[Any, float], dict[Any, float]]: """Calculate rectangular-tree x/y coordinates for all ETE3 nodes.""" leaves = list(tree.iter_leaves()) y = {leaf: float(len(leaves) - index - 1) for index, leaf in enumerate(leaves)} for node in tree.traverse("postorder"): if not node.is_leaf(): y[node] = sum(y[child] for child in node.children) / len(node.children) if time_scaled: if any(not hasattr(node, "calendar_date") for node in tree.traverse()): raise ValueError("Every tree node must contain calendar_date for time-tree plotting.") x = {node: float(node.calendar_date) for node in tree.traverse()} else: x = {node: float(tree.get_distance(node)) for node in tree.traverse()} return x, y @staticmethod def _clean_axis(axis: Any) -> None: """Apply an open, outward-spine publication style.""" axis.spines["top"].set_visible(False) axis.spines["right"].set_visible(False) axis.spines["left"].set_position(("outward", 5)) axis.spines["bottom"].set_position(("outward", 5))
[docs] def tree( self, tree: str | Path | Any, *, groups: Mapping[str, str] | None = None, title: str = "Maximum-likelihood phylogeny", show_support: bool = True, minimum_support: float = 70.0, time_scaled: bool = False, label_tips: bool = True, align_tip_labels: bool = True, tip_font_size: float = 7.0, support_font_size: float = 6.5, ) -> Any: """Plot a branch-length or calendar-time rectangular phylogeny. Parameters: tree: Newick path/string or ETE3 tree. groups: Optional mapping from leaf name to genotype/group. show_support: Label internal support at or above ``minimum_support``. time_scaled: Use each node's ``calendar_date`` feature for x. label_tips: Show leaf names; disable for dense overview trees. align_tip_labels: Place names in one right-hand column connected to their terminal nodes by dotted leader lines. tip_font_size: Tip-label size in points. The default remains legible when a dense tree is reproduced at journal width. support_font_size: Internal branch-support label size in points. """ loaded = self._tree(tree) if len(loaded) < 2: raise ValueError("A plotted phylogeny must contain at least two leaves.") plt, _ = self._matplotlib() if tip_font_size <= 0 or support_font_size <= 0: raise ValueError("Tree label font sizes must be positive.") height = max(4.5, min(14.0, len(loaded) * (0.16 if label_tips else 0.08))) figure, axis = plt.subplots(figsize=(9.2, height), constrained_layout=True) x, y = self._coordinates(loaded, time_scaled=time_scaled) x_span = max(x.values()) - min(x.values()) label_x = max(x.values()) + max(x_span * 0.025, 1e-6) known_groups = sorted({groups.get(leaf.name, "Unknown") for leaf in loaded.iter_leaves()}) if groups else [] palette = {group: self.COLORS[index % len(self.COLORS)] for index, group in enumerate(known_groups)} for node in loaded.traverse("preorder"): if node.is_leaf(): continue child_y = [y[child] for child in node.children] axis.plot([x[node], x[node]], [min(child_y), max(child_y)], color="#4D4D4D", linewidth=0.65) for child in node.children: axis.plot([x[node], x[child]], [y[child], y[child]], color="#4D4D4D", linewidth=0.65) support = float(getattr(node, "support", 0.0)) support_percent = support * 100 if 0 < support <= 1 else support visible_branch = not node.is_root() and abs(x[node] - x[node.up]) >= max(x_span * 0.008, 1e-12) if show_support and support_percent >= minimum_support and visible_branch: axis.text( x[node], y[node] + 0.16, f"{support_percent:.0f}", ha="center", va="bottom", fontsize=support_font_size, ) for leaf in loaded.iter_leaves(): group = groups.get(leaf.name, "Unknown") if groups else "Samples" color = palette.get(group, self.COLORS[0]) axis.scatter(x[leaf], y[leaf], s=12, color=color, edgecolor="white", linewidth=0.25, zorder=3) if label_tips: text_x = label_x if align_tip_labels else x[leaf] if align_tip_labels: axis.plot( [x[leaf], label_x], [y[leaf], y[leaf]], color="#A6A6A6", linestyle=(0, (1.2, 2.0)), linewidth=0.45, zorder=1, ) axis.annotate( leaf.name, (text_x, y[leaf]), xytext=(3, 0), textcoords="offset points", va="center", fontsize=tip_font_size, color=color, ) if groups: handles = [ axis.scatter([], [], s=18, color=palette[group], label=group, edgecolor="white", linewidth=0.3) for group in known_groups ] axis.legend( handles=handles, title="Group", frameon=False, loc="lower center", bbox_to_anchor=(0.5, 1.005), ncol=len(handles), columnspacing=1.4, handletextpad=0.45, borderaxespad=0.0, fontsize=8, title_fontsize=8, ) axis.set_yticks([]) axis.set_xlabel("Calendar year" if time_scaled else "Substitutions per site") axis.set_title(title, fontweight="bold", pad=50 if groups else 10) self._clean_axis(axis) axis.spines["left"].set_visible(False) return figure
[docs] def temporal_signal( self, analysis: Any, *, groups: Mapping[str, str] | None = None, title: str = "Root-to-tip temporal signal", ) -> Any: """Plot root-to-tip regression with sample identities and group encoding.""" plt, _ = self._matplotlib() figure, axis = plt.subplots(figsize=(5.2, 3.8), constrained_layout=True) names = sorted(analysis.sample_dates, key=analysis.sample_dates.get) known_groups = sorted({groups.get(name, "Unknown") for name in names}) if groups else ["Samples"] palette = {group: self.COLORS[index % len(self.COLORS)] for index, group in enumerate(known_groups)} for index, group in enumerate(known_groups): selected = [name for name in names if (groups.get(name, "Unknown") if groups else "Samples") == group] axis.scatter( [analysis.sample_dates[name] for name in selected], [analysis.root_to_tip[name] for name in selected], color=palette[group], marker=self.MARKERS[index % len(self.MARKERS)], s=26, alpha=0.78, edgecolor="white", linewidth=0.35, label=f"{group} (n={len(selected)})", ) limits = [min(analysis.sample_dates.values()), max(analysis.sample_dates.values())] axis.plot( limits, [analysis.intercept + analysis.rate * value for value in limits], color="#222222", linestyle="--", linewidth=1.0, ) axis.text( 0.02, 0.98, f"rate = {analysis.rate:.3g} substitutions/site/year\n" f"R² = {analysis.r_squared:.3f}; p = {analysis.p_value:.3g}\n" f"fitted root = {analysis.root_date:.1f}", transform=axis.transAxes, va="top", fontsize=7, ) axis.set(xlabel="Sampling year", ylabel="Root-to-tip divergence", title=title) axis.legend(frameon=False, loc="lower right", fontsize=7) self._clean_axis(axis) return figure
[docs] def lineage_through_time( self, trajectories: Mapping[str, Mapping[str, Sequence[float]]], *, title: str = "Lineage-through-time trajectories", normalize: bool = True, ) -> Any: """Plot deterministic dated-tree lineage trajectories. This figure is an exploratory demographic proxy. It does not estimate effective population size and its lines are not Bayesian posterior medians or highest-posterior-density intervals. """ if not trajectories: raise ValueError("At least one trajectory is required.") validated: list[tuple[str, list[float], list[float]]] = [] for label, values in trajectories.items(): times = list(values["time"]) lineages = list(values["lineages"]) if len(times) != len(lineages) or len(times) < 2: raise ValueError(f"Trajectory {label!r} has invalid time/lineage arrays.") validated.append((label, times, lineages)) plt, _ = self._matplotlib() figure, axis = plt.subplots(figsize=(7.2, 4.0), constrained_layout=True) for index, (label, times, lineages) in enumerate(validated): denominator = max(lineages) if normalize else 1.0 plotted = [value / denominator for value in lineages] axis.plot( times, plotted, color=self.COLORS[index % len(self.COLORS)], linestyle=("-", "--", "-.", ":")[index % 4], linewidth=1.4, label=label, ) axis.set_xlabel("Calendar year") axis.set_ylabel("Relative lineages" if normalize else "Lineages") axis.set_title(title, fontweight="bold") axis.legend( frameon=False, loc="upper left", bbox_to_anchor=(1.01, 1.0), ncol=1, fontsize=7, ) self._clean_axis(axis) return figure
[docs] @staticmethod def save(figure: Any, destination: str | Path, *, dpi: int = 600) -> Path: """Save a figure with a transparent vector or high-resolution raster backend.""" output = Path(destination) output.parent.mkdir(parents=True, exist_ok=True) figure.savefig(output, dpi=dpi, bbox_inches="tight", facecolor="white") return output