Repository: cogent3/cogent3
Pull request #2100 (2024-11-07 23:20:12)

ENH: enable creating sequence collections from Sequences
## Summary by Sourcery

Enable creation of sequence collections from Sequence objects and refactor sequence handling for improved clarity and functionality. Introduce new methods for sequence conversion and enhance testing coverage.

New Features:
- Enable creation of sequence collections from Sequence objects, allowing for more flexible data handling.

Enhancements:
- Refactor sequence naming and data preparation processes to improve code clarity and maintainability.
- Introduce a new class, _SeqNamer, to handle sequence naming more effectively.
- Add new methods to the Sequence class for converting sequences to numpy arrays with optional transformations.

Tests:
- Add a new test for the Sequence class's to_array method to ensure correct functionality.


Patch:
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index f9a2fcc46..28a6da91a 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import contextlib
+import dataclasses
 import json
 import os
 import re
@@ -29,6 +31,7 @@ from cogent3.core.location import (
     _input_vals_neg_step,
     _input_vals_pos_step,
 )
+from cogent3.core.new_alphabet import StrORBytesORArray
 from cogent3.core.profile import PSSM, MotifCountsArray, MotifFreqsArray, load_pssm
 from cogent3.format.alignment import save_to_filename
 from cogent3.format.fasta import seqs_to_fasta
@@ -47,6 +50,13 @@ from cogent3.util.misc import (
 )
 from cogent3.util.union_dict import UnionDict
 
+# DESIGN NOTES
+# the sequence data collections (SeqsDataABC and AlignedSeqsDataABC)
+# have no concept of strand. All transformations with respect to strand
+# are applied by the sequence record objects that have a .moltype
+# attribute, i.e. Sequence and Aligned.
+
+
 DEFAULT_ANNOTATION_DB = BasicAnnotationDb
 
 OptInt = Optional[int]
@@ -54,11 +64,14 @@ OptFloat = Optional[float]
 OptStr = Optional[str]
 OptList = Optional[list]
 OptDict = Optional[dict]
+DictStrStr = dict[str, str]
+DictStrInt = dict[str, int]
 OptCallable = Optional[Callable]
 OptRenamerCallable = Optional[Callable[[str], str]]
 OptPathType = Union[str, Path, None]
-StrORArray = Union[str, numpy.ndarray]
-StrORBytesORArray = Union[str, bytes, numpy.ndarray]
+StrORArray = Union[str, numpy.ndarray[int]]
+StrORBytesORArray = Union[str, bytes, numpy.ndarray[int]]
+StrORBytesORArrayOrSeq = Union[str, bytes, numpy.ndarray[int], new_sequence.Sequence]
 MolTypes = Union[str, new_moltype.MolType]
 
 # small number: 1-EPS is almost 1, and is used for things like the
@@ -66,20 +79,27 @@ MolTypes = Union[str, new_moltype.MolType]
 EPS = 1e-6
 
 
-def assign_sequential_names(num_seqs: int, base_name: str = "seq", start_at: int = 0):
-    """Returns list of sequential, unique names, e.g., ['seq_0' ... 'seq_n'] for
-     num_seqs = n+1
+class _SeqNamer:
+    def __init__(
+        self,
+        name_func: OptRenamerCallable = None,
+        base_name: str = "seq",
+        start_at: int = 0,
+    ):
+        self._base_name = base_name
+        self._num = start_at
+        self._name_func = name_func
+
+    def __call__(self, seq: StrORBytesORArray, name: OptStr = None) -> str:
+        name = name or getattr(seq, "name", name)
+
+        if not name:
+            name = f"{self._base_name}_{self._num}"
+            self._num += 1
+        elif self._name_func:
+            name = self._name_func(name)
 
-    Parameters
-    ----------
-    num_seqs
-        number of names to generate
-    base_name
-        the sequence name prefix
-    start_at
-        the number to start the sequence names at
-    """
-    return [f"{base_name}_{i}" for i in range(start_at, start_at + num_seqs)]
+        return name
 
 
 class GapsOk:
@@ -333,7 +353,7 @@ class SeqsDataABC(ABC):
     def to_alphabet(self, alphabet: new_alphabet.AlphabetABC) -> SeqsDataABC: ...
 
     @abstractmethod
-    def add_seqs(self, seqs) -> SeqsDataABC: ...
+    def add_seqs(self, seqs, **kwargs) -> SeqsDataABC: ...
 
     @abstractmethod
     def to_rich_dict(self) -> dict: ...
@@ -411,7 +431,7 @@ class SeqsData(SeqsDataABC):
         return list(self._data.keys())
 
     @property
-    def alphabet(self) -> new_alphabet.CharAlphabet:
+    def alphabet(self) -> new_alphabet.AlphabetABC:
         return self._alphabet
 
     @property
@@ -808,13 +828,20 @@ class SequenceCollection:
         seqs
             sequences to add
         """
-        data = coerce_to_seqs_data_dict(seqs, label_to_name=None)
+        assign_names = _SeqNamer()
+        data, offsets, is_reversed, name_map = prep_for_seqs_data(
+            seqs, self.moltype, assign_names
+        )
+
+        if not name_map:
+            name_map = dict(zip(data, data))
+
+        kwargs["offset"] = offsets
         seqs_data = self._seqs_data.add_seqs(data, **kwargs)
-        new_seqs_names = {name: name for name in data.keys()}
         return self.__class__(
             seqs_data=seqs_data,
             moltype=self.moltype,
-            name_map={**self._name_map, **new_seqs_names},
+            name_map={**self._name_map, **name_map},
             info=self.info,
             source=self.source,
             annotation_db=self.annotation_db,
@@ -909,12 +936,15 @@ class SequenceCollection:
         -----
         The returned collection will not retain an annotation_db if present.
         """
-        seqs = {
-            name: self.moltype.degap(self._seqs_data.get_seq_array(seqid=name))
-            for name in self._name_map.values()
-        }
+        data = {}
+        for name in self.names:
+            # because we are in a SequenceCollection, which cannot be sliced, so
+            # we can just interrogate the bound _seqs_data directly.
+            seq = self._seqs_data.get_seq_array(seqid=self._name_map.get(name, name))
+            data[name] = self.moltype.degap(seq)
+
         seqs_data = self._seqs_data.from_seqs(
-            data=coerce_to_seqs_data_dict(seqs),
+            data=data,
             alphabet=self._seqs_data.alphabet,
             offset=self._seqs_data.offset,
             reversed=self._seqs_data.is_reversed,
@@ -1007,24 +1037,23 @@ class SequenceCollection:
 
         translated = {}
         # do the translation
-        for seqname in self.names:
-            seq = self.seqs[seqname]
+        for seq in self.seqs:
             pep = seq.get_translation(
                 gc,
                 incomplete_ok=incomplete_ok,
                 include_stop=include_stop,
                 trim_stop=trim_stop,
             )
-            translated[seqname] = pep
-
-        seqs_data = coerce_to_seqs_data_dict(translated, label_to_name=None)
-        pep_moltype = pep.moltype
+            translated[seq.name] = numpy.array(pep)
 
+        pep_moltype = new_moltype.get_moltype(
+            "protein_with_stop" if include_stop else "protein"
+        )
         seqs_data = self._seqs_data.from_seqs(
-            data=seqs_data,
+            data=translated,
             alphabet=pep_moltype.most_degen_alphabet(),
             offset=self._seqs_data.offset,
-            reversed=self._seqs_data.is_reversed,
+            reversed=False,  # reversed not meaningful for a protein sequence
         )
         return self.__class__(
             seqs_data=seqs_data,
@@ -1514,10 +1543,15 @@ class SequenceCollection:
         if not self.has_terminal_stop(gc=gc, strict=strict):
             return self
 
-        new_seqs = {s.name: s.trim_stop_codon(gc=gc, strict=strict) for s in self.seqs}
+        new_seqs = {
+            s.name: s.trim_stop_codon(gc=gc, strict=strict).to_array(
+                apply_transforms=False
+            )
+            for s in self.seqs
+        }
 
         seqs_data = self._seqs_data.from_seqs(
-            data=coerce_to_seqs_data_dict(new_seqs),
+            data=new_seqs,
             alphabet=self._seqs_data.alphabet,
             offset=self._seqs_data.offset,
             reversed=self._seqs_data.is_reversed,
@@ -2288,97 +2322,122 @@ def _(seqs: dict) -> SupportsFeatures:
     return merged_db_collection(seqs.values())
 
 
-@singledispatch
-def coerce_to_seqs_data_dict(
-    data, label_to_name: OptRenamerCallable = None
-) -> dict[str, StrORBytesORArray]:
-    # refactor: handle conversion of SeqView to SeqDataView.
-    raise NotImplementedError(
-        f"coerce_to_seqs_data_dict not implemented for {type(data)}"
-    )
+@dataclasses.dataclass
+class raw_seq_data:
+    seq: StrORBytesORArray
+    name: OptStr = None
+    parent_name: OptStr = None
+    offset: int = 0
+    is_reversed: bool = False
 
 
-@coerce_to_seqs_data_dict.register
-def _(
-    data: dict, label_to_name: OptRenamerCallable = None
-) -> dict[str, StrORBytesORArray]:
-    is_sequence = isinstance(next(iter(data.values()), None), new_sequence.Sequence)
-    return {
-        (label_to_name(k) if label_to_name else k): (
-            numpy.array(v) if is_sequence else v
-        )
-        for k, v in data.items()
-    }
+@singledispatch
+def coerce_to_raw_seq_data(
+    seq, moltype: new_moltype.MolType, name: OptStr = None
+) -> raw_seq_data:
+    if isinstance(seq, Aligned):
+        name = name or seq.name
+        seq = str(seq)
+        return coerce_to_raw_seq_data(seq, moltype, name)
+    raise TypeError(f"coerce_to_seq_data not implemented for {type(seq)}")
 
 
-@coerce_to_seqs_data_dict.register
+@coerce_to_raw_seq_data.register
 def _(
-    data: list, label_to_name: OptRenamerCallable = None
-) -> dict[str, StrORBytesORArray]:
-    first = data[0]
-    labelled_seqs = assign_names(first, data=data)
-    return coerce_to_seqs_data_dict(labelled_seqs, label_to_name=label_to_name)
+    seq: new_sequence.Sequence, moltype: new_moltype.MolType, name: str
+) -> raw_seq_data:
+    seq = seq.to_moltype(moltype)
+    parent_name, start, _, step = seq.parent_coordinates()
+    # we always get the "plus strand" seq
+    # because reverse complementing is done via the Sequence instance
+    # and the orientation with respect to records in a bound db needs
+    # to be preserved (that annotation db is extracted elsewhere)
+    raw_seq = seq.to_array(apply_transforms=False)
+    return raw_seq_data(
+        seq=raw_seq,
+        name=name or seq.name,
+        parent_name=parent_name,
+        offset=start,
+        is_reversed=step < 0,
+    )
 
 
-@coerce_to_seqs_data_dict.register
-def _(
-    data: set, label_to_name: OptRenamerCallable = None
-) -> dict[str, StrORBytesORArray]:
-    first = next(iter(data))
-    labelled_seqs = assign_names(first, data=data)
-    return coerce_to_seqs_data_dict(labelled_seqs, label_to_name=label_to_name)
+@coerce_to_raw_seq_data.register
+def _(seq: str, moltype: new_moltype.MolType, name: OptStr = None) -> raw_seq_data:
+    return raw_seq_data(seq=seq, name=name)
 
 
-@coerce_to_seqs_data_dict.register
+@coerce_to_raw_seq_data.register
 def _(
-    data: SequenceCollection, label_to_name: OptRenamerCallable = None
-) -> dict[str, StrORBytesORArray]:
-    return coerce_to_seqs_data_dict(
-        data.to_dict(as_array=True), label_to_name=label_to_name
-    )
-
-
-@singledispatch
-def assign_names(
-    first, data: Union[list, set]
-) -> dict[str, Union[StrORBytesORArray, new_sequence.Sequence]]:
-    if isinstance(first, (str, bytes, numpy.ndarray)):
-        names = assign_sequential_names(len(data))
-        return dict(zip(names, data))
-    raise NotImplementedError(f"assign_names not implemented for {type(first)}")
+    seq: numpy.ndarray, moltype: new_moltype.MolType, name: OptStr = None
+) -> raw_seq_data:
+    return raw_seq_data(seq=seq, name=name)
 
 
-@assign_names.register
-def _(
-    first: new_sequence.Sequence, data: Union[list, set]
-) -> dict[str, new_sequence.Sequence]:
-    return {(seq.name or f"seq_{i}"): seq for i, seq in enumerate(data)}
+@coerce_to_raw_seq_data.register
+def _(seq: bytes, moltype: new_moltype.MolType, name: OptStr = None) -> raw_seq_data:
+    return raw_seq_data(seq=seq, name=name)
 
 
-@assign_names.register
-def _(first: list, data: Union[list, set]) -> dict[str, StrORBytesORArray]:
-    return {name_seq[0]: name_seq[1] for name_seq in data}
+CT = tuple[dict[str, StrORBytesORArray], dict[str, int], bool, dict[str, str]]
 
 
-@assign_names.register
-def _(first: tuple, data: Union[list, set]) -> dict[str, StrORBytesORArray]:
-    return {name_seq[0]: name_seq[1] for name_seq in data}
+@singledispatch
+def prep_for_seqs_data(data, moltype: new_moltype.MolType, seq_namer: _SeqNamer) -> CT:
+    # refactor: handle conversion of SeqView to SeqDataView.
+    raise NotImplementedError(
+        f"coerce_to_seqs_data_dict not implemented for {type(data)}"
+    )
 
 
-def _make_name_map(data: dict) -> tuple[OptDict, dict]:
-    if not isinstance(data, dict) or not isinstance(
-        next(iter(data.values()), None), new_sequence.Sequence
-    ):
-        return None, data
+@prep_for_seqs_data.register
+def _(data: dict, moltype: new_moltype.MolType, seq_namer: _SeqNamer) -> CT:
+    seqs = {}  # for the (Aligned)SeqsDataABC
+    offsets = {}  # for the (Aligned)SeqsDataABC
+    is_reversed = False
+    name_map = {}  # for the sequence collection
     # if we have a dict of Sequences, {name: seq, ...}, then the provided names
     # may differ to the name attribute on the Sequences. If the Sequences have
     # annotations, then we will need to map between the names in order to
     # query the annotation database. We do this by creating a name_map.
     # Note that we do (seq.name or name) to handle when the Sequence name is
     # None
-    name_map = {name: (seq.name or name) for name, seq in data.items()}
-    data = {(seq.name or name): numpy.array(seq) for name, seq in data.items()}
-    return name_map, data
+    for name, seq in data.items():
+        name = seq_namer(seq=seq, name=name)
+        seq_data = coerce_to_raw_seq_data(seq, moltype, name=name)
+        offsets[name] = seq_data.offset
+        seqs[seq_data.parent_name or seq_data.name] = seq_data.seq
+        is_reversed = seq_data.is_reversed
+        name_map[name] = seq_data.parent_name or name
+
+    return seqs, offsets, is_reversed, name_map
+
+
+@prep_for_seqs_data.register
+def _(data: list, moltype: new_moltype.MolType, seq_namer: _SeqNamer) -> CT:
+    if not isinstance(data[0], new_sequence.Sequence):
+        with contextlib.suppress(ValueError):
+            return prep_for_seqs_data(dict(data), moltype, seq_namer)
+
+    result = {seq_namer(seq=record): record for record in data}
+    return prep_for_seqs_data(result, moltype, seq_namer)
+
+
+@prep_for_seqs_data.register
+def _(data: tuple, moltype: new_moltype.MolType, seq_namer: _SeqNamer) -> CT:
+    return prep_for_seqs_data(list(data), moltype, seq_namer)
+
+
+@prep_for_seqs_data.register
+def _(data: set, moltype: new_moltype.MolType, seq_namer: _SeqNamer) -> CT:
+    return prep_for_seqs_data(list(data), moltype, seq_namer)
+
+
+@prep_for_seqs_data.register
+def _(
+    data: SequenceCollection, moltype: new_moltype.MolType, seq_namer: _SeqNamer
+) -> CT:
+    return prep_for_seqs_data({seq_namer(s): s for s in data.seqs}, moltype, seq_namer)
 
 
 @singledispatch
@@ -2387,11 +2446,11 @@ def make_unaligned_seqs(
     *,
     moltype: Union[str, new_moltype.MolType],
     label_to_name: OptRenamerCallable = None,
-    info: dict = None,
+    info: OptDict = None,
     source: OptPathType = None,
     annotation_db: SupportsFeatures = None,
-    offset: dict[str, int] = None,
-    name_map: dict[str, str] = None,
+    offset: typing.Optional[DictStrInt] = None,
+    name_map: typing.Optional[DictStrStr] = None,
 ) -> SequenceCollection:
     """Initialise an unaligned collection of sequences.
 
@@ -2410,14 +2469,18 @@ def make_unaligned_seqs(
         and added to info["source"].
     annotation_db
         annotation database to attach to the collection
+    offset
+        a dict mapping names to annotation offsets
+    name_map
+        a dict mapping sequence names to "parent" sequence names. The parent
+        name will be used for querying a annotation_db.
 
     Notes
     -----
-    - If no annotation_db is provided, but the sequences are annotated, an
+    If no annotation_db is provided, but the sequences are annotated, an
     annotation_db is created by merging any annotation db's found in the sequences.
-    - If the sequences are annotated AND an annotation_db is provided, only the
+    If the sequences are annotated AND an annotation_db is provided, only the
     annotation_db is used.
-
     """
     # refactor: design/simplify
     # the dispatches above handle the different type of data that the previous
@@ -2428,12 +2491,14 @@ def make_unaligned_seqs(
 
     # refactor: design
     # rename offset to offsets as it could track potentially multiple offsets
-    # refactor: add offset/s to docstring
 
     # refactor: design
     # define a source attribute rather than storing as .info["source"]
     # this will also replace the previous name attribute
 
+    moltype = new_moltype.get_moltype(moltype)
+    alphabet = moltype.most_degen_alphabet()
+
     if len(data) == 0:
         raise ValueError("data must be at least one sequence.")
 
@@ -2442,14 +2507,11 @@ def make_unaligned_seqs(
     # if we have Sequences, we need to construct the name map before we construct
     # the SeqsData object - however, if a name_map is provided, we assume that it
     # corrects for any naming differences in data and skip this step
-    name_map, data = _make_name_map(data) if name_map is None else (name_map, data)
-
-    seqs_data = coerce_to_seqs_data_dict(data, label_to_name=label_to_name)
-
-    moltype = new_moltype.get_moltype(moltype)
-    alphabet = moltype.most_degen_alphabet()
-
-    seqs_data = SeqsData(data=seqs_data, alphabet=alphabet, offset=offset)
+    assign_names = _SeqNamer(name_func=label_to_name)
+    seqs_data, offs, rvd, nm = prep_for_seqs_data(data, moltype, assign_names)
+    name_map = nm if name_map is None else name_map
+    # seqs_data keys should be the same as the value of name_map, not the keys
+    seqs_data = SeqsData(data=seqs_data, alphabet=alphabet, offset=offset, reversed=rvd)
     # we do not pass on offset/label_to_name as they are handled in this function
     return make_unaligned_seqs(
         seqs_data,
@@ -2517,8 +2579,8 @@ def _(
     *,
     alphabet: new_alphabet.AlphabetABC,
 ) -> tuple[numpy.ndarray, numpy.ndarray]:
-    gaps_bool = seq == alphabet.gap_index
-    ungapped = seq[~gaps_bool]
+    gap_indices: numpy.ndarray[bool] = seq == alphabet.gap_index
+    ungapped = seq[~gap_indices]
 
     # no gaps in seq
     if numpy.array_equal(ungapped, seq):
@@ -2528,7 +2590,7 @@ def _(
     in_gap = False
     parent_coords = []
     start = 0
-    for i, gapped in enumerate(gaps_bool):
+    for i, gapped in enumerate(gap_indices):
         if gapped and not in_gap:
             start = i
             in_gap = True
@@ -2750,19 +2812,39 @@ class AlignedSeqsDataABC(SeqsDataABC):
         alphabet: new_alphabet.AlphabetABC,
     ): ...
 
-    @classmethod
+    @property
     @abstractmethod
-    def from_names_and_array(
-        cls,
+    def align_len(self) -> int: ...
+
+    @abstractmethod
+    def get_seq_array(
+        self,
         *,
-        names: list[str],
-        data: numpy.ndarray,
-        alphabet: new_alphabet.AlphabetABC,
-    ): ...
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> numpy.ndarray: ...
 
-    @property
     @abstractmethod
-    def align_len(self) -> int: ...
+    def get_seq_str(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> str: ...
+
+    @abstractmethod
+    def get_seq_bytes(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> bytes: ...
 
     @abstractmethod
     def get_gapped_seq_array(
@@ -2879,7 +2961,7 @@ class AlignedSeqsData(AlignedSeqsDataABC):
             if seqs.keys() != gaps.keys():
                 raise ValueError("Keys in seqs and gaps must be identical.")
             if offset:
-                assert offset.keys().issubset(seqs.keys())
+                assert offset.keys() <= seqs.keys()
             gapped_seq_lengths = {
                 _gapped_seq_len(v, l) for v, l in zip(seqs.values(), gaps.values())
             }
@@ -2966,43 +3048,6 @@ class AlignedSeqsData(AlignedSeqsDataABC):
             **kwargs,
         )
 
-    @classmethod
-    def from_names_and_array(
-        cls,
-        *,
-        names: list[str],
-        data: numpy.ndarray,
-        alphabet: new_alphabet.AlphabetABC,
-    ):
-        """Construct an AlignedSeqsData object from a list of names and a numpy
-        array of aligned sequence data.
-
-        Parameters
-        ----------
-        names
-            list of sequence names
-        data
-            numpy array of aligned sequence data
-        alphabet
-            alphabet object for the sequences
-        """
-        if len(names) != data.shape[0]:
-            raise ValueError("Number of names must match number of rows in data.")
-
-        seqs = {}
-        gaps = {}
-        for name, seq in zip(names, data):
-            seq, gap_map = seq_to_gap_coords(seq, alphabet=alphabet)
-            seq.flags.writeable = False
-            gap_map.flags.writeable = False
-            seqs[name], gaps[name] = seq, gap_map
-
-        return cls(
-            seqs=seqs,
-            gaps=gaps,
-            alphabet=alphabet,
-        )
-
     @property
     def names(self) -> tuple[str]:
         return list(self._seqs.keys())
@@ -3607,10 +3652,11 @@ class _IndexableSeqs:
 class Alignment(SequenceCollection):
     def __init__(
         self,
+        seqs_data: AlignedSeqsDataABC,  # seqs_data
         slice_record: new_sequence.SliceRecord = None,
         **kwargs,
     ):
-        super().__init__(**kwargs)
+        super().__init__(seqs_data=seqs_data, **kwargs)
         self._slice_record = (
             slice_record
             if slice_record is not None
@@ -4381,38 +4427,24 @@ class Alignment(SequenceCollection):
             character (latter is default, as most evolutionary modelling treats
             gaps as N).
         """
-        if self.moltype.degen_alphabet is None:
-            raise new_moltype.MolTypeError(
-                f"Invalid MolType={self.moltype.label} (no degenerate characters), "
+        # refactor: array
+        try:
+            chars = list(self.moltype.alphabet)
+        except AttributeError as e:
+            msg = (
+                "Invalid MolType (no degenerate characters), "
                 "create the alignment using DNA, RNA or PROTEIN"
             )
+            raise ValueError(msg) from e
 
-        chars = len(self.moltype)
-
-        array_pos = self.array_positions
-        # by design, char alphabets are organised such that the canonical
-        # characters always occur first, followed by gap, then ambiguity
-        # characters. so we can define a cutoff as follows:
-        cutoff = chars + 1 if allow_gap else chars
-        indices = (array_pos < cutoff).all(axis=1)
+        if allow_gap:
+            chars.extend(self.moltype.gap)
 
-        if motif_length > 1:
-            num_motif = len(self) // motif_length
+        def predicate(column):
+            data = set("".join([str(d) for d in column]))
+            return data <= set(chars)
 
-            if remainder := len(self) % motif_length:
-                indices = indices[:-remainder]
-                array_pos = array_pos[:-remainder]
-
-            motif_valid = indices.reshape(num_motif, motif_length).all(axis=1).flatten()
-            indices = numpy.repeat(motif_valid, motif_length)
-        selected = array_pos[indices].T
-
-        aligned_seqs_data = self._seqs_data.from_names_and_array(
-            names=self.names, data=selected, alphabet=self.moltype.most_degen_alphabet()
-        )
-        return self.__class__(
-            seqs_data=aligned_seqs_data, info=self.info, moltype=self.moltype
-        )
+        return self.filtered(predicate, motif_length=motif_length)
 
     def omit_gap_pos(self, allowed_gap_frac: float = 1 - EPS, motif_length: int = 1):
         """Returns new alignment where all cols (motifs) have <= allowed_gap_frac gaps.
@@ -4726,7 +4758,9 @@ class Alignment(SequenceCollection):
             feature["strand"] = "-" if strand == -1 else "+"
             yield self.make_feature(feature=feature, on_alignment=on_al)
 
-    def get_drawables(self, *, biotype: Optional[str, Iterable[str]] = None) -> dict:
+    def get_drawables(
+        self, *, biotype: Optional[str, typing.Iterable[str]] = None
+    ) -> dict:
         """returns a dict of drawables, keyed by type
 
         Parameters
@@ -4743,7 +4777,7 @@ class Alignment(SequenceCollection):
     def get_drawable(
         self,
         *,
-        biotype: Optional[str, Iterable[str]] = None,
+        biotype: Optional[str, typing.Iterable[str]] = None,
         width: int = 600,
         vertical: int = False,
         title: OptStr = None,
@@ -5353,13 +5387,13 @@ class Alignment(SequenceCollection):
 def make_aligned_seqs(
     data: Union[dict[str, StrORBytesORArray], list, AlignedSeqsDataABC],
     *,
-    moltype: str,
+    moltype: typing.Union[str, new_moltype.MolType],
     label_to_name: OptRenamerCallable = None,
-    info: dict = None,
+    info: OptDict = None,
     source: OptPathType = None,
-    annotation_db: SupportsFeatures = None,
-    offset: dict[str, int] = None,
-    name_map: dict[str, str] = None,
+    annotation_db: typing.Optional[SupportsFeatures] = None,
+    offset: typing.Optional[DictStrInt] = None,
+    name_map: typing.Optional[DictStrStr] = None,
 ) -> Alignment:
     if len(data) == 0:
         raise ValueError("data must be at least one sequence.")
@@ -5370,12 +5404,12 @@ def make_aligned_seqs(
     # if we have Sequences, we need to construct the name map before we construct
     # the SeqsData object - however, if a name_map is provided, we assume that it
     # corrects for any naming differences in data and skip this step
-    name_map, data = _make_name_map(data) if name_map is None else (name_map, data)
-
-    data = coerce_to_seqs_data_dict(data, label_to_name=label_to_name)
-
+    assign_names = _SeqNamer(name_func=label_to_name)
+    seqs_data, offs, rvd, nm = prep_for_seqs_data(data, moltype, assign_names)
+    offset = offset or offs
+    name_map = name_map or nm
     seqs_data = AlignedSeqsData.from_seqs(
-        data=data,
+        data=seqs_data,
         alphabet=alphabet,
         offset=offset,
     )
@@ -5395,13 +5429,13 @@ def make_aligned_seqs(
 def _(
     data: AlignedSeqsDataABC,
     *,
-    moltype: str,
+    moltype: typing.Union[str, new_moltype.MolType],
     label_to_name: OptRenamerCallable = None,
-    info: dict = None,
+    info: OptDict = None,
     source: OptPathType = None,
-    annotation_db: SupportsFeatures = None,
-    offset: dict[str, int] = None,
-    name_map: dict[str, str] = None,
+    annotation_db: typing.Optional[SupportsFeatures] = None,
+    offset: typing.Optional[DictStrInt] = None,
+    name_map: typing.Optional[DictStrStr] = None,
 ) -> Alignment:
     moltype = new_moltype.get_moltype(moltype)
     if not moltype.is_compatible_alphabet(data.alphabet):
diff --git a/src/cogent3/core/new_alphabet.py b/src/cogent3/core/new_alphabet.py
index c55e5378b..10ab88348 100644
--- a/src/cogent3/core/new_alphabet.py
+++ b/src/cogent3/core/new_alphabet.py
@@ -207,7 +207,7 @@ class bytes_to_array:
         )
         self.dtype = dtype
 
-    def __call__(self, seq: bytes) -> numpy.ndarray:
+    def __call__(self, seq: bytes) -> numpy.ndarray[int]:
         b = self._converter(seq)
         return numpy.array(memoryview(b), dtype=self.dtype)
 
@@ -311,21 +311,21 @@ class CharAlphabet(tuple, AlphabetABC, MonomerAlphabetABC):
         return self.motif_len
 
     @functools.singledispatchmethod
-    def to_indices(self, seq: StrORBytesORArray) -> numpy.ndarray:
+    def to_indices(self, seq: StrORBytesORArray) -> numpy.ndarray[int]:
         raise TypeError(f"{type(seq)} is invalid")
 
     @to_indices.register
-    def _(self, seq: bytes) -> numpy.ndarray:
+    def _(self, seq: bytes) -> numpy.ndarray[int]:
         # any non-canonical characters should lie outside the range
         # we replace these with a single value
         return self._bytes2arr(seq)
 
     @to_indices.register
-    def _(self, seq: str) -> numpy.ndarray:
+    def _(self, seq: str) -> numpy.ndarray[int]:
         return self.to_indices(seq.encode("utf8"))
 
     @to_indices.register
-    def _(self, seq: numpy.ndarray) -> numpy.ndarray:
+    def _(self, seq: numpy.ndarray) -> numpy.ndarray[int]:
         return seq.astype(self.dtype)
 
     @functools.singledispatchmethod
@@ -905,11 +905,11 @@ class SenseCodonAlphabet(tuple, AlphabetABC):
             _alphabet_moltype_map[self] = monomers.moltype
 
     @property
-    def gap_char(self):
+    def gap_char(self) -> str:
         return self._gap_char
 
     @property
-    def gap_index(self):
+    def gap_index(self) -> OptInt:
         return self._to_indices[self.gap_char] if self._gap_char else None
 
     @property
diff --git a/src/cogent3/core/new_moltype.py b/src/cogent3/core/new_moltype.py
index 393029c5e..3051c74fb 100644
--- a/src/cogent3/core/new_moltype.py
+++ b/src/cogent3/core/new_moltype.py
@@ -140,9 +140,6 @@ RNA_EXTENDED_PAIRS = {
 }
 
 
-class MolTypeError(TypeError): ...
-
-
 def make_pairs(
     *,
     pairs: dict[tuple[str, str], bool] = None,
@@ -544,7 +541,12 @@ class MolType:
         return any(alpha == alphabet for alpha in self.iter_alphabets())
 
     def make_seq(
-        self, *, seq: str, name: OptStr = None, check_seq=True, **kwargs
+        self,
+        *,
+        seq: typing.Union[str, "SeqViewABC"],
+        name: OptStr = None,
+        check_seq: bool = True,
+        **kwargs,
     ) -> new_sequence.Sequence:
         """creates a Sequence object corresponding to the molecular type of
         this instance.
@@ -604,7 +606,7 @@ class MolType:
         return self._complement(seq)
 
     @complement.register
-    def _(self, seq: numpy.ndarray, validate: bool = True) -> str:
+    def _(self, seq: numpy.ndarray, validate: bool = True) -> numpy.ndarray[int]:
         if validate and not self.is_valid(seq):
             raise new_alphabet.AlphabetError(
                 f"{seq[:4]!r} not valid for moltype {self.name!r}"
@@ -637,7 +639,7 @@ class MolType:
                 f"{seq[:4]!r} not valid for moltype {self.name!r}"
             )
         return self.is_degenerate(
-            self.most_degen_alphabet().to_indices(seq), validate=False
+            self.degen_gapped_alphabet.to_indices(seq), validate=False
         )
 
     @is_degenerate.register
@@ -647,22 +649,23 @@ class MolType:
                 f"{seq[:4]!r} not valid for moltype {self.name!r}"
             )
         return self.is_degenerate(
-            self.most_degen_alphabet().to_indices(seq), validate=False
+            self.degen_gapped_alphabet.to_indices(seq), validate=False
         )
 
     @is_degenerate.register
     def _(self, seq: numpy.ndarray, validate: bool = True) -> bool:
-        if self.degen_alphabet is None:
-            return False
-
+        # what index is the first degenerate character
         if validate and not self.is_valid(seq):
             raise new_alphabet.AlphabetError(
                 f"{seq[:4]!r} not valid for moltype {self.name!r}"
             )
 
-        first_degen = self.most_degen_alphabet().gap_index + 1
-
-        return (seq >= first_degen).any()
+        for index, val in enumerate(self.degen_gapped_alphabet):
+            if val in self.ambiguities:
+                break
+        else:
+            return False
+        return (seq >= index).any()
 
     @functools.singledispatchmethod
     def is_gapped(self, seq, validate: bool = True) -> bool:
diff --git a/src/cogent3/core/new_sequence.py b/src/cogent3/core/new_sequence.py
index 6efa1be68..9b53a5329 100644
--- a/src/cogent3/core/new_sequence.py
+++ b/src/cogent3/core/new_sequence.py
@@ -24,7 +24,7 @@ import numpy
 from numpy import array, floating, integer, issubdtype
 
 from cogent3._version import __version__
-from cogent3.core import new_alphabet
+from cogent3.core import new_alphabet, new_moltype
 from cogent3.core.annotation import Feature
 from cogent3.core.annotation_db import (
     BasicAnnotationDb,
@@ -139,7 +139,7 @@ class Sequence:
     def __init__(
         self,
         moltype: "MolType",
-        seq: str,
+        seq: typing.Union[StrORBytesORArray, SeqViewABC],
         *,
         name: OptStr = None,
         info: typing.Optional[typing.Union[dict, InfoClass]] = None,
@@ -185,13 +185,37 @@ class Sequence:
                 result = self.moltype.complement(result)
         return result
 
-    def __array__(self, dtype=None, copy=None):
+    def __array__(self, dtype=None, copy=None) -> numpy.ndarray[int]:
         result = array(self._seq, dtype=dtype)
         if self._seq.is_reversed:
             with contextlib.suppress(TypeError):
                 result = self.moltype.complement(result)
         return result
 
+    def to_array(self, apply_transforms: bool = True) -> numpy.ndarray[int]:
+        """returns the numpy array
+
+        Parameters
+        ----------
+        apply_transforms
+            if True, applies any reverse complement operation
+
+        Notes
+        -----
+        Use this method with apply_transforms=False if you are
+        creating data for storage in a SeqData instance.
+        """
+        if apply_transforms:
+            return numpy.array(self)
+
+        arr = self._seq.array_value
+        if self._seq.is_reversed:
+            # the reversal will have been applied in the SeqView
+            # array_value method, so we undo that here.
+            arr = arr[::-1]
+
+        return arr
+
     def to_fasta(self, make_seqlabel=None, block_size=60) -> str:
         """Return string of self in FASTA format, no trailing newline
 
@@ -1146,7 +1170,7 @@ class Sequence:
             feature_data.pop(discard)
         return self.make_feature(feature_data)
 
-    def to_moltype(self, moltype: str):
+    def to_moltype(self, moltype: typing.Union[str, new_moltype.MolType]) -> "Sequence":
         """returns copy of self with moltype seq
 
         Parameters
@@ -1162,7 +1186,6 @@ class Sequence:
         When applied to a sequence in a SequenceCollection, the resulting
         sequence will no longer be part of the collection.
         """
-        from cogent3.core import new_moltype
 
         if not moltype:
             raise ValueError(f"unknown moltype '{moltype}'")
@@ -2574,7 +2597,7 @@ class SeqView(SeqViewABC):
     def __init__(
         self,
         *,
-        parent: str,
+        parent: typing.Union[str, "SeqsDataABC"],
         alphabet: new_alphabet.AlphabetABC,
         seqid: OptStr = None,
         parent_len: OptInt = None,


Test patch:
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
index 0ad729fc1..108fb2824 100644
--- a/tests/test_core/test_new_alignment.py
+++ b/tests/test_core/test_new_alignment.py
@@ -2773,7 +2773,7 @@ def test_aligned_seqs_data_unequal_seqlens_raises(data_type, dna_alphabet, dna_m
         )
 
 
-def test_from_seqs_and_gaps(dna_alphabet):
+def test_from_seqs_and_maps(dna_alphabet):
     # AlignedSeqsData should be able to be constructed from sequences and gap maps
     seqs = {"seq1": "ACCTA", "seq2": ""}
     gaps = {"seq1": numpy.array([[0, 1]]), "seq2": numpy.array([[0, 6]])}
@@ -2786,7 +2786,7 @@ def test_from_seqs_and_gaps(dna_alphabet):
     assert asd.get_gapped_seq_str(seqid="seq2") == "------"
 
 
-def test_from_seqs_and_gaps_diff_seq_lens_raises(dna_alphabet):
+def test_from_seqs_and_maps_diff_seq_lens_raises(dna_alphabet):
     seqs = {"seq1": "ACCTA", "seq2": "A"}
     gaps = {"seq1": numpy.array([[0, 1]]), "seq2": numpy.array([[0, 1]])}
     with pytest.raises(ValueError):
@@ -2795,7 +2795,7 @@ def test_from_seqs_and_gaps_diff_seq_lens_raises(dna_alphabet):
         )
 
 
-def test_from_seqs_and_gaps_diff_keys_raises(dna_alphabet):
+def test_from_seqs_and_maps_diff_keys_raises(dna_alphabet):
     seqs = {"seq1": "ACCTA", "seq2": "A"}
     gaps = {"seq1": numpy.array([[0, 1]]), "seq3": numpy.array([[0, 1]])}
     with pytest.raises(ValueError):
@@ -2804,39 +2804,6 @@ def test_from_seqs_and_gaps_diff_keys_raises(dna_alphabet):
         )
 
 
-@pytest.mark.parametrize("seqid, i", (("seq1", 0), ("seq2", 1), ("seq3", 2)))
-def test_from_names_and_array(dna_alphabet, seqid, i):
-    names = ["seq1", "seq2", "seq3"]
-    data = numpy.array([[0, 1, 2, 3], [3, 2, 1, 0], [4, 4, 4, 4]])
-    asd = new_alignment.AlignedSeqsData.from_names_and_array(
-        names=names, data=data, alphabet=dna_alphabet
-    )
-    assert asd.names == ["seq1", "seq2", "seq3"]
-    got = asd.get_gapped_seq_array(seqid=seqid)
-    expect = data[i]
-    assert numpy.array_equal(got, expect)
-
-
-def test_from_names_and_array_empty_raises(dna_alphabet):
-    names = []
-    data = numpy.array([]).reshape(0, 0)
-
-    with pytest.raises(ValueError):
-        _ = new_alignment.AlignedSeqsData.from_names_and_array(
-            names=names, data=data, alphabet=dna_alphabet
-        )
-
-
-def test_from_names_and_array_mismatched_length(dna_alphabet):
-    names = ["seq1", "seq2"]
-    data = numpy.array([[1, 0, 1], [0, 1, 0], [1, 1, 1]])
-
-    with pytest.raises(ValueError):
-        _ = new_alignment.AlignedSeqsData.from_names_and_array(
-            names=names, data=data, alphabet=dna_alphabet
-        )
-
-
 def test_aligned_seqs_data_diff_keys_raises(dna_alphabet):
     """AlignedSeqsData expect identical keys in seqs and gaps"""
     seqs = dict(seq1=numpy.array([2, 1]), seq2=numpy.array([2, 0, 3, 1]))
@@ -3396,29 +3363,6 @@ def test_alignment_to_html():
     assert got.find(ref_row) < got.find(other_row)
 
 
-def test_alignment_to_html_text_moltype():
-    """exercising producing html for text moltype"""
-    seqs = {"seq1": "ACG", "seq2": "-CT"}
-
-    aln = new_alignment.make_aligned_seqs(seqs, moltype="text")
-    got = aln.to_html(ref_name="longest")
-    ref_row = (
-        '<tr><td class="label">seq1</td>'
-        '<td><span class="A_text">A</span>'
-        '<span class="C_text">C</span>'
-        '<span class="G_text">G</span></td></tr>'
-    )
-    other_row = (
-        '<tr><td class="label">seq2</td>'
-        '<td><span class="ambig_text">-</span>'
-        '<span class="C_text">.</span>'
-        '<span class="T_text">T</span></td></tr>'
-    )
-
-    assert ref_row in got
-    assert other_row in got
-
-
 def test_alignment_repr():
     data = {
         "ENSMUSG00000056468": "GCCAGGGGGAAAA",
@@ -4130,27 +4074,6 @@ def test_no_degenerates():
     }
     assert result == expect
 
-    # for length non-divisible by motif_length
-    data = {
-        "s1": "AAA CCC GGG TTT T".replace(" ", ""),
-        "s2": "CCC GGG T-T AAA A".replace(" ", ""),
-        "s3": "GGR YTT AAA CCC C".replace(" ", ""),
-    }
-    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
-    result = aln.no_degenerates(motif_length=3, allow_gap=False).to_dict()
-    expect = {
-        "s1": "TTT".replace(" ", ""),
-        "s2": "AAA".replace(" ", ""),
-        "s3": "CCC".replace(" ", ""),
-    }
-
-
-def test_no_degenerates_bad_moltype_raises():
-    """no_degenerates should raise an error if moltype has no degenerate symbols"""
-    aln = new_alignment.make_aligned_seqs({"s1": "ACGT", "s2": "ACGT"}, moltype="text")
-    with pytest.raises(new_moltype.MolTypeError):
-        _ = aln.no_degenerates()
-
 
 def test_omit_gap_pos_motif_length():
     """consistency with different motif_length values"""
diff --git a/tests/test_core/test_new_moltype.py b/tests/test_core/test_new_moltype.py
index 06d7f7ad8..b311525e8 100644
--- a/tests/test_core/test_new_moltype.py
+++ b/tests/test_core/test_new_moltype.py
@@ -143,20 +143,6 @@ def test_is_degenerate_invalid():
         new_moltype.RNA.is_degenerate(list("GAG"))
 
 
-@pytest.mark.parametrize("data_type", (str, bytes, numpy.ndarray))
-@pytest.mark.parametrize(
-    "seq",
-    (
-        "",
-        "QWERTYUIOPASDFGHJKLZXCVBNM",
-    ),
-)
-def test_text_moltype_is_not_degenerate(seq, data_type):
-    """Text moltype should not be degenerate"""
-    seq = make_typed(seq, data_type, new_moltype.ASCII)
-    assert not new_moltype.ASCII.is_degenerate(seq)
-
-
 @pytest.mark.parametrize("data_type", (str, bytes, numpy.ndarray))
 @pytest.mark.parametrize(
     "seq",
diff --git a/tests/test_core/test_new_sequence.py b/tests/test_core/test_new_sequence.py
index c8ec41f62..92d4f462d 100644
--- a/tests/test_core/test_new_sequence.py
+++ b/tests/test_core/test_new_sequence.py
@@ -2605,6 +2605,23 @@ def test_sequence_str_bytes_array():
     )
 
 
+def test_sequence_to_array_method():
+    data = "ACGGTGGGAC"
+    the_slice = slice(2, 6)
+    dna = new_moltype.DNA
+    expect_plus = dna.alphabet.to_indices(data[the_slice])
+    expect_minus = dna.alphabet.to_indices(dna.rc(data[the_slice]))
+    seq = new_moltype.DNA.make_seq(seq=data)[the_slice]
+    # no transforms applied, so both options give same plus strand array
+    assert numpy.array_equal(seq.to_array(apply_transforms=True), expect_plus)
+    assert numpy.array_equal(seq.to_array(apply_transforms=False), expect_plus)
+    rc = seq.rc()
+    # transform applied, equals minus strand
+    assert numpy.array_equal(rc.to_array(apply_transforms=True), expect_minus)
+    # transform applied, not doing it returns the plus strand
+    assert numpy.array_equal(rc.to_array(apply_transforms=False), expect_plus)
+
+
 @pytest.mark.parametrize("seq,rc", (("ATGTTT", False), ("AAACAT", True)))
 def test_translation(seq, rc):
     seq = new_moltype.DNA.make_seq(seq=seq)