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.
@@ -0,0 +1,602 @@
1
+ """
2
+ CodonAdaptPy Phylogenetic and Temporal Analysis
3
+
4
+ This module provides class-based wrappers for reproducible nucleotide or
5
+ protein phylogenetics and lightweight temporal exploration. External programs
6
+ are invoked with argument lists rather than shell strings, their commands and
7
+ outputs are retained, and failures include captured diagnostic text.
8
+
9
+ The temporal methods intentionally do not claim to perform Bayesian dating. A
10
+ root-to-tip regression estimates an exploratory strict-clock rate and root
11
+ date, while lineage-through-time summaries describe the dated tree topology.
12
+ They provide fast, deterministic tools for quality control and figure
13
+ generation, not Bayesian posterior estimates, relaxed-clock inference, or
14
+ effective-population-size highest-posterior-density intervals.
15
+
16
+ Classes:
17
+ - PhylogeneticAnalyzer: FASTA writing, MAFFT alignment, optional trimAL,
18
+ and IQ-TREE or FastTree inference.
19
+ - TemporalAnalyzer: Root-to-tip temporal signal, deterministic time
20
+ scaling, and lineage-through-time summaries using ETE3 trees.
21
+ - PhylogeneticComparator: Shared-tip Robinson-Foulds topology comparison.
22
+
23
+ :Created: July 20, 2026
24
+ :Updated: July 20, 2026
25
+ :Author: Naveen Duhan
26
+ :Version: 1.0.0
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import math
32
+ import random
33
+ import shutil
34
+ import subprocess
35
+ from collections import defaultdict
36
+ from collections.abc import Iterable, Mapping
37
+ from dataclasses import asdict, dataclass
38
+ from pathlib import Path
39
+ from typing import Any, Literal
40
+
41
+ from .models import SequenceRecord
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class PhylogenyResult:
46
+ """Record the files, method, and commands produced by one tree run."""
47
+
48
+ input_fasta: str
49
+ alignment: str
50
+ tree: str
51
+ method: str
52
+ model: str
53
+ commands: list[list[str]]
54
+ trimmed_alignment: str | None = None
55
+ report: str | None = None
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ """Return a JSON-compatible representation of the run."""
59
+
60
+ return asdict(self)
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class TemporalSignalResult:
65
+ """Store root-to-tip regression statistics and sample-level diagnostics."""
66
+
67
+ rate: float
68
+ intercept: float
69
+ root_date: float
70
+ r_value: float
71
+ r_squared: float
72
+ p_value: float
73
+ p_value_kind: str
74
+ regression_p_value_unadjusted: float
75
+ permutation_p_value: float | None
76
+ permutations: int
77
+ random_seed: int | None
78
+ standard_error: float
79
+ sample_dates: dict[str, float]
80
+ root_to_tip: dict[str, float]
81
+ fitted_distances: dict[str, float]
82
+ residuals: dict[str, float]
83
+ rooting: str = "midpoint"
84
+ outgroup_leaves: list[str] | None = None
85
+
86
+ def to_dict(self) -> dict[str, Any]:
87
+ """Return a JSON-compatible representation of the regression."""
88
+
89
+ return asdict(self)
90
+
91
+
92
+ class PhylogeneticAnalyzer:
93
+ """Run alignment and maximum-likelihood phylogenetic inference.
94
+
95
+ Parameters:
96
+ mafft: MAFFT executable name or path.
97
+ iqtree: IQ-TREE 2 executable name or path. Both ``iqtree2`` and the
98
+ frequently packaged ``iqtree`` name are discovered automatically.
99
+ fasttree: FastTree executable name or path.
100
+ trimal: Optional trimAL executable name or path.
101
+
102
+ Notes:
103
+ External tools remain optional package dependencies. Methods raise an
104
+ informative :class:`RuntimeError` when the selected executable is not
105
+ available. IQ-TREE ultrafast bootstrap values should generally use at
106
+ least 1,000 replicates for a publication analysis.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ *,
112
+ mafft: str = "mafft",
113
+ iqtree: str | None = None,
114
+ fasttree: str | None = None,
115
+ trimal: str = "trimal",
116
+ ) -> None:
117
+ self.mafft = mafft
118
+ self.iqtree = iqtree or shutil.which("iqtree2") or shutil.which("iqtree") or "iqtree2"
119
+ self.fasttree = fasttree or shutil.which("FastTree") or shutil.which("fasttree") or "FastTree"
120
+ self.trimal = trimal
121
+
122
+ @staticmethod
123
+ def write_fasta(records: Iterable[SequenceRecord], destination: str | Path) -> Path:
124
+ """Write unique, non-empty sequence records as wrapped FASTA."""
125
+
126
+ output = Path(destination)
127
+ materialized = list(records)
128
+ if len(materialized) < 3:
129
+ raise ValueError("At least three sequences are required for phylogenetic inference.")
130
+ identifiers = [record.identifier for record in materialized]
131
+ if len(set(identifiers)) != len(identifiers):
132
+ raise ValueError("Sequence identifiers must be unique for phylogenetic inference.")
133
+ output.parent.mkdir(parents=True, exist_ok=True)
134
+ with output.open("w", encoding="utf-8") as handle:
135
+ for record in materialized:
136
+ sequence = "".join(record.sequence.split()).upper()
137
+ if not sequence:
138
+ raise ValueError(f"Sequence {record.identifier!r} is empty.")
139
+ handle.write(f">{record.identifier}\n")
140
+ for index in range(0, len(sequence), 80):
141
+ handle.write(sequence[index : index + 80] + "\n")
142
+ return output
143
+
144
+ @staticmethod
145
+ def _execute(command: list[str], *, stdout: Path | None = None) -> None:
146
+ """Execute one external program and raise a diagnostic-rich error."""
147
+
148
+ try:
149
+ if stdout is None:
150
+ completed = subprocess.run(command, check=False, capture_output=True, text=True)
151
+ else:
152
+ stdout.parent.mkdir(parents=True, exist_ok=True)
153
+ with stdout.open("w", encoding="utf-8") as handle:
154
+ completed = subprocess.run(command, check=False, stdout=handle, stderr=subprocess.PIPE, text=True)
155
+ except FileNotFoundError as exc:
156
+ raise RuntimeError(f"Required executable was not found: {command[0]}") from exc
157
+ if completed.returncode != 0:
158
+ diagnostic = (completed.stderr or completed.stdout or "No diagnostic output.").strip()
159
+ raise RuntimeError(f"Command failed ({completed.returncode}): {' '.join(command)}\n{diagnostic}")
160
+
161
+ def align(
162
+ self,
163
+ input_fasta: str | Path,
164
+ output_fasta: str | Path,
165
+ *,
166
+ method: Literal["auto", "linsi", "einsi", "fftnsi", "fftns"] = "auto",
167
+ threads: int = 1,
168
+ ) -> tuple[Path, list[str]]:
169
+ """Align sequences with MAFFT and return the output path and command."""
170
+
171
+ methods = {
172
+ "auto": ["--auto"],
173
+ "linsi": ["--localpair", "--maxiterate", "1000"],
174
+ "einsi": ["--genafpair", "--maxiterate", "1000"],
175
+ "fftnsi": ["--fftnsi"],
176
+ "fftns": ["--fftns"],
177
+ }
178
+ if method not in methods:
179
+ raise ValueError(f"Unsupported MAFFT method: {method}")
180
+ if threads < 1:
181
+ raise ValueError("threads must be at least one.")
182
+ command = [self.mafft, *methods[method], "--thread", str(threads), "--inputorder", str(input_fasta)]
183
+ output = Path(output_fasta)
184
+ self._execute(command, stdout=output)
185
+ return output, command
186
+
187
+ def trim(
188
+ self,
189
+ alignment: str | Path,
190
+ destination: str | Path,
191
+ *,
192
+ mode: Literal["automated1", "gappyout", "strict"] = "automated1",
193
+ required: bool = False,
194
+ ) -> tuple[Path, list[str] | None]:
195
+ """Trim an alignment with trimAL, or retain it when trimming is optional."""
196
+
197
+ source = Path(alignment)
198
+ output = Path(destination)
199
+ executable = shutil.which(self.trimal)
200
+ if executable is None:
201
+ if required:
202
+ raise RuntimeError(f"Required executable was not found: {self.trimal}")
203
+ output.parent.mkdir(parents=True, exist_ok=True)
204
+ shutil.copyfile(source, output)
205
+ return output, None
206
+ command = [executable, f"-{mode}", "-in", str(source), "-out", str(output), "-fasta"]
207
+ self._execute(command)
208
+ return output, command
209
+
210
+ def infer_tree(
211
+ self,
212
+ alignment: str | Path,
213
+ output_prefix: str | Path,
214
+ *,
215
+ method: Literal["iqtree", "fasttree"] = "iqtree",
216
+ sequence_type: Literal["nt", "aa"] = "nt",
217
+ model: str = "MFP",
218
+ bootstrap: int = 1000,
219
+ threads: int = 1,
220
+ ) -> tuple[Path, str | None, list[str]]:
221
+ """Infer an ML tree with IQ-TREE ModelFinder or approximate FastTree."""
222
+
223
+ source = Path(alignment)
224
+ prefix = Path(output_prefix)
225
+ prefix.parent.mkdir(parents=True, exist_ok=True)
226
+ if method == "iqtree":
227
+ if bootstrap < 1000:
228
+ raise ValueError("IQ-TREE ultrafast bootstrap must be at least 1,000.")
229
+ command = [
230
+ self.iqtree,
231
+ "-s",
232
+ str(source),
233
+ "--prefix",
234
+ str(prefix),
235
+ "-m",
236
+ model,
237
+ "-B",
238
+ str(bootstrap),
239
+ "-T",
240
+ str(threads),
241
+ "--redo",
242
+ ]
243
+ self._execute(command)
244
+ tree = Path(f"{prefix}.treefile")
245
+ return tree, str(Path(f"{prefix}.iqtree")), command
246
+ if method != "fasttree":
247
+ raise ValueError(f"Unsupported tree method: {method}")
248
+ tree = Path(f"{prefix}.tree")
249
+ command = (
250
+ [self.fasttree, "-nt", "-gtr", str(source)]
251
+ if sequence_type == "nt"
252
+ else [self.fasttree, "-lg", str(source)]
253
+ )
254
+ self._execute(command, stdout=tree)
255
+ return tree, None, command
256
+
257
+ def run(
258
+ self,
259
+ records: Iterable[SequenceRecord],
260
+ output_directory: str | Path,
261
+ *,
262
+ name: str = "phylogeny",
263
+ align_method: Literal["auto", "linsi", "einsi", "fftnsi", "fftns"] = "auto",
264
+ tree_method: Literal["iqtree", "fasttree"] = "iqtree",
265
+ trim: bool = True,
266
+ trim_required: bool = False,
267
+ model: str = "MFP",
268
+ bootstrap: int = 1000,
269
+ threads: int = 1,
270
+ ) -> PhylogenyResult:
271
+ """Run FASTA export, alignment, optional trimming, and tree inference."""
272
+
273
+ directory = Path(output_directory)
274
+ fasta = self.write_fasta(records, directory / f"{name}.fasta")
275
+ alignment, align_command = self.align(
276
+ fasta,
277
+ directory / f"{name}.aligned.fasta",
278
+ method=align_method,
279
+ threads=threads,
280
+ )
281
+ commands = [align_command]
282
+ analysis_alignment = alignment
283
+ trimmed: Path | None = None
284
+ if trim:
285
+ trimmed, trim_command = self.trim(
286
+ alignment,
287
+ directory / f"{name}.trimmed.fasta",
288
+ required=trim_required,
289
+ )
290
+ analysis_alignment = trimmed
291
+ if trim_command:
292
+ commands.append(trim_command)
293
+ tree, report, tree_command = self.infer_tree(
294
+ analysis_alignment,
295
+ directory / name,
296
+ method=tree_method,
297
+ model=model,
298
+ bootstrap=bootstrap,
299
+ threads=threads,
300
+ )
301
+ commands.append(tree_command)
302
+ return PhylogenyResult(
303
+ input_fasta=str(fasta),
304
+ alignment=str(alignment),
305
+ trimmed_alignment=str(trimmed) if trimmed else None,
306
+ tree=str(tree),
307
+ method=tree_method,
308
+ model=model if tree_method == "iqtree" else "GTR/CAT approximation",
309
+ commands=commands,
310
+ report=report,
311
+ )
312
+
313
+
314
+ class TemporalAnalyzer:
315
+ """Estimate temporal signal and deterministic dated-tree summaries.
316
+
317
+ ETE3 is imported lazily so codon-only workflows remain dependency free.
318
+ The input tree should contain branch lengths in substitutions per site.
319
+ Sampling dates are decimal calendar years keyed by exact leaf names.
320
+ """
321
+
322
+ @staticmethod
323
+ def _import_ete3() -> Any:
324
+ """Import ETE3 Tree with Python >= 3.13 cgi compatibility."""
325
+ import sys
326
+ import types
327
+
328
+ if "cgi" not in sys.modules:
329
+ dummy_cgi = types.ModuleType("cgi")
330
+ dummy_cgi.escape = (
331
+ lambda text, quote=True: text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
332
+ )
333
+ sys.modules["cgi"] = dummy_cgi
334
+ from ete3 import Tree
335
+
336
+ return Tree
337
+
338
+ @classmethod
339
+ def load_tree(cls, tree: str | Path | Any, *, midpoint_root: bool = True) -> Any:
340
+ """Load or copy an ETE3 tree and optionally apply midpoint rooting."""
341
+
342
+ try:
343
+ Tree = cls._import_ete3()
344
+ except (ImportError, ModuleNotFoundError) as exc:
345
+ raise RuntimeError("Temporal phylogenetics requires ete3; install the 'phylo' extra.") from exc
346
+ tree_object: Any = tree
347
+ if hasattr(tree_object, "traverse"):
348
+ loaded = tree_object.copy(method="deepcopy")
349
+ else:
350
+ try:
351
+ loaded = Tree(str(tree), format=0)
352
+ except Exception:
353
+ loaded = Tree(str(tree), format=1)
354
+ if midpoint_root and len(loaded) > 2:
355
+ outgroup = loaded.get_midpoint_outgroup()
356
+ if outgroup is not None:
357
+ loaded.set_outgroup(outgroup)
358
+ return loaded
359
+
360
+ def temporal_signal(
361
+ self,
362
+ tree: str | Path | Any,
363
+ sample_dates: Mapping[str, float | int],
364
+ *,
365
+ strata: Mapping[str, Any] | None = None,
366
+ midpoint_root: bool = True,
367
+ optimize_root: bool = False,
368
+ permutations: int = 0,
369
+ random_seed: int = 1,
370
+ ) -> TemporalSignalResult:
371
+ """Regress root-to-tip divergence on sampling date.
372
+
373
+ A positive slope is interpreted as an exploratory strict-clock rate.
374
+ When ``permutations`` is positive, sampling dates are randomized and
375
+ the complete root-selection procedure is repeated for every replicate.
376
+ When ``strata`` is provided, dates are permuted strictly within each
377
+ stratum (e.g. genotype or lineage) to avoid confounding between group
378
+ membership and sampling date. The resulting empirical p value therefore
379
+ accounts for optimization over candidate roots. At least three dated tips
380
+ and two distinct sampling years are required.
381
+ """
382
+
383
+ loaded = self.load_tree(tree, midpoint_root=False)
384
+ usable = {
385
+ leaf.name: float(sample_dates[leaf.name])
386
+ for leaf in loaded.iter_leaves()
387
+ if leaf.name in sample_dates and math.isfinite(float(sample_dates[leaf.name]))
388
+ }
389
+ if len(usable) < 3 or len(set(usable.values())) < 2:
390
+ raise ValueError("Temporal analysis requires at least three tips across two sampling dates.")
391
+ if permutations < 0:
392
+ raise ValueError("permutations cannot be negative.")
393
+ try:
394
+ from scipy.stats import linregress
395
+ except ImportError as exc:
396
+ raise RuntimeError("Temporal regression requires scipy; install the 'analysis' extra.") from exc
397
+ candidates: list[tuple[str, list[str] | None, Any]] = [("current", None, loaded)]
398
+ if midpoint_root and len(loaded) > 2:
399
+ midpoint_tree = loaded.copy(method="deepcopy")
400
+ outgroup = midpoint_tree.get_midpoint_outgroup()
401
+ if outgroup is not None:
402
+ leaves = [leaf.name for leaf in outgroup.iter_leaves()]
403
+ midpoint_tree.set_outgroup(outgroup)
404
+ candidates.append(("midpoint", leaves, midpoint_tree))
405
+ if optimize_root:
406
+ for index, node in enumerate(loaded.traverse("preorder")):
407
+ if node.is_root():
408
+ continue
409
+ leaf_names = [leaf.name for leaf in node.iter_leaves()]
410
+ candidate_tree = loaded.copy(method="deepcopy")
411
+ candidate = (
412
+ candidate_tree & leaf_names[0]
413
+ if len(leaf_names) == 1
414
+ else candidate_tree.get_common_ancestor(*leaf_names)
415
+ )
416
+ candidate_tree.set_outgroup(candidate)
417
+ candidates.append((f"optimized_branch_{index}", leaf_names, candidate_tree))
418
+
419
+ evaluated: list[tuple[float, str, list[str] | None, Any, Any, dict[str, float]]] = []
420
+ for rooting, outgroup_leaves, candidate_tree in candidates:
421
+ distances = {name: float(candidate_tree.get_distance(name)) for name in usable}
422
+ regression = linregress(list(usable.values()), [distances[name] for name in usable])
423
+ if not math.isfinite(regression.slope) or regression.slope <= 0:
424
+ continue
425
+ root_date = -float(regression.intercept) / float(regression.slope)
426
+ if root_date > min(usable.values()):
427
+ continue
428
+ evaluated.append(
429
+ (float(regression.rvalue**2), rooting, outgroup_leaves, candidate_tree, regression, distances)
430
+ )
431
+ if not evaluated:
432
+ raise ValueError("No tested root produced a positive, temporally valid root-to-tip regression.")
433
+ _, rooting, outgroup_leaves, loaded, regression, distances = max(evaluated, key=lambda item: item[0])
434
+ if not math.isfinite(regression.slope) or regression.slope <= 0:
435
+ raise ValueError("The rooted tree has no positive temporal signal; a time scale cannot be estimated.")
436
+ root_date = -float(regression.intercept) / float(regression.slope)
437
+ fitted = {name: float(regression.intercept + regression.slope * date) for name, date in usable.items()}
438
+ residuals = {name: distances[name] - fitted[name] for name in usable}
439
+ permutation_p_value: float | None = None
440
+ if permutations:
441
+ observed_r_squared = float(regression.rvalue**2)
442
+ ordered_names = list(usable)
443
+ observed_dates = [usable[name] for name in ordered_names]
444
+ candidate_distances = [
445
+ [candidate_distances[name] for name in ordered_names]
446
+ for _, _, _, _, _, candidate_distances in evaluated
447
+ ]
448
+ generator = random.Random(random_seed)
449
+ exceedances = 0
450
+
451
+ stratum_indices: dict[Any, list[int]] = defaultdict(list)
452
+ if strata:
453
+ for idx, name in enumerate(ordered_names):
454
+ stratum_indices[strata.get(name, "default")].append(idx)
455
+
456
+ for _ in range(permutations):
457
+ shuffled_dates = observed_dates.copy()
458
+ if strata:
459
+ for group_idxs in stratum_indices.values():
460
+ sub_dates = [shuffled_dates[i] for i in group_idxs]
461
+ generator.shuffle(sub_dates)
462
+ for i, d in zip(group_idxs, sub_dates, strict=True):
463
+ shuffled_dates[i] = d
464
+ else:
465
+ generator.shuffle(shuffled_dates)
466
+ best_null = 0.0
467
+ for candidate_values in candidate_distances:
468
+ null_regression = linregress(shuffled_dates, candidate_values)
469
+ if not math.isfinite(null_regression.slope) or null_regression.slope <= 0:
470
+ continue
471
+ null_root_date = -float(null_regression.intercept) / float(null_regression.slope)
472
+ if null_root_date > min(shuffled_dates):
473
+ continue
474
+ best_null = max(best_null, float(null_regression.rvalue**2))
475
+ if best_null >= observed_r_squared:
476
+ exceedances += 1
477
+ permutation_p_value = (exceedances + 1) / (permutations + 1)
478
+ reported_p_value = permutation_p_value if permutation_p_value is not None else float(regression.pvalue)
479
+ return TemporalSignalResult(
480
+ rate=float(regression.slope),
481
+ intercept=float(regression.intercept),
482
+ root_date=root_date,
483
+ r_value=float(regression.rvalue),
484
+ r_squared=float(regression.rvalue**2),
485
+ p_value=reported_p_value,
486
+ p_value_kind=(
487
+ "stratified_date_randomization"
488
+ if permutation_p_value is not None and strata
489
+ else "root_optimized_date_randomization"
490
+ if permutation_p_value is not None
491
+ else "unadjusted_regression"
492
+ ),
493
+ regression_p_value_unadjusted=float(regression.pvalue),
494
+ permutation_p_value=permutation_p_value,
495
+ permutations=permutations,
496
+ random_seed=random_seed if permutations else None,
497
+ standard_error=float(regression.stderr),
498
+ sample_dates=usable,
499
+ root_to_tip=distances,
500
+ fitted_distances=fitted,
501
+ residuals=residuals,
502
+ rooting=rooting,
503
+ outgroup_leaves=outgroup_leaves,
504
+ )
505
+
506
+ def date_tree(
507
+ self,
508
+ tree: str | Path | Any,
509
+ signal: TemporalSignalResult,
510
+ *,
511
+ midpoint_root: bool = True,
512
+ ) -> Any:
513
+ """Attach deterministic calendar dates to every ETE3 node.
514
+
515
+ Internal dates follow the fitted strict-clock transform of root
516
+ distance. Tips retain an additional ``observed_date`` feature when a
517
+ sampling date is available. The returned topology is suitable for
518
+ exploratory time-tree plotting and lineage-through-time summaries.
519
+ """
520
+
521
+ loaded = self.load_tree(tree, midpoint_root=False)
522
+ if signal.outgroup_leaves:
523
+ outgroup = (
524
+ loaded & signal.outgroup_leaves[0]
525
+ if len(signal.outgroup_leaves) == 1
526
+ else loaded.get_common_ancestor(*signal.outgroup_leaves)
527
+ )
528
+ loaded.set_outgroup(outgroup)
529
+ elif midpoint_root:
530
+ outgroup = loaded.get_midpoint_outgroup()
531
+ if outgroup is not None:
532
+ loaded.set_outgroup(outgroup)
533
+ for node in loaded.traverse("preorder"):
534
+ distance = float(loaded.get_distance(node))
535
+ fitted_date = signal.root_date + distance / signal.rate
536
+ node.add_feature("calendar_date", fitted_date)
537
+ if node.is_leaf() and node.name in signal.sample_dates:
538
+ node.add_feature("observed_date", signal.sample_dates[node.name])
539
+ node.add_feature("fitted_date", fitted_date)
540
+ node.calendar_date = signal.sample_dates[node.name]
541
+ epsilon = 1e-6
542
+ for node in loaded.traverse("postorder"):
543
+ if not node.is_leaf():
544
+ latest_allowed = min(float(child.calendar_date) for child in node.children) - epsilon
545
+ node.calendar_date = min(float(node.calendar_date), latest_allowed)
546
+ return loaded
547
+
548
+ @staticmethod
549
+ def lineage_through_time(tree: Any, *, points: int = 200) -> dict[str, list[float]]:
550
+ """Count branches crossing an evenly spaced calendar-time grid."""
551
+
552
+ if points < 2:
553
+ raise ValueError("points must be at least two.")
554
+ nodes = [node for node in tree.traverse() if not node.is_root()]
555
+ if not nodes or any(not hasattr(node, "calendar_date") for node in tree.traverse()):
556
+ raise ValueError("Every tree node must contain a calendar_date feature.")
557
+ start = float(tree.calendar_date)
558
+ end = max(float(node.calendar_date) for node in tree.iter_leaves())
559
+ if end <= start:
560
+ raise ValueError("Dated tree must span a positive calendar interval.")
561
+ step = (end - start) / (points - 1)
562
+ times = [start + index * step for index in range(points)]
563
+ lineages = []
564
+ for time in times:
565
+ count = sum(
566
+ 1
567
+ for node in nodes
568
+ if float(node.up.calendar_date) <= time <= float(node.calendar_date)
569
+ )
570
+ lineages.append(float(count))
571
+ return {"time": times, "lineages": lineages}
572
+
573
+
574
+ class PhylogeneticComparator:
575
+ """Compare whole-genome and gene-tree topologies on their shared tips."""
576
+
577
+ def robinson_foulds(self, first: str | Path | Any, second: str | Path | Any) -> dict[str, Any]:
578
+ """Calculate unrooted Robinson-Foulds distance after shared-tip pruning.
579
+
580
+ The normalized distance is zero for identical shared-tip bipartitions
581
+ and approaches one as topologies become maximally discordant. At least
582
+ four shared tips are required for an informative unrooted comparison.
583
+ """
584
+
585
+ loader = TemporalAnalyzer()
586
+ first_tree = loader.load_tree(first, midpoint_root=False)
587
+ second_tree = loader.load_tree(second, midpoint_root=False)
588
+ shared = sorted(set(first_tree.get_leaf_names()) & set(second_tree.get_leaf_names()))
589
+ if len(shared) < 4:
590
+ raise ValueError("Robinson-Foulds comparison requires at least four shared tips.")
591
+ first_tree.prune(shared, preserve_branch_length=True)
592
+ second_tree.prune(shared, preserve_branch_length=True)
593
+ comparison = first_tree.robinson_foulds(second_tree, unrooted_trees=True)
594
+ distance = int(comparison[0])
595
+ maximum = int(comparison[1])
596
+ return {
597
+ "shared_tips": len(shared),
598
+ "tip_names": shared,
599
+ "distance": distance,
600
+ "maximum_distance": maximum,
601
+ "normalized_distance": distance / maximum if maximum else 0.0,
602
+ }
codonadaptpy/py.typed ADDED
@@ -0,0 +1 @@
1
+