phykit 2.1.41__tar.gz → 2.1.43__tar.gz

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.
Files changed (118) hide show
  1. {phykit-2.1.41 → phykit-2.1.43}/PKG-INFO +1 -1
  2. {phykit-2.1.41 → phykit-2.1.43}/phykit/cli_registry.py +2 -0
  3. phykit-2.1.43/phykit/helpers/quartet_utils.py +136 -0
  4. {phykit-2.1.41 → phykit-2.1.43}/phykit/phykit.py +108 -0
  5. {phykit-2.1.41 → phykit-2.1.43}/phykit/service_factories.py +1 -0
  6. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/__init__.py +1 -0
  7. phykit-2.1.43/phykit/services/tree/quartet_pie.py +305 -0
  8. phykit-2.1.43/phykit/version.py +1 -0
  9. {phykit-2.1.41 → phykit-2.1.43}/phykit.egg-info/PKG-INFO +1 -1
  10. {phykit-2.1.41 → phykit-2.1.43}/phykit.egg-info/SOURCES.txt +2 -0
  11. {phykit-2.1.41 → phykit-2.1.43}/phykit.egg-info/entry_points.txt +3 -0
  12. {phykit-2.1.41 → phykit-2.1.43}/setup.py +3 -0
  13. phykit-2.1.41/phykit/version.py +0 -1
  14. {phykit-2.1.41 → phykit-2.1.43}/LICENSE.md +0 -0
  15. {phykit-2.1.41 → phykit-2.1.43}/README.md +0 -0
  16. {phykit-2.1.41 → phykit-2.1.43}/phykit/__init__.py +0 -0
  17. {phykit-2.1.41 → phykit-2.1.43}/phykit/__main__.py +0 -0
  18. {phykit-2.1.41 → phykit-2.1.43}/phykit/errors.py +0 -0
  19. {phykit-2.1.41 → phykit-2.1.43}/phykit/helpers/__init__.py +0 -0
  20. {phykit-2.1.41 → phykit-2.1.43}/phykit/helpers/boolean_argument_parsing.py +0 -0
  21. {phykit-2.1.41 → phykit-2.1.43}/phykit/helpers/caching.py +0 -0
  22. {phykit-2.1.41 → phykit-2.1.43}/phykit/helpers/discrete_models.py +0 -0
  23. {phykit-2.1.41 → phykit-2.1.43}/phykit/helpers/files.py +0 -0
  24. {phykit-2.1.41 → phykit-2.1.43}/phykit/helpers/json_output.py +0 -0
  25. {phykit-2.1.41 → phykit-2.1.43}/phykit/helpers/parallel.py +0 -0
  26. {phykit-2.1.41 → phykit-2.1.43}/phykit/helpers/plot_config.py +0 -0
  27. {phykit-2.1.41 → phykit-2.1.43}/phykit/helpers/stats_summary.py +0 -0
  28. {phykit-2.1.41 → phykit-2.1.43}/phykit/helpers/streaming.py +0 -0
  29. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/__init__.py +0 -0
  30. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/__init__.py +0 -0
  31. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/alignment_entropy.py +0 -0
  32. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/alignment_length.py +0 -0
  33. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/alignment_length_no_gaps.py +0 -0
  34. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/alignment_outlier_taxa.py +0 -0
  35. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/alignment_recoding.py +0 -0
  36. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/base.py +0 -0
  37. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/column_score.py +0 -0
  38. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/composition_per_taxon.py +0 -0
  39. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/compositional_bias_per_site.py +0 -0
  40. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/create_concatenation_matrix.py +0 -0
  41. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/dna_threader.py +0 -0
  42. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/evolutionary_rate_per_site.py +0 -0
  43. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/faidx.py +0 -0
  44. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/gc_content.py +0 -0
  45. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/mask_alignment.py +0 -0
  46. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/occupancy_per_taxon.py +0 -0
  47. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/pairwise_identity.py +0 -0
  48. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/parsimony_informative_sites.py +0 -0
  49. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/plot_alignment_qc.py +0 -0
  50. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/rcv.py +0 -0
  51. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/rcvt.py +0 -0
  52. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/rename_fasta_entries.py +0 -0
  53. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/sum_of_pairs_score.py +0 -0
  54. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/alignment/variable_sites.py +0 -0
  55. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/base.py +0 -0
  56. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/ancestral_reconstruction.py +0 -0
  57. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/base.py +0 -0
  58. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/bipartition_support_stats.py +0 -0
  59. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/branch_length_multiplier.py +0 -0
  60. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/collapse_branches.py +0 -0
  61. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/concordance_asr.py +0 -0
  62. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/consensus_network.py +0 -0
  63. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/consensus_tree.py +0 -0
  64. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/cont_map.py +0 -0
  65. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/cophylo.py +0 -0
  66. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/covarying_evolutionary_rates.py +0 -0
  67. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/density_map.py +0 -0
  68. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/discordance_asymmetry.py +0 -0
  69. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/dvmc.py +0 -0
  70. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/evo_tempo_map.py +0 -0
  71. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/evolutionary_rate.py +0 -0
  72. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/fit_continuous.py +0 -0
  73. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/fit_discrete.py +0 -0
  74. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/hidden_paralogy_check.py +0 -0
  75. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/internal_branch_stats.py +0 -0
  76. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/internode_labeler.py +0 -0
  77. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/kf_distance.py +0 -0
  78. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/last_common_ancestor_subtree.py +0 -0
  79. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/lb_score.py +0 -0
  80. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/ltt.py +0 -0
  81. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/monophyly_check.py +0 -0
  82. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/nearest_neighbor_interchange.py +0 -0
  83. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/network_signal.py +0 -0
  84. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/ou_shift_detection.py +0 -0
  85. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/ouwie.py +0 -0
  86. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/patristic_distances.py +0 -0
  87. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/phenogram.py +0 -0
  88. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/phylogenetic_glm.py +0 -0
  89. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/phylogenetic_ordination.py +0 -0
  90. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/phylogenetic_regression.py +0 -0
  91. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/phylogenetic_signal.py +0 -0
  92. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/phylomorphospace.py +0 -0
  93. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/polytomy_test.py +0 -0
  94. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/print_tree.py +0 -0
  95. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/prune_tree.py +0 -0
  96. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/quartet_network.py +0 -0
  97. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/rate_heterogeneity.py +0 -0
  98. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/relative_rate_test.py +0 -0
  99. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/rename_tree_tips.py +0 -0
  100. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/rf_distance.py +0 -0
  101. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/root_tree.py +0 -0
  102. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/saturation.py +0 -0
  103. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/spectral_discordance.py +0 -0
  104. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/spurious_sequence.py +0 -0
  105. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/stochastic_character_map.py +0 -0
  106. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/terminal_branch_stats.py +0 -0
  107. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/threshold_model.py +0 -0
  108. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/tip_labels.py +0 -0
  109. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/tip_to_tip_distance.py +0 -0
  110. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/tip_to_tip_node_distance.py +0 -0
  111. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/total_tree_length.py +0 -0
  112. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/treeness.py +0 -0
  113. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/treeness_over_rcv.py +0 -0
  114. {phykit-2.1.41 → phykit-2.1.43}/phykit/services/tree/vcv_utils.py +0 -0
  115. {phykit-2.1.41 → phykit-2.1.43}/phykit.egg-info/dependency_links.txt +0 -0
  116. {phykit-2.1.41 → phykit-2.1.43}/phykit.egg-info/requires.txt +0 -0
  117. {phykit-2.1.41 → phykit-2.1.43}/phykit.egg-info/top_level.txt +0 -0
  118. {phykit-2.1.41 → phykit-2.1.43}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: phykit
3
- Version: 2.1.41
3
+ Version: 2.1.43
4
4
  Home-page: https://github.com/jlsteenwyk/phykit
5
5
  Author: Jacob L. Steenwyk
6
6
  Author-email: jlsteenwyk@gmail.com
@@ -116,6 +116,8 @@ ALIAS_TO_HANDLER: Dict[str, str] = {
116
116
  "quartet_net": "quartet_network",
117
117
  "qnet": "quartet_network",
118
118
  "nanuq": "quartet_network",
119
+ "qpie": "quartet_pie",
120
+ "quartet_pie_chart": "quartet_pie",
119
121
  "ltt": "ltt",
120
122
  "gamma_stat": "ltt",
121
123
  "gamma": "ltt",
@@ -0,0 +1,136 @@
1
+ """
2
+ Shared utilities for quartet concordance factor computation and ASTRAL parsing.
3
+
4
+ Provides gene concordance factor (gCF/gDF1/gDF2) computation via the
5
+ four-group bipartition decomposition, and parsing of ASTRAL -t 2
6
+ q1/q2/q3 annotations from Newick node labels.
7
+ """
8
+ from typing import Dict, List, Optional, Tuple
9
+
10
+ from ..errors import PhykitUserError
11
+
12
+
13
+ def canonical_split(tips: frozenset, all_taxa: frozenset) -> frozenset:
14
+ """Normalize a bipartition to a canonical frozenset-of-frozensets."""
15
+ complement = all_taxa - tips
16
+ return frozenset([tips, complement])
17
+
18
+
19
+ def compute_gcf_per_node(
20
+ species_tree, gene_trees: list
21
+ ) -> Dict[int, Tuple[float, float, float, int, int, int]]:
22
+ """Compute (gCF, gDF1, gDF2, concordant, disc1, disc2) per internal node.
23
+
24
+ Uses the four-group decomposition (C1, C2, S, D) around each internal
25
+ branch to identify the concordant bipartition and the two NNI alternatives.
26
+
27
+ Returns dict mapping clade id -> (gCF, gDF1, gDF2, n_conc, n_d1, n_d2).
28
+ """
29
+ all_taxa = frozenset(t.name for t in species_tree.get_terminals())
30
+
31
+ # Build parent map
32
+ parent_map = {}
33
+ for clade in species_tree.find_clades(order="preorder"):
34
+ for child in clade.clades:
35
+ parent_map[id(child)] = clade
36
+
37
+ # Extract bipartitions from all gene trees
38
+ gt_splits = []
39
+ for gt in gene_trees:
40
+ gt_taxa = frozenset(t.name for t in gt.get_terminals())
41
+ shared = gt_taxa & all_taxa
42
+ if len(shared) < 4:
43
+ gt_splits.append(set())
44
+ continue
45
+ splits = set()
46
+ for clade in gt.get_nonterminals():
47
+ tips = frozenset(t.name for t in clade.get_terminals()) & shared
48
+ if len(tips) <= 1 or tips == shared:
49
+ continue
50
+ splits.add(canonical_split(tips, shared))
51
+ gt_splits.append(splits)
52
+
53
+ result = {}
54
+ for clade in species_tree.find_clades(order="preorder"):
55
+ if clade.is_terminal() or clade == species_tree.root:
56
+ continue
57
+
58
+ children = clade.clades
59
+ if len(children) < 2:
60
+ continue
61
+
62
+ C1 = frozenset(t.name for t in children[0].get_terminals())
63
+ C2 = frozenset(t.name for t in children[1].get_terminals())
64
+ # Handle polytomies: merge extra children into C2
65
+ for c in children[2:]:
66
+ C2 = C2 | frozenset(t.name for t in c.get_terminals())
67
+
68
+ remaining = all_taxa - C1 - C2
69
+ if not remaining:
70
+ continue
71
+
72
+ concordant_bp = canonical_split(C1 | C2, all_taxa)
73
+ nni1_bp = canonical_split(remaining | C2, all_taxa)
74
+ nni2_bp = canonical_split(C1 | remaining, all_taxa)
75
+
76
+ conc = sum(1 for s in gt_splits if concordant_bp in s)
77
+ d1 = sum(1 for s in gt_splits if nni1_bp in s)
78
+ d2 = sum(1 for s in gt_splits if nni2_bp in s)
79
+
80
+ total = conc + d1 + d2
81
+ if total > 0:
82
+ gcf = conc / total
83
+ gdf1 = d1 / total
84
+ gdf2 = d2 / total
85
+ else:
86
+ gcf, gdf1, gdf2 = 1.0, 0.0, 0.0
87
+
88
+ result[id(clade)] = (gcf, gdf1, gdf2, conc, d1, d2)
89
+
90
+ return result
91
+
92
+
93
+ def parse_astral_annotations(
94
+ tree,
95
+ ) -> Dict[int, Tuple[float, float, float]]:
96
+ """Parse q1/q2/q3 annotations from ASTRAL -t 2 Newick node labels.
97
+
98
+ ASTRAL annotates internal nodes with formats like:
99
+ '[q1=0.5;q2=0.3;q3=0.2;f1=50;...]'
100
+ 'q1=0.5;q2=0.3;q3=0.2'
101
+
102
+ Returns dict mapping clade id -> (q1, q2, q3).
103
+ Only nodes with valid q1/q2/q3 are included.
104
+ """
105
+ result = {}
106
+ for clade in tree.find_clades(order="preorder"):
107
+ if clade.is_terminal():
108
+ continue
109
+ label = clade.name or clade.comment or ""
110
+ qs = _parse_qs_from_label(str(label))
111
+ if qs is not None:
112
+ result[id(clade)] = qs
113
+ return result
114
+
115
+
116
+ def _parse_qs_from_label(label: str) -> Optional[Tuple[float, float, float]]:
117
+ """Extract q1, q2, q3 from an ASTRAL node label string."""
118
+ s = label.strip("'\"").strip("[]")
119
+ q1 = q2 = q3 = None
120
+ for part in s.split(";"):
121
+ if "=" not in part:
122
+ continue
123
+ key, val = part.split("=", 1)
124
+ key = key.strip()
125
+ try:
126
+ if key == "q1":
127
+ q1 = float(val)
128
+ elif key == "q2":
129
+ q2 = float(val)
130
+ elif key == "q3":
131
+ q3 = float(val)
132
+ except ValueError:
133
+ continue
134
+ if q1 is not None and q2 is not None and q3 is not None:
135
+ return (q1, q2, q3)
136
+ return None
@@ -242,6 +242,9 @@ class Phykit:
242
242
  quartet_network (alias: quartet_net; qnet; nanuq)
243
243
  - quartet-based network inference (NANUQ-style)
244
244
  distinguishing ILS from hybridization
245
+ quartet_pie (alias: qpie; quartet_pie_chart)
246
+ - phylogram with quartet concordance pie charts
247
+ at internal nodes
245
248
  ltt (alias: gamma_stat; gamma)
246
249
  - lineage-through-time plot and Pybus & Harvey
247
250
  gamma statistic for diversification rate testing
@@ -4524,6 +4527,107 @@ class Phykit:
4524
4527
  _add_json_argument(parser)
4525
4528
  _run_service(parser, argv, ConsensusNetwork)
4526
4529
 
4530
+ @staticmethod
4531
+ def quartet_pie(argv):
4532
+ parser = _new_parser(
4533
+ description=textwrap.dedent(
4534
+ f"""\
4535
+ {help_header}
4536
+
4537
+ Draw a phylogram with pie charts at internal nodes showing
4538
+ quartet concordance proportions.
4539
+
4540
+ In native mode (-g provided), computes gene concordance
4541
+ factors (gCF, gDF1, gDF2) from a species tree and gene
4542
+ trees via bipartition matching. In ASTRAL mode (no -g),
4543
+ parses q1/q2/q3 annotations from ASTRAL -t 2 output.
4544
+
4545
+ Pie slices show: concordant (blue), discordant alt 1
4546
+ (red), discordant alt 2 (gray).
4547
+
4548
+ Aliases:
4549
+ quartet_pie, qpie, quartet_pie_chart
4550
+ Command line interfaces:
4551
+ pk_quartet_pie, pk_qpie
4552
+
4553
+ Usage:
4554
+ phykit quartet_pie -t <tree> [-g <gene_trees>] -o <output>
4555
+ [--annotate] [--json]
4556
+ [--fig-width <float>] [--fig-height <float>]
4557
+ [--dpi <int>] [--no-title] [--title <str>]
4558
+ [--legend-position <str>]
4559
+ [--ylabel-fontsize <float>] [--xlabel-fontsize <float>]
4560
+ [--title-fontsize <float>] [--axis-fontsize <float>]
4561
+ [--colors <str>]
4562
+
4563
+ Options
4564
+ =====================================================
4565
+ -t/--tree species tree file (required)
4566
+
4567
+ -g/--gene-trees gene trees file, one Newick
4568
+ tree per line (optional;
4569
+ if omitted, ASTRAL -t 2
4570
+ annotations are parsed)
4571
+
4572
+ -o/--output output figure path (required;
4573
+ supports .png, .pdf, .svg)
4574
+
4575
+ --annotate show gCF/gDF values as text
4576
+ near each pie chart
4577
+
4578
+ --fig-width figure width in inches
4579
+ (auto-scaled if omitted)
4580
+
4581
+ --fig-height figure height in inches
4582
+ (auto-scaled if omitted)
4583
+
4584
+ --dpi resolution in DPI
4585
+ (default: 300)
4586
+
4587
+ --no-title hide the plot title
4588
+
4589
+ --title custom title text
4590
+
4591
+ --legend-position legend location (e.g.,
4592
+ "upper right", "none")
4593
+
4594
+ --ylabel-fontsize font size for tip labels;
4595
+ 0 to hide
4596
+
4597
+ --xlabel-fontsize font size for x-axis labels;
4598
+ 0 to hide
4599
+
4600
+ --title-fontsize font size for the title
4601
+
4602
+ --axis-fontsize font size for axis labels
4603
+
4604
+ --colors comma-separated colors for
4605
+ concordant, disc1, disc2
4606
+ (default: "#2b8cbe,#d62728,
4607
+ #969696")
4608
+
4609
+ --json optional argument to output
4610
+ per-node concordance as JSON
4611
+ """
4612
+ ),
4613
+ )
4614
+ parser.add_argument(
4615
+ "-t", "--tree", type=str, required=True, help=SUPPRESS, metavar=""
4616
+ )
4617
+ parser.add_argument(
4618
+ "-g", "--gene-trees", type=str, required=False, default=None,
4619
+ help=SUPPRESS, metavar=""
4620
+ )
4621
+ parser.add_argument(
4622
+ "-o", "--output", type=str, required=True, help=SUPPRESS, metavar=""
4623
+ )
4624
+ parser.add_argument(
4625
+ "--annotate", action="store_true", required=False, help=SUPPRESS
4626
+ )
4627
+ add_plot_arguments(parser)
4628
+ _add_json_argument(parser)
4629
+ _run_service(parser, argv, QuartetPie)
4630
+
4527
4631
  @staticmethod
4528
4632
  def quartet_network(argv):
4529
4633
  parser = _new_parser(
@@ -6620,6 +6724,10 @@ def quartet_network(argv=None):
6620
6724
  Phykit.quartet_network(sys.argv[1:])
6621
6725
 
6622
6726
 
6727
+ def quartet_pie(argv=None):
6728
+ Phykit.quartet_pie(sys.argv[1:])
6729
+
6730
+
6623
6731
  def ltt(argv=None):
6624
6732
  Phykit.ltt(sys.argv[1:])
6625
6733
 
@@ -84,6 +84,7 @@ ThresholdModel = _LazyServiceFactory("phykit.services.tree.threshold_model", "Th
84
84
  PolytomyTest = _LazyServiceFactory("phykit.services.tree.polytomy_test", "PolytomyTest")
85
85
  PrintTree = _LazyServiceFactory("phykit.services.tree.print_tree", "PrintTree")
86
86
  PruneTree = _LazyServiceFactory("phykit.services.tree.prune_tree", "PruneTree")
87
+ QuartetPie = _LazyServiceFactory("phykit.services.tree.quartet_pie", "QuartetPie")
87
88
  RenameTreeTips = _LazyServiceFactory("phykit.services.tree.rename_tree_tips", "RenameTreeTips")
88
89
  FitDiscrete = _LazyServiceFactory("phykit.services.tree.fit_discrete", "FitDiscrete")
89
90
  KuhnerFelsensteinDistance = _LazyServiceFactory("phykit.services.tree.kf_distance", "KuhnerFelsensteinDistance")
@@ -28,6 +28,7 @@ _EXPORTS = {
28
28
  "PolytomyTest": "polytomy_test",
29
29
  "PrintTree": "print_tree",
30
30
  "PruneTree": "prune_tree",
31
+ "QuartetPie": "quartet_pie",
31
32
  "RenameTreeTips": "rename_tree_tips",
32
33
  "KuhnerFelsensteinDistance": "kf_distance",
33
34
  "RobinsonFouldsDistance": "rf_distance",
@@ -0,0 +1,305 @@
1
+ """
2
+ Quartet pie chart visualization of gene tree concordance.
3
+
4
+ Draws a phylogram with pie charts at internal nodes showing the
5
+ proportion of gene trees supporting the species tree topology (gCF)
6
+ versus the two NNI alternative topologies (gDF1, gDF2). Supports
7
+ both native computation from gene trees and parsing of ASTRAL -t 2
8
+ annotations.
9
+ """
10
+ import sys
11
+ from typing import Dict, List, Tuple
12
+
13
+ import numpy as np
14
+
15
+ from .base import Tree
16
+ from ...helpers.json_output import print_json
17
+ from ...helpers.plot_config import PlotConfig
18
+ from ...helpers.quartet_utils import (
19
+ compute_gcf_per_node,
20
+ parse_astral_annotations,
21
+ )
22
+ from ...errors import PhykitUserError
23
+
24
+
25
+ class QuartetPie(Tree):
26
+ def __init__(self, args) -> None:
27
+ parsed = self.process_args(args)
28
+ super().__init__(tree_file_path=parsed["tree_file_path"])
29
+ self.gene_trees_path = parsed["gene_trees_path"]
30
+ self.output_path = parsed["output_path"]
31
+ self.annotate = parsed["annotate"]
32
+ self.json_output = parsed["json_output"]
33
+ self.plot_config = parsed["plot_config"]
34
+
35
+ def run(self) -> None:
36
+ tree = self.read_tree_file()
37
+ self._validate_tree(tree)
38
+
39
+ if self.gene_trees_path:
40
+ # Native mode: compute gCF from gene trees
41
+ gene_trees = self._parse_gene_trees(self.gene_trees_path)
42
+ proportions = compute_gcf_per_node(tree, gene_trees)
43
+ input_mode = "native"
44
+ n_gene_trees = len(gene_trees)
45
+ else:
46
+ # ASTRAL mode: parse q1/q2/q3 from node labels
47
+ astral_props = parse_astral_annotations(tree)
48
+ if not astral_props:
49
+ raise PhykitUserError(
50
+ [
51
+ "No ASTRAL q1/q2/q3 annotations found in the tree.",
52
+ "Either provide gene trees with -g, or use an ASTRAL",
53
+ "-t 2 output tree with quartet annotations.",
54
+ ],
55
+ code=2,
56
+ )
57
+ # Convert to same format: (q1, q2, q3, 0, 0, 0) — no raw counts
58
+ proportions = {
59
+ cid: (q1, q2, q3, 0, 0, 0)
60
+ for cid, (q1, q2, q3) in astral_props.items()
61
+ }
62
+ input_mode = "astral"
63
+ n_gene_trees = 0
64
+
65
+ self._plot_quartet_pie(tree, proportions, self.output_path)
66
+
67
+ if self.json_output:
68
+ self._print_json(tree, proportions, input_mode, n_gene_trees)
69
+ else:
70
+ print(f"Quartet pie chart saved: {self.output_path}")
71
+
72
+ def process_args(self, args) -> Dict:
73
+ return dict(
74
+ tree_file_path=args.tree,
75
+ gene_trees_path=getattr(args, "gene_trees", None),
76
+ output_path=args.output,
77
+ annotate=getattr(args, "annotate", False),
78
+ json_output=getattr(args, "json", False),
79
+ plot_config=PlotConfig.from_args(args),
80
+ )
81
+
82
+ def _validate_tree(self, tree) -> None:
83
+ tips = list(tree.get_terminals())
84
+ if len(tips) < 4:
85
+ raise PhykitUserError(
86
+ ["Tree must have at least 4 tips for quartet analysis."],
87
+ code=2,
88
+ )
89
+ for clade in tree.find_clades():
90
+ if clade.branch_length is None and clade != tree.root:
91
+ clade.branch_length = 1e-8
92
+
93
+ def _parse_gene_trees(self, path: str) -> list:
94
+ from Bio import Phylo
95
+ try:
96
+ return list(Phylo.parse(path, "newick"))
97
+ except Exception:
98
+ raise PhykitUserError(
99
+ [
100
+ f"Could not parse gene trees from {path}.",
101
+ "File should contain one Newick tree per line.",
102
+ ],
103
+ code=2,
104
+ )
105
+
106
+ def _plot_quartet_pie(
107
+ self,
108
+ tree,
109
+ proportions: Dict[int, Tuple],
110
+ output_path: str,
111
+ ) -> None:
112
+ try:
113
+ import matplotlib
114
+ matplotlib.use("Agg")
115
+ import matplotlib.pyplot as plt
116
+ from matplotlib.patches import Patch
117
+ except ImportError:
118
+ print("matplotlib is required for quartet_pie. Install matplotlib and retry.")
119
+ raise SystemExit(2)
120
+
121
+ config = self.plot_config
122
+ tips = list(tree.get_terminals())
123
+ config.resolve(n_rows=len(tips), n_cols=None)
124
+
125
+ default_colors = ["#2b8cbe", "#d62728", "#969696"]
126
+ colors = config.merge_colors(default_colors)
127
+
128
+ # Build parent map
129
+ parent_map = {}
130
+ for clade in tree.find_clades(order="preorder"):
131
+ for child in clade.clades:
132
+ parent_map[id(child)] = clade
133
+
134
+ # Compute node positions
135
+ node_x = {}
136
+ node_y = {}
137
+
138
+ for i, tip in enumerate(tips):
139
+ node_y[id(tip)] = i
140
+
141
+ root = tree.root
142
+ for clade in tree.find_clades(order="preorder"):
143
+ if clade == root:
144
+ node_x[id(clade)] = 0.0
145
+ elif id(clade) in parent_map:
146
+ parent = parent_map[id(clade)]
147
+ t = clade.branch_length if clade.branch_length else 0.0
148
+ node_x[id(clade)] = node_x.get(id(parent), 0.0) + t
149
+
150
+ for clade in tree.find_clades(order="postorder"):
151
+ if not clade.is_terminal() and id(clade) not in node_y:
152
+ child_ys = [
153
+ node_y[id(c)] for c in clade.clades if id(c) in node_y
154
+ ]
155
+ if child_ys:
156
+ node_y[id(clade)] = np.mean(child_ys)
157
+ else:
158
+ node_y[id(clade)] = 0.0
159
+
160
+ fig, ax = plt.subplots(figsize=(config.fig_width, config.fig_height))
161
+
162
+ # Draw branches
163
+ for clade in tree.find_clades(order="preorder"):
164
+ if clade == root:
165
+ continue
166
+ if id(clade) not in parent_map:
167
+ continue
168
+ parent = parent_map[id(clade)]
169
+ if id(parent) not in node_x or id(clade) not in node_x:
170
+ continue
171
+
172
+ x0 = node_x[id(parent)]
173
+ x1 = node_x[id(clade)]
174
+ y0 = node_y.get(id(parent), 0)
175
+ y1 = node_y.get(id(clade), 0)
176
+
177
+ ax.plot([x0, x1], [y1, y1], color="black", lw=1.5)
178
+ ax.plot([x0, x0], [y0, y1], color="black", lw=1.5)
179
+
180
+ # Pie charts at internal nodes — rendered as inset axes so they
181
+ # appear as perfect circles regardless of axis scaling, and are
182
+ # drawn above the phylogeny branches.
183
+ max_x = max(node_x.values()) if node_x else 1.0
184
+ n_tips = len(tips)
185
+ # Pie size in figure-fraction units (scales with figure, not data)
186
+ pie_size = min(0.06, 0.8 / max(n_tips, 1))
187
+
188
+ # Force a draw so transData is populated
189
+ fig.canvas.draw()
190
+
191
+ for clade in tree.find_clades(order="preorder"):
192
+ if clade.is_terminal() or clade == root:
193
+ continue
194
+ cid = id(clade)
195
+ if cid not in proportions:
196
+ continue
197
+
198
+ props = proportions[cid]
199
+ gcf, gdf1, gdf2 = props[0], props[1], props[2]
200
+ cx = node_x.get(cid, 0)
201
+ cy = node_y.get(cid, 0)
202
+
203
+ # Convert data coords to figure-fraction coords for the inset
204
+ disp = ax.transData.transform((cx, cy))
205
+ fig_coord = fig.transFigure.inverted().transform(disp)
206
+ fx, fy = fig_coord
207
+
208
+ # Create a small inset axes centered on the node
209
+ inset = fig.add_axes(
210
+ [fx - pie_size / 2, fy - pie_size / 2, pie_size, pie_size],
211
+ zorder=10,
212
+ )
213
+ wedge_vals = [gcf, gdf1, gdf2]
214
+ wedge_colors = [c for v, c in zip(wedge_vals, colors) if v > 1e-6]
215
+ wedge_vals = [v for v in wedge_vals if v > 1e-6]
216
+ if wedge_vals:
217
+ inset.pie(
218
+ wedge_vals, colors=wedge_colors, startangle=90,
219
+ wedgeprops={"edgecolor": "black", "linewidth": 0.5},
220
+ )
221
+ inset.set_aspect("equal")
222
+ inset.axis("off")
223
+
224
+ # Annotate with values if requested
225
+ if self.annotate:
226
+ ax.annotate(
227
+ f"{gcf:.2f}/{gdf1:.2f}/{gdf2:.2f}",
228
+ (cx, cy),
229
+ textcoords="offset points",
230
+ xytext=(8, 8),
231
+ fontsize=6,
232
+ color="black",
233
+ zorder=11,
234
+ )
235
+
236
+ # Tip labels
237
+ offset = max_x * 0.03
238
+ label_fontsize = config.ylabel_fontsize if config.ylabel_fontsize and config.ylabel_fontsize > 0 else 9
239
+ for tip in tips:
240
+ ax.text(
241
+ node_x[id(tip)] + offset, node_y[id(tip)],
242
+ tip.name, va="center", fontsize=label_fontsize,
243
+ )
244
+
245
+ # Legend
246
+ legend_handles = [
247
+ Patch(facecolor=colors[0], edgecolor="black", linewidth=0.5,
248
+ label="Concordant (gCF / q1)"),
249
+ Patch(facecolor=colors[1], edgecolor="black", linewidth=0.5,
250
+ label="Discordant alt 1 (gDF1 / q2)"),
251
+ Patch(facecolor=colors[2], edgecolor="black", linewidth=0.5,
252
+ label="Discordant alt 2 (gDF2 / q3)"),
253
+ ]
254
+ legend_loc = config.legend_position or "upper right"
255
+ if legend_loc != "none":
256
+ ax.legend(handles=legend_handles, loc=legend_loc, fontsize=8, frameon=True)
257
+
258
+ ax.set_xlabel("Branch length (subs/site)")
259
+ ax.set_yticks([])
260
+ ax.spines["top"].set_visible(False)
261
+ ax.spines["right"].set_visible(False)
262
+ ax.spines["left"].set_visible(False)
263
+
264
+ if config.show_title:
265
+ ax.set_title(
266
+ config.title or "Quartet Concordance Pie Chart",
267
+ fontsize=config.title_fontsize,
268
+ )
269
+ if config.axis_fontsize:
270
+ ax.xaxis.label.set_fontsize(config.axis_fontsize)
271
+
272
+ # Use constrained_layout=False since inset pie axes are incompatible
273
+ # with tight_layout; manual padding via subplots_adjust instead
274
+ fig.subplots_adjust(left=0.05, right=0.85, top=0.92, bottom=0.12)
275
+ fig.savefig(output_path, dpi=config.dpi, bbox_inches="tight")
276
+ plt.close(fig)
277
+
278
+ def _print_json(self, tree, proportions, input_mode, n_gene_trees):
279
+ nodes = []
280
+ for clade in tree.find_clades(order="preorder"):
281
+ if clade.is_terminal():
282
+ continue
283
+ cid = id(clade)
284
+ if cid not in proportions:
285
+ continue
286
+ props = proportions[cid]
287
+ tip_names = sorted(t.name for t in clade.get_terminals())
288
+ nodes.append({
289
+ "node_tips": tip_names,
290
+ "gCF": round(props[0], 4),
291
+ "gDF1": round(props[1], 4),
292
+ "gDF2": round(props[2], 4),
293
+ "concordant_count": props[3],
294
+ "disc1_count": props[4],
295
+ "disc2_count": props[5],
296
+ })
297
+
298
+ payload = {
299
+ "n_taxa": tree.count_terminals(),
300
+ "n_gene_trees": n_gene_trees,
301
+ "input_mode": input_mode,
302
+ "nodes": nodes,
303
+ "output_file": self.output_path,
304
+ }
305
+ print_json(payload)
@@ -0,0 +1 @@
1
+ __version__ = "2.1.43"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: phykit
3
- Version: 2.1.41
3
+ Version: 2.1.43
4
4
  Home-page: https://github.com/jlsteenwyk/phykit
5
5
  Author: Jacob L. Steenwyk
6
6
  Author-email: jlsteenwyk@gmail.com
@@ -22,6 +22,7 @@ phykit/helpers/files.py
22
22
  phykit/helpers/json_output.py
23
23
  phykit/helpers/parallel.py
24
24
  phykit/helpers/plot_config.py
25
+ phykit/helpers/quartet_utils.py
25
26
  phykit/helpers/stats_summary.py
26
27
  phykit/helpers/streaming.py
27
28
  phykit/services/__init__.py
@@ -93,6 +94,7 @@ phykit/services/tree/polytomy_test.py
93
94
  phykit/services/tree/print_tree.py
94
95
  phykit/services/tree/prune_tree.py
95
96
  phykit/services/tree/quartet_network.py
97
+ phykit/services/tree/quartet_pie.py
96
98
  phykit/services/tree/rate_heterogeneity.py
97
99
  phykit/services/tree/relative_rate_test.py
98
100
  phykit/services/tree/rename_tree_tips.py
@@ -163,8 +163,11 @@ pk_ps = phykit.phykit:phylogenetic_signal
163
163
  pk_pt = phykit.phykit:print_tree
164
164
  pk_ptt = phykit.phykit:polytomy_test
165
165
  pk_qnet = phykit.phykit:quartet_network
166
+ pk_qpie = phykit.phykit:quartet_pie
166
167
  pk_quartet_net = phykit.phykit:quartet_network
167
168
  pk_quartet_network = phykit.phykit:quartet_network
169
+ pk_quartet_pie = phykit.phykit:quartet_pie
170
+ pk_quartet_pie_chart = phykit.phykit:quartet_pie
168
171
  pk_rate_heterogeneity = phykit.phykit:rate_heterogeneity
169
172
  pk_rcv = phykit.phykit:rcv
170
173
  pk_rcvt = phykit.phykit:rcvt
@@ -202,6 +202,9 @@ setup(
202
202
  "pk_quartet_net = phykit.phykit:quartet_network",
203
203
  "pk_qnet = phykit.phykit:quartet_network",
204
204
  "pk_nanuq = phykit.phykit:quartet_network",
205
+ "pk_quartet_pie = phykit.phykit:quartet_pie",
206
+ "pk_qpie = phykit.phykit:quartet_pie",
207
+ "pk_quartet_pie_chart = phykit.phykit:quartet_pie",
205
208
  "pk_ltt = phykit.phykit:ltt",
206
209
  "pk_gamma_stat = phykit.phykit:ltt",
207
210
  "pk_gamma = phykit.phykit:ltt",
@@ -1 +0,0 @@
1
- __version__ = "2.1.41"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes