CodonAdaptPy 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codonadaptpy/__init__.py +67 -0
- codonadaptpy/__main__.py +17 -0
- codonadaptpy/__version__.py +21 -0
- codonadaptpy/aggregation.py +94 -0
- codonadaptpy/analyzer.py +270 -0
- codonadaptpy/cli.py +409 -0
- codonadaptpy/comparative.py +538 -0
- codonadaptpy/exceptions.py +39 -0
- codonadaptpy/genetic_code.py +115 -0
- codonadaptpy/metrics/__init__.py +28 -0
- codonadaptpy/metrics/adaptation.py +133 -0
- codonadaptpy/metrics/composition.py +173 -0
- codonadaptpy/metrics/evolutionary.py +60 -0
- codonadaptpy/metrics/host.py +145 -0
- codonadaptpy/metrics/pairs.py +107 -0
- codonadaptpy/metrics/usage.py +155 -0
- codonadaptpy/models.py +128 -0
- codonadaptpy/optimizer.py +194 -0
- codonadaptpy/parsers.py +220 -0
- codonadaptpy/phylo_visualization.py +334 -0
- codonadaptpy/phylogenetics.py +602 -0
- codonadaptpy/py.typed +1 -0
- codonadaptpy/references.py +169 -0
- codonadaptpy/reporting.py +174 -0
- codonadaptpy/validation.py +169 -0
- codonadaptpy/visualization.py +1073 -0
- codonadaptpy-1.0.0.dist-info/METADATA +346 -0
- codonadaptpy-1.0.0.dist-info/RECORD +32 -0
- codonadaptpy-1.0.0.dist-info/WHEEL +5 -0
- codonadaptpy-1.0.0.dist-info/entry_points.txt +2 -0
- codonadaptpy-1.0.0.dist-info/licenses/LICENSE +14 -0
- codonadaptpy-1.0.0.dist-info/top_level.txt +1 -0
codonadaptpy/parsers.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy Sequence and Metadata Parsers
|
|
3
|
+
|
|
4
|
+
This module provides batch-friendly readers for FASTA, GenBank, delimited
|
|
5
|
+
tables, ZIP archives, open text streams, and directly supplied nucleotide
|
|
6
|
+
strings. FASTA parsing is implemented in the standard library; GenBank parsing
|
|
7
|
+
uses Biopython when requested.
|
|
8
|
+
|
|
9
|
+
Features:
|
|
10
|
+
- Duplicate identifier detection across every input source
|
|
11
|
+
- Transparent DNA/RNA sequence preservation for later validation
|
|
12
|
+
- Metadata attachment from CSV and TSV files
|
|
13
|
+
- Safe ZIP member inspection without extracting archive contents
|
|
14
|
+
|
|
15
|
+
Classes:
|
|
16
|
+
- SequenceParser: Unified high-level parser for supported input formats.
|
|
17
|
+
|
|
18
|
+
:Created: July 20, 2026
|
|
19
|
+
:Updated: July 20, 2026
|
|
20
|
+
:Author: Naveen Duhan
|
|
21
|
+
:Version: 1.0.0
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import csv
|
|
27
|
+
import io
|
|
28
|
+
import shlex
|
|
29
|
+
import zipfile
|
|
30
|
+
from collections.abc import Iterable, Iterator
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import TextIO, cast
|
|
33
|
+
|
|
34
|
+
from .exceptions import OptionalDependencyError, SequenceValidationError
|
|
35
|
+
from .models import SequenceRecord
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SequenceParser:
|
|
39
|
+
"""Read supported sequence inputs into consistent sequence records."""
|
|
40
|
+
|
|
41
|
+
def parse(self, source: str | Path | TextIO, *, fmt: str | None = None) -> list[SequenceRecord]:
|
|
42
|
+
"""Parse a path, text stream, or direct nucleotide sequence.
|
|
43
|
+
|
|
44
|
+
Format is inferred from file extensions when omitted. Plain strings
|
|
45
|
+
that do not identify existing paths are treated as pasted sequences
|
|
46
|
+
unless they begin with ``>`` and therefore represent FASTA text.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
if hasattr(source, "read"):
|
|
50
|
+
return list(self.parse_fasta(cast(TextIO, source)))
|
|
51
|
+
if isinstance(source, str):
|
|
52
|
+
text = source.strip()
|
|
53
|
+
if text.startswith(">"):
|
|
54
|
+
return list(self.parse_fasta(io.StringIO(text)))
|
|
55
|
+
compact = "".join(text.split()).upper()
|
|
56
|
+
if compact and not (set(compact) - set("ACGTURYSWKMBDHVN")):
|
|
57
|
+
return [SequenceRecord(identifier="sequence_1", sequence=text)]
|
|
58
|
+
path = Path(source)
|
|
59
|
+
if path.exists():
|
|
60
|
+
inferred = (fmt or path.suffix.lstrip(".")).lower()
|
|
61
|
+
if inferred in {"fa", "fna", "fas", "fasta"}:
|
|
62
|
+
with path.open(encoding="utf-8") as handle:
|
|
63
|
+
return list(self.parse_fasta(handle))
|
|
64
|
+
if inferred in {"gb", "gbk", "genbank"}:
|
|
65
|
+
return list(self.parse_genbank(path))
|
|
66
|
+
if inferred in {"csv", "tsv", "txt"}:
|
|
67
|
+
return list(self.parse_table(path))
|
|
68
|
+
if inferred == "zip":
|
|
69
|
+
return list(self.parse_zip(path))
|
|
70
|
+
raise ValueError(f"Unsupported sequence input format: {path.suffix or fmt}")
|
|
71
|
+
return [SequenceRecord(identifier="sequence_1", sequence=str(source))]
|
|
72
|
+
|
|
73
|
+
def parse_fasta(self, handle: Iterable[str]) -> Iterator[SequenceRecord]:
|
|
74
|
+
"""Yield records from FASTA-formatted text with duplicate checks."""
|
|
75
|
+
|
|
76
|
+
identifier: str | None = None
|
|
77
|
+
description = ""
|
|
78
|
+
metadata: dict[str, str] = {}
|
|
79
|
+
sequence: list[str] = []
|
|
80
|
+
seen: set[str] = set()
|
|
81
|
+
for line_number, raw_line in enumerate(handle, start=1):
|
|
82
|
+
line = raw_line.strip()
|
|
83
|
+
if not line:
|
|
84
|
+
continue
|
|
85
|
+
if line.startswith(">"):
|
|
86
|
+
if identifier is not None:
|
|
87
|
+
yield SequenceRecord(identifier, "".join(sequence), description, metadata)
|
|
88
|
+
header = line[1:].strip()
|
|
89
|
+
if not header:
|
|
90
|
+
raise SequenceValidationError(f"Empty FASTA header on line {line_number}.")
|
|
91
|
+
fields = header.split(maxsplit=1)
|
|
92
|
+
identifier = fields[0]
|
|
93
|
+
description = fields[1] if len(fields) == 2 else ""
|
|
94
|
+
if identifier in seen:
|
|
95
|
+
raise SequenceValidationError(f"Duplicate FASTA identifier: {identifier}")
|
|
96
|
+
seen.add(identifier)
|
|
97
|
+
sequence = []
|
|
98
|
+
metadata = {
|
|
99
|
+
key: value
|
|
100
|
+
for token in shlex.split(description)
|
|
101
|
+
if "=" in token
|
|
102
|
+
for key, value in [token.split("=", 1)]
|
|
103
|
+
}
|
|
104
|
+
elif identifier is None:
|
|
105
|
+
raise SequenceValidationError(f"FASTA sequence data precede the first header on line {line_number}.")
|
|
106
|
+
else:
|
|
107
|
+
sequence.append(line)
|
|
108
|
+
if identifier is not None:
|
|
109
|
+
yield SequenceRecord(identifier, "".join(sequence), description, metadata)
|
|
110
|
+
|
|
111
|
+
def parse_table(self, path: str | Path) -> Iterator[SequenceRecord]:
|
|
112
|
+
"""Yield records from CSV/TSV containing identifier and sequence columns."""
|
|
113
|
+
|
|
114
|
+
path = Path(path)
|
|
115
|
+
delimiter = "," if path.suffix.lower() == ".csv" else "\t"
|
|
116
|
+
with path.open(encoding="utf-8", newline="") as handle:
|
|
117
|
+
reader = csv.DictReader(handle, delimiter=delimiter)
|
|
118
|
+
normalized = {name.lower(): name for name in (reader.fieldnames or [])}
|
|
119
|
+
id_column = normalized.get("identifier") or normalized.get("id") or normalized.get("name")
|
|
120
|
+
sequence_column = normalized.get("sequence") or normalized.get("seq")
|
|
121
|
+
if not id_column or not sequence_column:
|
|
122
|
+
raise SequenceValidationError("Delimited input requires identifier/id/name and sequence/seq columns.")
|
|
123
|
+
seen: set[str] = set()
|
|
124
|
+
for row in reader:
|
|
125
|
+
identifier = (row.get(id_column) or "").strip()
|
|
126
|
+
if not identifier:
|
|
127
|
+
raise SequenceValidationError("Delimited input contains an empty identifier.")
|
|
128
|
+
if identifier in seen:
|
|
129
|
+
raise SequenceValidationError(f"Duplicate table identifier: {identifier}")
|
|
130
|
+
seen.add(identifier)
|
|
131
|
+
metadata = {key: value for key, value in row.items() if key not in {id_column, sequence_column}}
|
|
132
|
+
yield SequenceRecord(identifier, row.get(sequence_column) or "", metadata=metadata)
|
|
133
|
+
|
|
134
|
+
def parse_genbank(self, path: str | Path) -> Iterator[SequenceRecord]:
|
|
135
|
+
"""Yield CDS features, or whole records without CDS, from GenBank input."""
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
from Bio import SeqIO
|
|
139
|
+
except ImportError as exc:
|
|
140
|
+
raise OptionalDependencyError("GenBank parsing requires CodonAdaptPy[io].") from exc
|
|
141
|
+
for source in SeqIO.parse(str(path), "genbank"):
|
|
142
|
+
cds_features = [feature for feature in source.features if feature.type == "CDS"]
|
|
143
|
+
if not cds_features:
|
|
144
|
+
yield SequenceRecord(source.id, str(source.seq), source.description, dict(source.annotations))
|
|
145
|
+
continue
|
|
146
|
+
for index, feature in enumerate(cds_features, start=1):
|
|
147
|
+
qualifiers = feature.qualifiers
|
|
148
|
+
product = (qualifiers.get("product") or [""])[0]
|
|
149
|
+
protein_id = (qualifiers.get("protein_id") or [""])[0]
|
|
150
|
+
identifier = (
|
|
151
|
+
qualifiers.get("locus_tag") or qualifiers.get("protein_id") or [f"{source.id}_CDS{index}"]
|
|
152
|
+
)[0]
|
|
153
|
+
gene = (qualifiers.get("gene") or qualifiers.get("locus_tag") or [identifier])[0]
|
|
154
|
+
yield SequenceRecord(
|
|
155
|
+
identifier,
|
|
156
|
+
str(feature.extract(source.seq)),
|
|
157
|
+
product,
|
|
158
|
+
{
|
|
159
|
+
"source_record": source.id,
|
|
160
|
+
"isolate": source.id,
|
|
161
|
+
"gene": gene,
|
|
162
|
+
"product": product,
|
|
163
|
+
"protein_id": protein_id,
|
|
164
|
+
"feature_index": index,
|
|
165
|
+
"location": str(feature.location),
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def attach_metadata(
|
|
170
|
+
self,
|
|
171
|
+
records: list[SequenceRecord],
|
|
172
|
+
path: str | Path,
|
|
173
|
+
*,
|
|
174
|
+
identifier_column: str = "identifier",
|
|
175
|
+
) -> list[SequenceRecord]:
|
|
176
|
+
"""Attach CSV/TSV metadata rows to records by unique identifier."""
|
|
177
|
+
|
|
178
|
+
metadata_path = Path(path)
|
|
179
|
+
delimiter = "," if metadata_path.suffix.lower() == ".csv" else "\t"
|
|
180
|
+
with metadata_path.open(encoding="utf-8", newline="") as handle:
|
|
181
|
+
rows = list(csv.DictReader(handle, delimiter=delimiter))
|
|
182
|
+
if not rows or identifier_column not in rows[0]:
|
|
183
|
+
raise SequenceValidationError(f"Metadata requires a {identifier_column!r} column.")
|
|
184
|
+
metadata_by_id: dict[str, dict[str, str | None]] = {}
|
|
185
|
+
for row in rows:
|
|
186
|
+
identifier = (row.get(identifier_column) or "").strip()
|
|
187
|
+
if not identifier:
|
|
188
|
+
raise SequenceValidationError("Metadata contains an empty identifier.")
|
|
189
|
+
if identifier in metadata_by_id:
|
|
190
|
+
raise SequenceValidationError(f"Duplicate metadata identifier: {identifier}")
|
|
191
|
+
metadata_by_id[identifier] = row
|
|
192
|
+
record_ids = {record.identifier for record in records}
|
|
193
|
+
missing = sorted(record_ids - metadata_by_id.keys())
|
|
194
|
+
if missing:
|
|
195
|
+
raise SequenceValidationError(f"Metadata are missing for: {', '.join(missing)}")
|
|
196
|
+
for record in records:
|
|
197
|
+
record.metadata.update(
|
|
198
|
+
{key: value for key, value in metadata_by_id[record.identifier].items() if key != identifier_column}
|
|
199
|
+
)
|
|
200
|
+
return records
|
|
201
|
+
|
|
202
|
+
def parse_zip(self, path: str | Path) -> Iterator[SequenceRecord]:
|
|
203
|
+
"""Yield FASTA and delimited records directly from a ZIP archive."""
|
|
204
|
+
|
|
205
|
+
seen: set[str] = set()
|
|
206
|
+
with zipfile.ZipFile(path) as archive:
|
|
207
|
+
for member in sorted(archive.namelist()):
|
|
208
|
+
suffix = Path(member).suffix.lower()
|
|
209
|
+
if member.endswith("/") or suffix not in {".fa", ".fna", ".fas", ".fasta"}:
|
|
210
|
+
continue
|
|
211
|
+
with archive.open(member) as binary:
|
|
212
|
+
text = io.TextIOWrapper(binary, encoding="utf-8")
|
|
213
|
+
for record in self.parse_fasta(text):
|
|
214
|
+
if record.identifier in seen:
|
|
215
|
+
raise SequenceValidationError(
|
|
216
|
+
f"Duplicate identifier across ZIP members: {record.identifier}"
|
|
217
|
+
)
|
|
218
|
+
seen.add(record.identifier)
|
|
219
|
+
record.metadata["archive_member"] = member
|
|
220
|
+
yield record
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy ETE3 Phylogenetic Visualization
|
|
3
|
+
|
|
4
|
+
This module converts ETE3 tree objects into publication-ready Matplotlib
|
|
5
|
+
figures. ETE3 supplies topology parsing, rooting, traversal, pruning, and node
|
|
6
|
+
metadata; Matplotlib supplies stable headless rendering to PNG and vector PDF
|
|
7
|
+
without requiring ETE3's optional Qt graphical stack.
|
|
8
|
+
|
|
9
|
+
The plotting methods preserve branch lengths, show optional support values,
|
|
10
|
+
use colorblind-safe group encodings, and distinguish deterministic time-tree
|
|
11
|
+
and lineage-through-time outputs from Bayesian chronograms or skyline plots.
|
|
12
|
+
|
|
13
|
+
Classes:
|
|
14
|
+
- ETE3TreePlotter: Rectangular ML trees, time-scaled trees, root-to-tip
|
|
15
|
+
regressions, and lineage-through-time trajectory figures.
|
|
16
|
+
|
|
17
|
+
:Created: July 20, 2026
|
|
18
|
+
:Updated: July 20, 2026
|
|
19
|
+
:Author: Naveen Duhan
|
|
20
|
+
:Version: 1.0.0
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Mapping, Sequence
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ETE3TreePlotter:
|
|
31
|
+
"""Render ETE3 phylogenies with consistent publication styling."""
|
|
32
|
+
|
|
33
|
+
COLORS = ("#0072B2", "#E69F00", "#009E73", "#CC79A7", "#7A7A7A", "#D55E00")
|
|
34
|
+
MARKERS = ("o", "s", "^", "D", "P", "X")
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _matplotlib() -> tuple[Any, Any]:
|
|
38
|
+
"""Import and configure Matplotlib only when a plot is requested."""
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import matplotlib as mpl
|
|
42
|
+
import matplotlib.pyplot as plt
|
|
43
|
+
except ImportError as exc:
|
|
44
|
+
raise RuntimeError("Phylogenetic plotting requires matplotlib; install the 'plot' extra.") from exc
|
|
45
|
+
mpl.rcParams.update(
|
|
46
|
+
{
|
|
47
|
+
"font.family": "sans-serif",
|
|
48
|
+
"font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"],
|
|
49
|
+
"font.size": 8,
|
|
50
|
+
"axes.labelsize": 9,
|
|
51
|
+
"axes.titlesize": 10,
|
|
52
|
+
"xtick.labelsize": 7,
|
|
53
|
+
"ytick.labelsize": 7,
|
|
54
|
+
"pdf.fonttype": 42,
|
|
55
|
+
"ps.fonttype": 42,
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
return plt, mpl
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _import_ete3() -> Any:
|
|
62
|
+
"""Import ETE3 Tree with Python >= 3.13 cgi compatibility."""
|
|
63
|
+
import sys
|
|
64
|
+
import types
|
|
65
|
+
|
|
66
|
+
if "cgi" not in sys.modules:
|
|
67
|
+
dummy_cgi = types.ModuleType("cgi")
|
|
68
|
+
dummy_cgi.escape = (
|
|
69
|
+
lambda text, quote=True: text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
70
|
+
)
|
|
71
|
+
sys.modules["cgi"] = dummy_cgi
|
|
72
|
+
from ete3 import Tree
|
|
73
|
+
|
|
74
|
+
return Tree
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def _tree(cls, tree: str | Path | Any) -> Any:
|
|
78
|
+
"""Load or copy a tree through ETE3."""
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
Tree = cls._import_ete3()
|
|
82
|
+
except (ImportError, ModuleNotFoundError) as exc:
|
|
83
|
+
raise RuntimeError("Tree plotting requires ete3; install the 'phylo' extra.") from exc
|
|
84
|
+
tree_object: Any = tree
|
|
85
|
+
if hasattr(tree_object, "traverse"):
|
|
86
|
+
return tree_object.copy(method="deepcopy")
|
|
87
|
+
try:
|
|
88
|
+
return Tree(str(tree), format=0)
|
|
89
|
+
except Exception:
|
|
90
|
+
return Tree(str(tree), format=1)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _coordinates(tree: Any, *, time_scaled: bool = False) -> tuple[dict[Any, float], dict[Any, float]]:
|
|
94
|
+
"""Calculate rectangular-tree x/y coordinates for all ETE3 nodes."""
|
|
95
|
+
|
|
96
|
+
leaves = list(tree.iter_leaves())
|
|
97
|
+
y = {leaf: float(len(leaves) - index - 1) for index, leaf in enumerate(leaves)}
|
|
98
|
+
for node in tree.traverse("postorder"):
|
|
99
|
+
if not node.is_leaf():
|
|
100
|
+
y[node] = sum(y[child] for child in node.children) / len(node.children)
|
|
101
|
+
if time_scaled:
|
|
102
|
+
if any(not hasattr(node, "calendar_date") for node in tree.traverse()):
|
|
103
|
+
raise ValueError("Every tree node must contain calendar_date for time-tree plotting.")
|
|
104
|
+
x = {node: float(node.calendar_date) for node in tree.traverse()}
|
|
105
|
+
else:
|
|
106
|
+
x = {node: float(tree.get_distance(node)) for node in tree.traverse()}
|
|
107
|
+
return x, y
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _clean_axis(axis: Any) -> None:
|
|
111
|
+
"""Apply an open, outward-spine publication style."""
|
|
112
|
+
|
|
113
|
+
axis.spines["top"].set_visible(False)
|
|
114
|
+
axis.spines["right"].set_visible(False)
|
|
115
|
+
axis.spines["left"].set_position(("outward", 5))
|
|
116
|
+
axis.spines["bottom"].set_position(("outward", 5))
|
|
117
|
+
|
|
118
|
+
def tree(
|
|
119
|
+
self,
|
|
120
|
+
tree: str | Path | Any,
|
|
121
|
+
*,
|
|
122
|
+
groups: Mapping[str, str] | None = None,
|
|
123
|
+
title: str = "Maximum-likelihood phylogeny",
|
|
124
|
+
show_support: bool = True,
|
|
125
|
+
minimum_support: float = 70.0,
|
|
126
|
+
time_scaled: bool = False,
|
|
127
|
+
label_tips: bool = True,
|
|
128
|
+
align_tip_labels: bool = True,
|
|
129
|
+
tip_font_size: float = 7.0,
|
|
130
|
+
support_font_size: float = 6.5,
|
|
131
|
+
) -> Any:
|
|
132
|
+
"""Plot a branch-length or calendar-time rectangular phylogeny.
|
|
133
|
+
|
|
134
|
+
Parameters:
|
|
135
|
+
tree: Newick path/string or ETE3 tree.
|
|
136
|
+
groups: Optional mapping from leaf name to genotype/group.
|
|
137
|
+
show_support: Label internal support at or above ``minimum_support``.
|
|
138
|
+
time_scaled: Use each node's ``calendar_date`` feature for x.
|
|
139
|
+
label_tips: Show leaf names; disable for dense overview trees.
|
|
140
|
+
align_tip_labels: Place names in one right-hand column connected
|
|
141
|
+
to their terminal nodes by dotted leader lines.
|
|
142
|
+
tip_font_size: Tip-label size in points. The default remains
|
|
143
|
+
legible when a dense tree is reproduced at journal width.
|
|
144
|
+
support_font_size: Internal branch-support label size in points.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
loaded = self._tree(tree)
|
|
148
|
+
if len(loaded) < 2:
|
|
149
|
+
raise ValueError("A plotted phylogeny must contain at least two leaves.")
|
|
150
|
+
plt, _ = self._matplotlib()
|
|
151
|
+
if tip_font_size <= 0 or support_font_size <= 0:
|
|
152
|
+
raise ValueError("Tree label font sizes must be positive.")
|
|
153
|
+
height = max(4.5, min(14.0, len(loaded) * (0.16 if label_tips else 0.08)))
|
|
154
|
+
figure, axis = plt.subplots(figsize=(9.2, height), constrained_layout=True)
|
|
155
|
+
x, y = self._coordinates(loaded, time_scaled=time_scaled)
|
|
156
|
+
x_span = max(x.values()) - min(x.values())
|
|
157
|
+
label_x = max(x.values()) + max(x_span * 0.025, 1e-6)
|
|
158
|
+
known_groups = sorted({groups.get(leaf.name, "Unknown") for leaf in loaded.iter_leaves()}) if groups else []
|
|
159
|
+
palette = {group: self.COLORS[index % len(self.COLORS)] for index, group in enumerate(known_groups)}
|
|
160
|
+
for node in loaded.traverse("preorder"):
|
|
161
|
+
if node.is_leaf():
|
|
162
|
+
continue
|
|
163
|
+
child_y = [y[child] for child in node.children]
|
|
164
|
+
axis.plot([x[node], x[node]], [min(child_y), max(child_y)], color="#4D4D4D", linewidth=0.65)
|
|
165
|
+
for child in node.children:
|
|
166
|
+
axis.plot([x[node], x[child]], [y[child], y[child]], color="#4D4D4D", linewidth=0.65)
|
|
167
|
+
support = float(getattr(node, "support", 0.0))
|
|
168
|
+
support_percent = support * 100 if 0 < support <= 1 else support
|
|
169
|
+
visible_branch = not node.is_root() and abs(x[node] - x[node.up]) >= max(x_span * 0.008, 1e-12)
|
|
170
|
+
if show_support and support_percent >= minimum_support and visible_branch:
|
|
171
|
+
axis.text(
|
|
172
|
+
x[node],
|
|
173
|
+
y[node] + 0.16,
|
|
174
|
+
f"{support_percent:.0f}",
|
|
175
|
+
ha="center",
|
|
176
|
+
va="bottom",
|
|
177
|
+
fontsize=support_font_size,
|
|
178
|
+
)
|
|
179
|
+
for leaf in loaded.iter_leaves():
|
|
180
|
+
group = groups.get(leaf.name, "Unknown") if groups else "Samples"
|
|
181
|
+
color = palette.get(group, self.COLORS[0])
|
|
182
|
+
axis.scatter(x[leaf], y[leaf], s=12, color=color, edgecolor="white", linewidth=0.25, zorder=3)
|
|
183
|
+
if label_tips:
|
|
184
|
+
text_x = label_x if align_tip_labels else x[leaf]
|
|
185
|
+
if align_tip_labels:
|
|
186
|
+
axis.plot(
|
|
187
|
+
[x[leaf], label_x],
|
|
188
|
+
[y[leaf], y[leaf]],
|
|
189
|
+
color="#A6A6A6",
|
|
190
|
+
linestyle=(0, (1.2, 2.0)),
|
|
191
|
+
linewidth=0.45,
|
|
192
|
+
zorder=1,
|
|
193
|
+
)
|
|
194
|
+
axis.annotate(
|
|
195
|
+
leaf.name,
|
|
196
|
+
(text_x, y[leaf]),
|
|
197
|
+
xytext=(3, 0),
|
|
198
|
+
textcoords="offset points",
|
|
199
|
+
va="center",
|
|
200
|
+
fontsize=tip_font_size,
|
|
201
|
+
color=color,
|
|
202
|
+
)
|
|
203
|
+
if groups:
|
|
204
|
+
handles = [
|
|
205
|
+
axis.scatter([], [], s=18, color=palette[group], label=group, edgecolor="white", linewidth=0.3)
|
|
206
|
+
for group in known_groups
|
|
207
|
+
]
|
|
208
|
+
axis.legend(
|
|
209
|
+
handles=handles,
|
|
210
|
+
title="Group",
|
|
211
|
+
frameon=False,
|
|
212
|
+
loc="lower center",
|
|
213
|
+
bbox_to_anchor=(0.5, 1.005),
|
|
214
|
+
ncol=len(handles),
|
|
215
|
+
columnspacing=1.4,
|
|
216
|
+
handletextpad=0.45,
|
|
217
|
+
borderaxespad=0.0,
|
|
218
|
+
fontsize=8,
|
|
219
|
+
title_fontsize=8,
|
|
220
|
+
)
|
|
221
|
+
axis.set_yticks([])
|
|
222
|
+
axis.set_xlabel("Calendar year" if time_scaled else "Substitutions per site")
|
|
223
|
+
axis.set_title(title, fontweight="bold", pad=50 if groups else 10)
|
|
224
|
+
self._clean_axis(axis)
|
|
225
|
+
axis.spines["left"].set_visible(False)
|
|
226
|
+
return figure
|
|
227
|
+
|
|
228
|
+
def temporal_signal(
|
|
229
|
+
self,
|
|
230
|
+
analysis: Any,
|
|
231
|
+
*,
|
|
232
|
+
groups: Mapping[str, str] | None = None,
|
|
233
|
+
title: str = "Root-to-tip temporal signal",
|
|
234
|
+
) -> Any:
|
|
235
|
+
"""Plot root-to-tip regression with sample identities and group encoding."""
|
|
236
|
+
|
|
237
|
+
plt, _ = self._matplotlib()
|
|
238
|
+
figure, axis = plt.subplots(figsize=(5.2, 3.8), constrained_layout=True)
|
|
239
|
+
names = sorted(analysis.sample_dates, key=analysis.sample_dates.get)
|
|
240
|
+
known_groups = sorted({groups.get(name, "Unknown") for name in names}) if groups else ["Samples"]
|
|
241
|
+
palette = {group: self.COLORS[index % len(self.COLORS)] for index, group in enumerate(known_groups)}
|
|
242
|
+
for index, group in enumerate(known_groups):
|
|
243
|
+
selected = [name for name in names if (groups.get(name, "Unknown") if groups else "Samples") == group]
|
|
244
|
+
axis.scatter(
|
|
245
|
+
[analysis.sample_dates[name] for name in selected],
|
|
246
|
+
[analysis.root_to_tip[name] for name in selected],
|
|
247
|
+
color=palette[group],
|
|
248
|
+
marker=self.MARKERS[index % len(self.MARKERS)],
|
|
249
|
+
s=26,
|
|
250
|
+
alpha=0.78,
|
|
251
|
+
edgecolor="white",
|
|
252
|
+
linewidth=0.35,
|
|
253
|
+
label=f"{group} (n={len(selected)})",
|
|
254
|
+
)
|
|
255
|
+
limits = [min(analysis.sample_dates.values()), max(analysis.sample_dates.values())]
|
|
256
|
+
axis.plot(
|
|
257
|
+
limits,
|
|
258
|
+
[analysis.intercept + analysis.rate * value for value in limits],
|
|
259
|
+
color="#222222",
|
|
260
|
+
linestyle="--",
|
|
261
|
+
linewidth=1.0,
|
|
262
|
+
)
|
|
263
|
+
axis.text(
|
|
264
|
+
0.02,
|
|
265
|
+
0.98,
|
|
266
|
+
f"rate = {analysis.rate:.3g} substitutions/site/year\n"
|
|
267
|
+
f"R² = {analysis.r_squared:.3f}; p = {analysis.p_value:.3g}\n"
|
|
268
|
+
f"fitted root = {analysis.root_date:.1f}",
|
|
269
|
+
transform=axis.transAxes,
|
|
270
|
+
va="top",
|
|
271
|
+
fontsize=7,
|
|
272
|
+
)
|
|
273
|
+
axis.set(xlabel="Sampling year", ylabel="Root-to-tip divergence", title=title)
|
|
274
|
+
axis.legend(frameon=False, loc="lower right", fontsize=7)
|
|
275
|
+
self._clean_axis(axis)
|
|
276
|
+
return figure
|
|
277
|
+
|
|
278
|
+
def lineage_through_time(
|
|
279
|
+
self,
|
|
280
|
+
trajectories: Mapping[str, Mapping[str, Sequence[float]]],
|
|
281
|
+
*,
|
|
282
|
+
title: str = "Lineage-through-time trajectories",
|
|
283
|
+
normalize: bool = True,
|
|
284
|
+
) -> Any:
|
|
285
|
+
"""Plot deterministic dated-tree lineage trajectories.
|
|
286
|
+
|
|
287
|
+
This figure is an exploratory demographic proxy. It does not estimate
|
|
288
|
+
effective population size and its lines are not Bayesian posterior
|
|
289
|
+
medians or highest-posterior-density intervals.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
if not trajectories:
|
|
293
|
+
raise ValueError("At least one trajectory is required.")
|
|
294
|
+
validated: list[tuple[str, list[float], list[float]]] = []
|
|
295
|
+
for label, values in trajectories.items():
|
|
296
|
+
times = list(values["time"])
|
|
297
|
+
lineages = list(values["lineages"])
|
|
298
|
+
if len(times) != len(lineages) or len(times) < 2:
|
|
299
|
+
raise ValueError(f"Trajectory {label!r} has invalid time/lineage arrays.")
|
|
300
|
+
validated.append((label, times, lineages))
|
|
301
|
+
plt, _ = self._matplotlib()
|
|
302
|
+
figure, axis = plt.subplots(figsize=(7.2, 4.0), constrained_layout=True)
|
|
303
|
+
for index, (label, times, lineages) in enumerate(validated):
|
|
304
|
+
denominator = max(lineages) if normalize else 1.0
|
|
305
|
+
plotted = [value / denominator for value in lineages]
|
|
306
|
+
axis.plot(
|
|
307
|
+
times,
|
|
308
|
+
plotted,
|
|
309
|
+
color=self.COLORS[index % len(self.COLORS)],
|
|
310
|
+
linestyle=("-", "--", "-.", ":")[index % 4],
|
|
311
|
+
linewidth=1.4,
|
|
312
|
+
label=label,
|
|
313
|
+
)
|
|
314
|
+
axis.set_xlabel("Calendar year")
|
|
315
|
+
axis.set_ylabel("Relative lineages" if normalize else "Lineages")
|
|
316
|
+
axis.set_title(title, fontweight="bold")
|
|
317
|
+
axis.legend(
|
|
318
|
+
frameon=False,
|
|
319
|
+
loc="upper left",
|
|
320
|
+
bbox_to_anchor=(1.01, 1.0),
|
|
321
|
+
ncol=1,
|
|
322
|
+
fontsize=7,
|
|
323
|
+
)
|
|
324
|
+
self._clean_axis(axis)
|
|
325
|
+
return figure
|
|
326
|
+
|
|
327
|
+
@staticmethod
|
|
328
|
+
def save(figure: Any, destination: str | Path, *, dpi: int = 600) -> Path:
|
|
329
|
+
"""Save a figure with a transparent vector or high-resolution raster backend."""
|
|
330
|
+
|
|
331
|
+
output = Path(destination)
|
|
332
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
figure.savefig(output, dpi=dpi, bbox_inches="tight", facecolor="white")
|
|
334
|
+
return output
|