"""
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("&", "&").replace("<", "<").replace(">", ">")
)
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