Repository: cogent3/cogent3
Pull request #1610 (2023-10-18 11:41:38)

DEP: moved Alphabet methods resolve_ambiguity() and get_matched_array()
[CHANGED] resolve_ambiguity() is now on the MolType, while get_matched_array()
    is a standalone function in the likelihood_tree module (the only place
    it's used).

[CHANGED] where possible, now using moltype directly rather than from alphabet
    class


Patch:
diff --git a/src/cogent3/align/progressive.py b/src/cogent3/align/progressive.py
index 1498c85a8..6858d73cd 100644
--- a/src/cogent3/align/progressive.py
+++ b/src/cogent3/align/progressive.py
@@ -66,7 +66,7 @@ def tree_align(
     param_vals = dict(param_vals) if param_vals else {}
 
     model = get_model(model)
-    moltype = model.alphabet.moltype
+    moltype = model.moltype
 
     num_states = len(model.alphabet)
 
diff --git a/src/cogent3/app/align.py b/src/cogent3/app/align.py
index b6b7ada6d..34c83c3e9 100644
--- a/src/cogent3/app/align.py
+++ b/src/cogent3/app/align.py
@@ -470,7 +470,7 @@ class progressive_align:
         )
         kwargs = {} if gc is None else dict(gc=gc)
         sm = get_model(sm, **kwargs)
-        moltype = sm.alphabet.moltype
+        moltype = sm.moltype
         self._model = sm
         self._scalar = sm.word_length
         self._indel_length = indel_length
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index d967051d9..624bb89a3 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -1619,8 +1619,9 @@ class _SequenceCollectionBase:
 
         only non-overlapping motifs are counted
         """
+        moltype = self.moltype
         if alphabet is None:
-            alphabet = self.moltype.alphabet
+            alphabet = moltype.alphabet
             if allow_gap:
                 alphabet = alphabet.gapped
 
@@ -1647,7 +1648,7 @@ class _SequenceCollectionBase:
                 probs[motif] = pseudocount
 
         for motif, count in list(counts.items()):
-            motif_set = alphabet.resolve_ambiguity(motif)
+            motif_set = moltype.resolve_ambiguity(motif, alphabet=alphabet)
             if len(motif_set) > 1:
                 if include_ambiguity:
                     count = float(count) / len(motif_set)
diff --git a/src/cogent3/core/alphabet.py b/src/cogent3/core/alphabet.py
index ed3442528..2d5dedc5c 100644
--- a/src/cogent3/core/alphabet.py
+++ b/src/cogent3/core/alphabet.py
@@ -565,13 +565,13 @@ class Alphabet(Enumeration):
             motif_subset = [m for m in self if m not in motif_subset]
         return self._with(motif_subset)
 
-    # todo: move onto moltype
+    @c3warns.deprecated_callable(
+        "2024.3",
+        reason="does not belong on alphabet",
+        new="<moltype>.resolve_ambiguity()",
+    )
     def resolve_ambiguity(self, ambig_motif):
-        """Returns set of symbols corresponding to ambig_motif.
-
-        Handles multi-character symbols and screens against the set of
-        valid motifs, unlike the MolType version.
-        """
+        """deprecated, use method on MolType"""
         # shortcut easy case
         if ambig_motif in self._quick_motifset:
             return (ambig_motif,)
@@ -605,20 +605,13 @@ class Alphabet(Enumeration):
 
         return tuple(motif_set)
 
-    # todo: move onto moltype
+    @c3warns.deprecated_callable(
+        "2024.3",
+        reason="does not belong on alphabet",
+        new="cogent3.evolve.likelihood_tree.get_matched_array",
+    )
     def get_matched_array(self, motifs, dtype=float):
-        """Returns an array in which rows are motifs, columns are items in self.
-
-        Result is an array of Float in which a[i][j] indicates whether the ith
-        motif passed in as motifs is a symbol that matches the jth character
-        in self. For example, on the DNA alphabet 'TCAG', the degenerate symbol
-        'Y' would correspond to the row [1,1,0,0] because Y is a degenerate
-        symbol that encompasses T and C but not A or G.
-
-        This code is similar to code in the Profile class, and should perhaps
-        be merged with it (in particular, because there is nothing likelihood-
-        specific about the resulting match table).
-        """
+        """deprecated, use function in evolve.likelihood_tree"""
         result = zeros([len(motifs), len(self)], dtype)
         obj_to_index = self._obj_to_index
         for u, ambig_motif in enumerate(motifs):
diff --git a/src/cogent3/core/moltype.py b/src/cogent3/core/moltype.py
index 5eb391011..b0f0a7184 100644
--- a/src/cogent3/core/moltype.py
+++ b/src/cogent3/core/moltype.py
@@ -11,8 +11,10 @@ the MolType. It is thus essential that the connection between these other
 types and the MolType can be made after the objects are created.
 """
 
+import itertools
 import json
 import re
+import typing
 
 from collections import defaultdict
 from copy import deepcopy
@@ -1284,6 +1286,56 @@ class MolType(object):
 
         return css, styles
 
+    def resolve_ambiguity(
+        self,
+        ambig_motif: str,
+        alphabet: typing.Optional[Alphabet] = None,
+        allow_gap: bool = False,
+    ) -> typing.Tuple[str]:
+        """Returns tuple of all possible canonical characters corresponding
+        to ambig_motif
+
+        Parameters
+        ----------
+        ambig_motif
+            the string to be expanded
+        alphabet
+            optional, disambiguated motifs not present in alphabet will be
+            excluded. This could be a codon alphabet where stop codons are
+            not present.
+        allow_gap
+            whether the gap character is allowed in output. Only
+            applied when alphabet is None.
+
+        Notes
+        -----
+        If ambig_motif is > 1 character long and alphabet is None, we construct
+        a word alphabet with the same length.
+        """
+        ambiguities = {**self.ambiguities}
+        if alphabet is None:
+            word_alpha = self.alphabet.get_word_alphabet(len(ambig_motif))
+            alphabet = word_alpha.with_gap_motif() if allow_gap else word_alpha
+            if not allow_gap:
+                ambiguities["?"] = tuple(c for c in ambiguities["?"] if c != self.gap)
+
+        if ambig_motif in alphabet:
+            return (ambig_motif,)
+
+        try:
+            resolved = [ambiguities[c] for c in ambig_motif]
+        except KeyError:
+            raise AlphabetError(ambig_motif)
+
+        result = tuple("".join(e) for e in itertools.product(*resolved))
+        if alphabet:
+            result = tuple(e for e in result if e in alphabet)
+
+        if not result:
+            raise AlphabetError(ambig_motif)
+
+        return result
+
 
 def _convert_to_rna(seq: str) -> str:
     return seq.replace("t", "u").replace("T", "U")
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index 797d411dd..5a78c9489 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -1742,6 +1742,7 @@ class NucleicAcidSequence(Sequence):
         protein = get_moltype("protein_with_stop" if include_stop else "protein")
         gc = get_code(gc)
         codon_alphabet = gc.get_alphabet(include_stop=include_stop).with_gap_motif()
+        moltype = self.moltype
         # translate the codons
         translation = []
         if include_stop or not trim_stop:
@@ -1753,7 +1754,9 @@ class NucleicAcidSequence(Sequence):
         for posn in range(0, len(seq) - 2, 3):
             orig_codon = str(seq[posn : posn + 3])
             try:
-                resolved = codon_alphabet.resolve_ambiguity(orig_codon)
+                resolved = moltype.resolve_ambiguity(
+                    orig_codon, alphabet=codon_alphabet
+                )
             except AlphabetError:
                 if not incomplete_ok or "-" not in orig_codon:
                     raise AlphabetError(
diff --git a/src/cogent3/evolve/likelihood_tree.py b/src/cogent3/evolve/likelihood_tree.py
index 9cef8aebb..77ae96b8b 100644
--- a/src/cogent3/evolve/likelihood_tree.py
+++ b/src/cogent3/evolve/likelihood_tree.py
@@ -205,12 +205,29 @@ def _indexed(values):
     return unique, counts, index
 
 
+def get_matched_array(alphabet, moltype, motifs, dtype=float) -> numpy.ndarray:
+    """Returns an array in which rows are motifs, columns are items in self.
+
+    Result is an array of dtype in which a[i][j] indicates whether the ith
+    motif passed in as motifs is a symbol that matches the jth character
+    in self. For example, on the DNA alphabet 'TCAG', the degenerate symbol
+    'Y' would correspond to the row [1,1,0,0] because Y is a degenerate
+    symbol that encompasses T and C but not A or G.
+    """
+    result = numpy.zeros([len(motifs), len(alphabet)], dtype)
+    obj_to_index = alphabet.to_indices
+    for u, ambig_motif in enumerate(motifs):
+        for motif in moltype.resolve_ambiguity(ambig_motif, alphabet=alphabet):
+            result[u, obj_to_index((motif,))] = 1.0
+    return result
+
+
 def make_likelihood_tree_leaf(sequence, alphabet, seq_name):
     motif_len = alphabet.get_motif_len()
     sequence2 = sequence.get_in_motif_size(motif_len)
 
     # Convert sequence to indexed list of unique motifs
-    (uniq_motifs, counts, index) = _indexed(sequence2)
+    uniq_motifs, counts, index = _indexed(sequence2)
 
     # extra column for gap
     uniq_motifs.append("?" * motif_len)
@@ -220,7 +237,9 @@ def make_likelihood_tree_leaf(sequence, alphabet, seq_name):
 
     # Convert list of unique motifs to array of unique profiles
     try:
-        likelihoods = alphabet.get_matched_array(uniq_motifs, float)
+        likelihoods = get_matched_array(
+            alphabet, alphabet.moltype, uniq_motifs, dtype=float
+        )
     except alphabet.AlphabetError as detail:
         motif = str(detail)
         posn = list(sequence2).index(motif) * motif_len
diff --git a/src/cogent3/evolve/motif_prob_model.py b/src/cogent3/evolve/motif_prob_model.py
index faac29ee0..f091a95ef 100644
--- a/src/cogent3/evolve/motif_prob_model.py
+++ b/src/cogent3/evolve/motif_prob_model.py
@@ -49,11 +49,10 @@ class MotifProbModel(object):
 
     def count_motifs(self, alignment, include_ambiguity=False, recode_gaps=True):
         result = None
+        alpha = self.get_counted_alphabet()
         for seq_name in alignment.names:
             sequence = alignment.get_gapped_seq(seq_name, recode_gaps)
-            leaf = make_likelihood_tree_leaf(
-                sequence, self.get_counted_alphabet(), seq_name
-            )
+            leaf = make_likelihood_tree_leaf(sequence, alpha, seq_name)
             count = leaf.get_motif_counts(include_ambiguity=include_ambiguity)
             if result is None:
                 result = count.copy()
diff --git a/src/cogent3/evolve/substitution_model.py b/src/cogent3/evolve/substitution_model.py
index a49dfa753..9c3044b48 100644
--- a/src/cogent3/evolve/substitution_model.py
+++ b/src/cogent3/evolve/substitution_model.py
@@ -388,7 +388,7 @@ class _SubstitutionModel(object):
         return result
 
     def convert_sequence(self, sequence, name):
-        # make_likelihood_tree_leaf, sort of an indexed profile where duplicate
+        # make_likelihood_tree_leaf, a sort of indexed profile where duplicate
         # columns stored once, so likelihoods only calc'd once
         return make_likelihood_tree_leaf(sequence, self.get_alphabet(), name)
 


Test patch:
diff --git a/tests/test_core/test_moltype.py b/tests/test_core/test_moltype.py
index bd4636194..382df337e 100644
--- a/tests/test_core/test_moltype.py
+++ b/tests/test_core/test_moltype.py
@@ -1,7 +1,8 @@
-#!/usr/bin/env python
 import pickle
 
-from unittest import TestCase, main
+from unittest import TestCase
+
+import pytest
 
 # ind some of the standard alphabets to reduce typing
 from numpy.testing import assert_allclose
@@ -968,19 +969,6 @@ class TestCodonAlphabet(_AlphabetTestCase):
 
         self.alpha = STANDARD_CODON
 
-    def test_ambiguous_gaps(self):
-        alpha = self.alpha.with_gap_motif()
-        self.assertEqual(len(alpha.resolve_ambiguity("AT?")), 4)
-        self.assertRaises(Exception, alpha.resolve_ambiguity, "at-")
-        self.assertEqual(len(alpha.resolve_ambiguity("???")), 62)
-        self.assertEqual(len(alpha.resolve_ambiguity("---")), 1)
-
-        alpha = self.alpha
-        self.assertEqual(len(alpha.resolve_ambiguity("AT?")), 4)
-        self.assertRaises(Exception, alpha.resolve_ambiguity, "at-")
-        self.assertEqual(len(alpha.resolve_ambiguity("???")), 61)
-        self.assertRaises(Exception, alpha.resolve_ambiguity, "---")
-
     def test_exclude(self):
         """testing excluding gap motif"""
         alpha = self.alpha
@@ -1128,5 +1116,26 @@ class TestCodonAlphabet(_AlphabetTestCase):
         self.assertEqual(alpha_int, alpha_name)
 
 
-if __name__ == "__main__":
-    main()
+def test_resolve_ambiguity_nucs():
+    got = DNA.resolve_ambiguity("AT?", allow_gap=False)
+    assert len(got) == 4
+    assert len(got[0]) == 3
+
+
+def test_resolve_ambiguity_codons():
+    from cogent3 import get_code
+
+    gc = get_code(1)
+    codon_alpha = gc.get_alphabet(include_stop=False)
+    codon_alpha_w_gap = codon_alpha.with_gap_motif()
+    assert len(DNA.resolve_ambiguity("AT?", alphabet=codon_alpha_w_gap)) == 4
+    assert len(DNA.resolve_ambiguity("???", alphabet=codon_alpha_w_gap)) == 62
+    assert len(DNA.resolve_ambiguity("---", alphabet=codon_alpha_w_gap)) == 1
+
+    assert len(DNA.resolve_ambiguity("AT?", alphabet=codon_alpha)) == 4
+    assert len(DNA.resolve_ambiguity("???", alphabet=codon_alpha)) == 61
+
+    with pytest.raises(AlphabetError):
+        DNA.resolve_ambiguity("at-")
+    with pytest.raises(AlphabetError):
+        DNA.resolve_ambiguity("---", alphabet=codon_alpha)