Repository: cogent3/cogent3
Pull request #1896 (2024-06-12 12:00:51)

Seq collections refactor

coveralls: ## Pull Request Test Coverage Report for [Build 9482155490](https://coveralls.io/builds/68040111)


### Details

* **178** of **235**   **(75.74%)**  changed or added relevant lines in **3** files are covered.
* **5** unchanged lines in **2** files lost coverage.
* Overall coverage increased (+**0.2%**) to **90.812%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [src/cogent3/core/new_moltype.py](https://coveralls.io/builds/68040111/source?filename=src%2Fcogent3%2Fcore%2Fnew_moltype.py#L563) | 47 | 54 | 87.04%
| [src/cogent3/core/new_alignment.py](https://coveralls.io/builds/68040111/source?filename=src%2Fcogent3%2Fcore%2Fnew_alignment.py#L601) | 120 | 170 | 70.59%
<!-- | **Total:** | **178** | **235** | **75.74%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [src/cogent3/core/new_moltype.py](https://coveralls.io/builds/68040111/source?filename=src%2Fcogent3%2Fcore%2Fnew_moltype.py#L534) | 1 | 94.26% |
| [src/cogent3/core/new_sequence.py](https://coveralls.io/builds/68040111/source?filename=src%2Fcogent3%2Fcore%2Fnew_sequence.py#L355) | 4 | 55.46% |
<!-- | **Total:** | **5** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/68040111/badge)](https://coveralls.io/builds/68040111) |
| :-- | --: |
| Change from base [Build 9459174206](https://coveralls.io/builds/68006031): |  0.2% |
| Covered Lines: | 32180 |
| Relevant Lines: | 35436 |

---
##### 💛  - [Coveralls](https://coveralls.io)

coveralls: ## Pull Request Test Coverage Report for [Build 9492006494](https://coveralls.io/builds/68056626)


### Details

* **179** of **236**   **(75.85%)**  changed or added relevant lines in **3** files are covered.
* **6** unchanged lines in **3** files lost coverage.
* Overall coverage increased (+**0.2%**) to **90.809%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [src/cogent3/core/new_moltype.py](https://coveralls.io/builds/68056626/source?filename=src%2Fcogent3%2Fcore%2Fnew_moltype.py#L562) | 47 | 54 | 87.04%
| [src/cogent3/core/new_alignment.py](https://coveralls.io/builds/68056626/source?filename=src%2Fcogent3%2Fcore%2Fnew_alignment.py#L601) | 121 | 171 | 70.76%
<!-- | **Total:** | **179** | **236** | **75.85%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [src/cogent3/maths/scipy_optimize.py](https://coveralls.io/builds/68056626/source?filename=src%2Fcogent3%2Fmaths%2Fscipy_optimize.py#L517) | 1 | 85.57% |
| [src/cogent3/core/new_moltype.py](https://coveralls.io/builds/68056626/source?filename=src%2Fcogent3%2Fcore%2Fnew_moltype.py#L534) | 1 | 94.26% |
| [src/cogent3/core/new_sequence.py](https://coveralls.io/builds/68056626/source?filename=src%2Fcogent3%2Fcore%2Fnew_sequence.py#L355) | 4 | 55.46% |
<!-- | **Total:** | **6** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/68056626/badge)](https://coveralls.io/builds/68056626) |
| :-- | --: |
| Change from base [Build 9459174206](https://coveralls.io/builds/68006031): |  0.2% |
| Covered Lines: | 32179 |
| Relevant Lines: | 35436 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/src/cogent3/app/dist.py b/src/cogent3/app/dist.py
index 3d6937033..d9e12fcb3 100644
--- a/src/cogent3/app/dist.py
+++ b/src/cogent3/app/dist.py
@@ -181,8 +181,8 @@ def jaccard_dist(seq_coll: UnalignedSeqsType, k: int = 10) -> PairwiseDistanceTy
     """
 
     kmers = {
-        name: set(seq.iter_kmers(k, strict=True))
-        for name, seq in seq_coll.named_seqs.items()
+        name: set(seq_coll.named_seqs[name].iter_kmers(k, strict=True))
+        for name in seq_coll.names
     }
     seq_names = sorted(kmers.keys())
     num_seqs = len(seq_names)
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index 5a06a94b4..358f36df9 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -1012,7 +1012,7 @@ class _SequenceCollectionBase:
 
         return combined_aln
 
-    def write(self, filename=None, format=None, **kwargs):
+    def write(self, filename=None, format=None, **kwargs):  # ported
         """Write the alignment to a file, preserving order of sequences.
 
         Parameters
@@ -1403,7 +1403,7 @@ class _SequenceCollectionBase:
 
         return self.take_seqs_if(gaps_ok)
 
-    def omit_gap_runs(self, allowed_run=1):
+    def omit_gap_runs(self, allowed_run=1):  # will not port
         """Returns new alignment where all seqs have runs of gaps <=allowed_run.
 
         Note that seqs with exactly allowed_run gaps are not deleted.
@@ -1437,19 +1437,19 @@ class _SequenceCollectionBase:
             result.annotation_db = self.annotation_db
         return result
 
-    def to_dna(self):
+    def to_dna(self):  # ported
         """returns copy of self as an alignment of DNA moltype seqs"""
         return self.to_moltype("dna")
 
-    def to_rna(self):
+    def to_rna(self):  # ported
         """returns copy of self as an alignment of RNA moltype seqs"""
         return self.to_moltype("rna")
 
-    def to_protein(self):
+    def to_protein(self):  # ported
         """returns copy of self as an alignment of PROTEIN moltype seqs"""
         return self.to_moltype("protein")
 
-    def rc(self):
+    def rc(self):  # ported
         """Returns the reverse complement alignment"""
         seqs = [self.named_seqs[name].rc() for name in self.names]
         rc = self.__class__(
@@ -1462,11 +1462,11 @@ class _SequenceCollectionBase:
         rc.annotation_db = self.annotation_db
         return rc
 
-    def reverse_complement(self):
+    def reverse_complement(self):  # ported
         """Returns the reverse complement alignment. A synonym for rc."""
         return self.rc()
 
-    def pad_seqs(self, pad_length=None, **kwargs):
+    def pad_seqs(self, pad_length=None, **kwargs):  # ported
         """Returns copy in which sequences are padded to same length.
 
         Parameters
@@ -1505,7 +1505,7 @@ class _SequenceCollectionBase:
         # return new SequenceCollection object
         return SequenceCollection(moltype=self.moltype, data=new_seqs, **kwargs)
 
-    def strand_symmetry(self, motif_length=1):
+    def strand_symmetry(self, motif_length=1):  # ported
         """returns dict of strand symmetry test results per seq"""
         return {s.name: s.strand_symmetry(motif_length=motif_length) for s in self.seqs}
 
@@ -1521,7 +1521,7 @@ class _SequenceCollectionBase:
         title: Optional[str] = None,
         rc: bool = False,
         show_progress: bool = False,
-    ):
+    ):  # ported
         """make a dotplot between specified sequences. Random sequences
         chosen if names not provided.
 
@@ -1624,7 +1624,7 @@ class _SequenceCollectionBase:
             )
         return dotplot
 
-    def rename_seqs(self, renamer):
+    def rename_seqs(self, renamer):  # ported
         """returns new instance with sequences renamed
 
         Parameters
@@ -1651,7 +1651,7 @@ class _SequenceCollectionBase:
     @UI.display_wrap
     def apply_pssm(
         self, pssm=None, path=None, background=None, pseudocount=0, names=None, ui=None
-    ):
+    ):  # ported
         """scores sequences using the specified pssm
 
         Parameters
@@ -2006,7 +2006,7 @@ class SequenceCollection(_SequenceCollectionBase):
                 feature["spans"] = (array(feature["spans"]) - offset).tolist()
             yield seq.make_feature(feature, self)
 
-    def distance_matrix(self, calc="pdist"):
+    def distance_matrix(self, calc="pdist"):  # ported
         """Estimated pairwise distance between sequences
 
         Parameters
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index d6bf15096..7bc505b5d 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -2,13 +2,14 @@ from __future__ import annotations
 
 import re
 import typing
+import warnings
 
 from abc import ABC, abstractmethod
 from collections import defaultdict
 from dataclasses import InitVar, dataclass, field
 from functools import singledispatch, singledispatchmethod
 from pathlib import Path
-from typing import Callable, Iterator, Mapping, Optional, Union
+from typing import Any, Callable, Iterator, Mapping, Optional, Union
 
 import numpy
 
@@ -24,9 +25,13 @@ from cogent3.core.annotation_db import (
 )
 from cogent3.core.info import Info as InfoClass
 from cogent3.core.location import IndelMap
+from cogent3.core.profile import PSSM, MotifCountsArray, MotifFreqsArray
+from cogent3.format.alignment import save_to_filename
 from cogent3.format.fasta import alignment_to_fasta
 from cogent3.format.phylip import alignment_to_phylip
+from cogent3.util import progress_display as UI
 from cogent3.util import warning as c3warn
+from cogent3.util.io import atomic_write, get_format_suffixes
 from cogent3.util.misc import get_setting_from_environ, negate_condition
 
 
@@ -143,7 +148,7 @@ class SeqDataView(new_seq.SliceRecordABC):
             f"seqid={self.seqid!r}, seq_len={self.seq_len})"
         )
 
-    # todo: do we support copy? do we support copy with sliced?
+    # refactor: design, do we support copy? do we support copy with sliced?
     def copy(self, sliced: bool = False):
         """returns copy"""
         return self
@@ -226,10 +231,11 @@ class SeqsData(SeqsDataABC):
     ):
         self._alphabet = alphabet
         self._make_seq = make_seq
-        # todo: kath, make underlying data unchangable with `arrl.flags.writeable = False`
-        self._data: dict[str, numpy.ndarray] = {
-            str(name): self._alphabet.to_indices(seq) for name, seq in data.items()
-        }
+        self._data: dict[str, numpy.ndarray] = {}
+        for name, seq in data.items():
+            arr = self._alphabet.to_indices(seq)
+            arr.flags.writeable = False
+            self._data[str(name)] = arr
 
     @property
     def names(self) -> list:
@@ -290,8 +296,16 @@ class SeqsData(SeqsDataABC):
     def add_seqs(
         self, seqs: dict[str, PrimitiveSeqTypes], force_unique_keys=True
     ) -> SeqsData:
-        # todo: kath
-        ...
+        """Returns a new SeqsData object with added sequences."""
+        if force_unique_keys and any(name in self.names for name in seqs):
+            raise ValueError("One or more sequence names already exist in collection")
+        new_data = {
+            **self._data,
+            **{name: self.alphabet.to_indices(seq) for name, seq in seqs.items()},
+        }
+        return self.__class__(
+            data=new_data, alphabet=self.alphabet, make_seq=self.make_seq
+        )
 
     def to_alphabet(
         self, alphabet: new_alpha.CharAlphabet, check_valid=True
@@ -350,10 +364,11 @@ class SequenceCollection:
     def __init__(
         self,
         *,
-        seqs_data: SeqsData,
+        seqs_data: SeqsDataABC,
         moltype: new_moltype.MolType,
         names: list[str] = None,
         info: Union[dict, InfoClass] = None,
+        source: str = None,  # refactor: need to deal with optional args type hints
         annotation_db: SupportsFeatures = None,
     ):
         self._seqs_data = seqs_data
@@ -363,6 +378,7 @@ class SequenceCollection:
         if not isinstance(info, InfoClass):
             info = InfoClass(info) if info else InfoClass()
         self.info = info
+        self.source = source
         self._repr_policy = dict(num_seqs=10, num_pos=60, ref_name="longest", wrap=60)
         self._annotation_db = annotation_db or DEFAULT_ANNOTATION_DB()
 
@@ -452,7 +468,8 @@ class SequenceCollection:
         The seqs in the new collection will be references to the same objects as
         the seqs in the old collection.
         """
-        # todo: kath, note gavins comment that this method could operate on only the names
+        # refactor: design
+        # consider gavins comment that this method could operate on only the names
         # list and not the seqs_data object
 
         if isinstance(names, str):
@@ -511,10 +528,9 @@ class SequenceCollection:
         The seqs in the new collection are the same objects as the
         seqs in the old collection, not copies.
         """
-        # pass take_seqs the result of get_seq_indices
         return self.take_seqs(self.get_seq_names_if(f, negate), **kwargs)
 
-    def get_seq(self, seqname: str, copy_annotations: bool = False):
+    def get_seq(self, seqname: str, copy_annotations: bool = False) -> new_seq.Sequence:
         """Return a sequence object for the specified seqname.
 
         Parameters
@@ -540,9 +556,24 @@ class SequenceCollection:
 
         return seq
 
-    def add_seqs(self, seqs: dict[str, PrimitiveSeqTypes], **kwargs):
-        # todo: kath, add this method once its functional on SeqsData
-        ...
+    def add_seqs(
+        self, seqs: Union[dict[str, PrimitiveSeqTypes], SeqsData, list], **kwargs
+    ) -> SequenceCollection:
+        """Returns new collection with additional sequences.
+
+        Parameters
+        ----------
+        seqs
+            sequences to add
+        """
+        data = coerce_to_seqs_data_dict(seqs, label_to_name=None)
+        seqs_data = self.seqs.add_seqs(data, **kwargs)
+        return self.__class__(
+            seqs_data=seqs_data,
+            moltype=self.moltype,
+            info=self.info,
+            annotation_db=self.annotation_db,
+        )
 
     def to_dict(self, as_array: bool = False) -> dict[str, Union[str, numpy.ndarray]]:
         """Return a dictionary of sequences.
@@ -556,17 +587,27 @@ class SequenceCollection:
         get = self.seqs.get_seq_array if as_array else self.seqs.get_seq_str
         return {name: get(seqid=name) for name in self.names}
 
-    def degap(self, **kwargs):
-        """Returns copy in which sequences have no gaps.
+    def degap(self) -> SequenceCollection:
+        """Returns new collection in which sequences have no gaps.
 
-        Parameters
-        ----------
-        kwargs
-            passed to class constructor
+        Notes
+        -----
+        The returned collection will not retain an annotation_db if present.
         """
         # refactor: array - chars > gap char
-        seqs = {name: self.seqs[name].degap() for name in self.names}
-        return make_unaligned_seqs(seqs, moltype=self.moltype, info=self.info, **kwargs)
+        seqs = {
+            name: self.moltype.degap(self.seqs.get_seq_array(seqid=name))
+            for name in self.names
+        }
+        seqs_data = self.seqs.__class__(
+            data=seqs, alphabet=self.seqs.alphabet, make_seq=self.seqs.make_seq
+        )
+        return self.__class__(
+            seqs_data=seqs_data,
+            moltype=self.moltype,
+            info=self.info,
+            source=self.source,
+        )
 
     def to_moltype(self, moltype: str) -> SequenceCollection:
         """returns copy of self with changed moltype
@@ -604,24 +645,50 @@ class SequenceCollection:
             annotation_db=self.annotation_db,
         )
 
-    def get_lengths(
-        self, include_ambiguity: bool = False, allow_gap: bool = False
-    ) -> dict[str, int]:
-        """returns sequence lengths as a dict of {seqid: length}
+    def distance_matrix(self, calc: str = "pdist"):
+        """Estimated pairwise distance between sequences
 
         Parameters
         ----------
-        include_ambiguity
-            if True, motifs containing ambiguous characters
-            from the seq moltype are included. No expansion of those is attempted.
-        allow_gap
-            if True, motifs containing a gap character are included.
+        calc
+            The distance calculation method to use, either "pdist" or "jc69".
+            - "pdist" is an approximation of the proportion sites different.
+            - "jc69" is an approximation of the Jukes Cantor distance.
 
+        Returns
+        -------
+        DistanceMatrix
+            Estimated pairwise distances between sequences in the collection
+
+        Notes
+        -----
+        pdist approximates the proportion sites different from the Jaccard
+        distance. Coefficients for the approximation were derived from a
+        polynomial fit between Jaccard distance of kmers with k=10 and the
+        proportion of sites different using mammalian 106 protein coding
+        gene DNA sequence alignments.
+
+        jc69 approximates the Jukes Cantor distance using the approximated
+        proportion sites different, i.e., a transformation of the above.
         """
-        counts = self.counts_per_seq(
-            motif_length=1, include_ambiguity=include_ambiguity, allow_gap=allow_gap
+        from cogent3.app.dist import get_approx_dist_calc
+
+        # check moltype
+        if len(self.moltype.alphabet) != 4:
+            raise NotImplementedError("only defined for DNA/RNA molecular types")
+
+        # assert we have more than one sequence in the SequenceCollection
+        if self.num_seqs == 1:
+            raise ValueError(
+                "Pairwise distance cannot be computed for a single sequence. "
+                "Please provide at least two sequences."
+            )
+
+        dist_calc_app = get_approx_dist_calc(
+            dist=calc, num_states=len(self.moltype.alphabet)
         )
-        return counts.row_sum()
+
+        return dist_calc_app(self)
 
     def copy_annotations(self, seq_db: SupportsFeatures) -> None:
         """copy annotations into attached annotation db
@@ -805,6 +872,41 @@ class SequenceCollection:
 
         return alignment_to_phylip(self.to_dict())
 
+    def write(self, filename: str = None, file_format: str = None, **kwargs):
+        """Write the sequences to a file, preserving order of sequences.
+
+        Parameters
+        ----------
+        filename
+            name of the sequence file
+        file_format
+            format of the sequence file
+
+        Notes
+        -----
+
+        If file_format is None, will attempt to infer format from the filename
+        suffix.
+        """
+
+        # todo: kath, add support for json
+        if filename is None:
+            raise IOError("no filename specified")
+
+        suffix, _ = get_format_suffixes(filename)
+        if file_format is None and suffix:
+            file_format = suffix
+
+        if file_format == "json":
+            with atomic_write(filename, mode="wt") as f:
+                f.write(self.to_json())
+            return
+
+        if "order" not in kwargs:
+            kwargs["order"] = self.names
+
+        save_to_filename(self.to_dict(), filename, file_format, **kwargs)
+
     def dotplot(
         self,
         name1: Optional[str] = None,
@@ -912,6 +1014,383 @@ class SequenceCollection:
             )
         return dotplot
 
+    @UI.display_wrap
+    def apply_pssm(
+        self,
+        pssm: PSSM = None,
+        path: str = None,
+        background: numpy.array = None,
+        pseudocount: int = 0,
+        names: list = None,
+        ui=None,
+    ) -> numpy.array:
+        """scores sequences using the specified pssm
+
+        Parameters
+        ----------
+        pssm :
+            A profile.PSSM instance, if not provided, will be loaded from path
+        path
+            path to either a jaspar or cisbp matrix (path must end have a suffix
+            matching the format).
+        background
+            background frequencies distribution
+        pseudocount
+            adjustment for zero in matrix
+        names
+            returns only scores for these sequences and in the name order
+        ui
+            argument to control the display of a progress bar. i.e.,
+            'show_progress=True'
+
+
+        Returns
+        -------
+        numpy array of log2 based scores at every position
+        """
+        assert not self.is_ragged(), "all sequences must have same length"
+        from cogent3.parse import cisbp, jaspar
+
+        assert pssm or path, "Must specify a PSSM or a path"
+        assert not (pssm and path), "Can only specify one of pssm, path"
+
+        if isinstance(names, str):
+            names = [names]
+
+        # refactor: design - delegate below logic to seperate function load_pssm()
+        if path:
+            is_cisbp = path.endswith("cisbp")
+            is_jaspar = path.endswith("jaspar")
+            if not (is_cisbp or is_jaspar):
+                raise NotImplementedError(f"Unknown format {path.split('.')[-1]}")
+
+            if is_cisbp:
+                pfp = cisbp.read(path)
+                pssm = pfp.to_pssm(background=background)
+            else:
+                _, pwm = jaspar.read(path)
+                pssm = pwm.to_pssm(background=background, pseudocount=pseudocount)
+
+        assert isinstance(pssm, PSSM)
+        array_align = hasattr(self, "array_seqs")
+        assert set(pssm.motifs) == set(self.moltype)
+        if array_align and list(pssm.motifs) == list(self.moltype):
+            if names:
+                name_indices = [self.names.index(n) for n in names]
+                data = self.array_seqs.take(name_indices, axis=0)
+            else:
+                data = self.array_seqs
+
+            result = [pssm.score_indexed_seq(seq) for seq in ui.series(data)]
+        else:
+            seqs = [self.seqs[n] for n in names] if names else self.seqs
+            result = [pssm.score_seq(seq) for seq in ui.series(seqs)]
+
+        return numpy.array(result)
+
+    def get_ambiguous_positions(self):
+        """Returns dict of seq:{position:char} for ambiguous chars.
+
+        Used in likelihood calculations.
+        """
+        # refactor: performance
+        result = {}
+        for name in self.names:
+            result[name] = ambig = {}
+            for i, motif in enumerate(self.seqs[name]):
+                if self.moltype.is_ambiguity(motif):
+                    ambig[i] = motif
+        return result
+
+    def trim_stop_codons(self, gc: Any = 1, strict: bool = False):
+        """Removes any terminal stop codons from the sequences
+
+        Parameters
+        ----------
+        gc
+            valid input to cogent3.get_code(), a genetic code object, number
+            or name, defaults to standard code
+        strict
+            If True, raises an exception if a seq length not divisible by 3
+        """
+        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}
+        result = make_unaligned_seqs(new_seqs, moltype=self.moltype, info=self.info)
+        if self.annotation_db:
+            result.annotation_db = self.annotation_db
+        return result
+
+    def counts_per_seq(
+        self,
+        motif_length: int = 1,
+        include_ambiguity: bool = False,
+        allow_gap: bool = False,
+        exclude_unobserved: bool = False,
+        warn: bool = False,
+    ) -> MotifCountsArray:  # refactor: using array
+        """counts of motifs per sequence
+
+        Parameters
+        ----------
+        motif_length
+            number of characters per tuple.
+        include_ambiguity
+            if True, motifs containing ambiguous characters from the seq moltype
+            are included. No expansion of those is attempted.
+        allow_gap
+            if True, motifs containing a gap character are included.
+        warn
+            warns if motif_length > 1 and collection trimmed to produce motif
+            columns.
+
+        Notes
+        -----
+
+        only non-overlapping motifs are counted
+        """
+        counts = []
+        motifs = set()
+        for name in self.names:
+            seq = self.get_seq(name)
+            c = seq.counts(
+                motif_length=motif_length,
+                include_ambiguity=include_ambiguity,
+                allow_gap=allow_gap,
+                exclude_unobserved=exclude_unobserved,
+                warn=warn,
+            )
+            motifs.update(c.keys())
+            counts.append(c)
+        motifs = list(sorted(motifs))
+        for i, c in enumerate(counts):
+            counts[i] = c.tolist(motifs)
+        return MotifCountsArray(counts, motifs, row_indices=self.names)
+
+    def counts(
+        self,
+        motif_length: int = 1,
+        include_ambiguity: bool = False,
+        allow_gap: bool = False,
+        exclude_unobserved: bool = False,
+    ) -> MotifCountsArray:
+        """counts of motifs
+
+        Parameters
+        ----------
+        motif_length
+            number of elements per character.
+        include_ambiguity
+            if True, motifs containing ambiguous characters from the seq moltype
+            are included. No expansion of those is attempted.
+        allow_gap
+            if True, motifs containing a gap character are included.
+        exclude_unobserved
+            if True, unobserved motif combinations are excluded.
+
+        Notes
+        -----
+
+        only non-overlapping motifs are counted
+        """
+        per_seq = self.counts_per_seq(
+            motif_length=motif_length,
+            include_ambiguity=include_ambiguity,
+            allow_gap=allow_gap,
+            exclude_unobserved=exclude_unobserved,
+        )
+        return per_seq.motif_totals()
+
+    def get_motif_probs(
+        self,
+        alphabet: new_alpha.CharAlphabet = None,
+        include_ambiguity: bool = False,
+        exclude_unobserved: bool = False,
+        allow_gap: bool = False,
+        pseudocount: int = 0,
+    ) -> dict:  # refactor: using array
+        """Return a dictionary of motif probs, calculated as the averaged
+        frequency across sequences.
+
+        Parameters
+        ----------
+        include_ambiguity
+            if True resolved ambiguous codes are included in estimation of
+            frequencies, default is False.
+        exclude_unobserved
+            if True, motifs that are not present in the alignment are excluded
+            from the returned dictionary,
+            default is False.
+        allow_gap
+            allow gap motif
+
+        Notes
+        -----
+
+        only non-overlapping motifs are counted
+        """
+        moltype = self.moltype
+        if alphabet is None:
+            alphabet = moltype.alphabet
+            if allow_gap:
+                alphabet = alphabet.gapped
+
+        counts = {}
+        for seq_name in self.names:
+            sequence = self.seqs[seq_name]
+            motif_len = alphabet.get_motif_len()
+            if motif_len > 1:
+                posns = list(range(0, len(sequence) + 1 - motif_len, motif_len))
+                sequence = [sequence[i : i + motif_len] for i in posns]
+            for motif in sequence:
+                if not allow_gap and self.moltype.gap in motif:
+                    continue
+
+                if motif in counts:
+                    counts[motif] += 1
+                else:
+                    counts[motif] = 1
+
+        probs = {}
+        if not exclude_unobserved:
+            for motif in alphabet:
+                probs[motif] = pseudocount
+
+        for motif, count in list(counts.items()):
+            motif_set = moltype.resolve_ambiguity(motif, alphabet=alphabet)
+            if len(motif_set) > 1:
+                if include_ambiguity:
+                    count = float(count) / len(motif_set)
+                else:
+                    continue
+            for motif in motif_set:
+                probs[motif] = probs.get(motif, pseudocount) + count
+
+        total = float(sum(probs.values()))
+        for motif in probs:
+            probs[motif] /= total
+
+        return probs
+
+    def probs_per_seq(
+        self,
+        motif_length: int = 1,
+        include_ambiguity: bool = False,
+        allow_gap: bool = False,
+        exclude_unobserved: bool = False,
+        warn: bool = False,
+    ) -> MotifFreqsArray:
+        """return MotifFreqsArray per sequence"""
+
+        counts = self.counts_per_seq(
+            motif_length=motif_length,
+            include_ambiguity=include_ambiguity,
+            allow_gap=allow_gap,
+            exclude_unobserved=exclude_unobserved,
+            warn=warn,
+        )
+        return None if counts is None else counts.to_freq_array()
+
+    def entropy_per_seq(
+        self,
+        motif_length: int = 1,
+        include_ambiguity: bool = False,
+        allow_gap: bool = False,
+        exclude_unobserved: bool = True,
+        warn: bool = False,
+    ) -> numpy.ndarray:
+        """Returns the Shannon entropy per sequence.
+
+        Parameters
+        ----------
+        motif_length: int
+            number of characters per tuple.
+        include_ambiguity: bool
+            if True, motifs containing ambiguous characters
+            from the seq moltype are included. No expansion of those is attempted.
+        allow_gap: bool
+            if True, motifs containing a gap character are included.
+        exclude_unobserved: bool
+            if True, unobserved motif combinations are excluded.
+        warn
+            warns if motif_length > 1 and alignment trimmed to produce
+            motif columns
+
+        Notes
+        -----
+        For motif_length > 1, it's advisable to specify exclude_unobserved=True,
+        this avoids unnecessary calculations.
+        """
+        probs = self.probs_per_seq(
+            motif_length=motif_length,
+            include_ambiguity=include_ambiguity,
+            allow_gap=allow_gap,
+            exclude_unobserved=exclude_unobserved,
+            warn=warn,
+        )
+
+        return None if probs is None else probs.entropy()
+
+    def get_lengths(
+        self, include_ambiguity: bool = False, allow_gap: bool = False
+    ) -> dict[str, int]:
+        """returns sequence lengths as a dict of {seqid: length}
+
+        Parameters
+        ----------
+        include_ambiguity
+            if True, motifs containing ambiguous characters
+            from the seq moltype are included. No expansion of those is attempted.
+        allow_gap
+            if True, motifs containing a gap character are included.
+
+        """
+        counts = self.counts_per_seq(
+            motif_length=1, include_ambiguity=include_ambiguity, allow_gap=allow_gap
+        )
+        return counts.row_sum()
+
+    def pad_seqs(self, pad_length: int = None):
+        """Returns copy in which sequences are padded to same length.
+
+        Parameters
+        ----------
+        pad_length
+            Length all sequences are to be padded to. Will pad to max sequence
+            length if pad_length is None or less than max length.
+        """
+
+        max_len = max(self.seqs.seq_lengths().values())
+
+        if pad_length is None:
+            pad_length = max_len
+        elif pad_length < max_len:
+            raise ValueError(
+                f"pad_length must be at greater or equal to maximum sequence length: {str(max_len)}"
+            )
+
+        new_seqs = {}
+        for seq_name in self.names:
+            seq = self.seqs.get_seq_str(seqid=seq_name)
+            padded_seq = seq + "-" * (pad_length - len(seq))
+            new_seqs[seq_name] = padded_seq
+
+        seqs_data = self.seqs.__class__(
+            data=new_seqs, alphabet=self.seqs.alphabet, make_seq=self.seqs.make_seq
+        )
+        return self.__class__(
+            seqs_data=seqs_data,
+            moltype=self.moltype,
+            info=self.info,
+            source=self.source,
+            annotation_db=self.annotation_db,
+        )
+
+    def strand_symmetry(self, motif_length: int = 1):
+        """returns dict of strand symmetry test results per seq"""
+        return {s.name: s.strand_symmetry(motif_length=motif_length) for s in self.seqs}
+
     def is_ragged(self) -> bool:
         return len(set(self.seqs.seq_lengths().values())) > 1
 
@@ -933,7 +1412,9 @@ class SequenceCollection:
                 return True
         return False
 
-    def get_identical_sets(self, mask_degen: bool = False):  # refactor: array
+    def get_identical_sets(
+        self, mask_degen: bool = False
+    ) -> list[set]:  # refactor: array/simplify
         """returns sets of names for sequences that are identical
 
         Parameters
@@ -943,12 +1424,23 @@ class SequenceCollection:
 
         """
 
+        if self.is_ragged():
+            raise ValueError("not all seqs same length, cannot get identical sets")
+
+        if mask_degen and not self.moltype.degen_alphabet:
+            warnings.warn(
+                "in get_identical_sets, mask_degen has no effect as moltype "
+                f"{self.moltype.label!r} has no degenerate characters",
+                UserWarning,
+            )
+            mask_degen = False
+
         def reduced(seq, indices):
             return "".join(seq[i] for i in range(len(seq)) if i not in indices)
 
         identical_sets = []
-        mask_posns = {}
         seen = []
+
         # if strict, we do a sort and one pass through the list
         seqs = self.to_dict()
         if not mask_degen:
@@ -965,16 +1457,17 @@ class SequenceCollection:
             identical_sets = list(dupes.values())
             return identical_sets
 
+        mask_posns = {
+            name: self.moltype.get_degenerate_positions(seq, include_gap=True)
+            for name, seq in seqs.items()
+        }
+
         for i in range(len(self.names) - 1):
             n1 = self.names[i]
             if n1 in seen:
                 continue
 
             seq1 = seqs[n1]
-            if n1 not in mask_posns:
-                pos = self.moltype.get_degenerate_positions(seq1, include_gap=True)
-                mask_posns[n1] = pos
-
             group = set()
             for j in range(i + 1, len(self.names)):
                 n2 = self.names[j]
@@ -982,20 +1475,13 @@ class SequenceCollection:
                     continue
 
                 seq2 = seqs[n2]
-                if n2 not in mask_posns:
-                    pos = self.moltype.get_degenerate_positions(seq2, include_gap=True)
-                    mask_posns[n2] = pos
-
-                seq2 = seqs[n2]
-
-                s1, s2 = seq1, seq2
-
                 pos = mask_posns[n1] + mask_posns[n2]
+
                 if pos:
-                    s1 = reduced(seq1, pos)
-                    s2 = reduced(seq2, pos)
+                    seq1 = reduced(seq1, pos)
+                    seq2 = reduced(seq2, pos)
 
-                if s1 == s2:
+                if seq1 == seq2:
                     seen.append(n2)
                     group.update([n1, n2])
 
@@ -1009,9 +1495,9 @@ class SequenceCollection:
         target: new_seq.Sequence,
         min_similarity: float = 0.0,
         max_similarity: float = 1.0,
-        metric: float = new_seq.frac_same,
+        metric: Callable = new_seq.frac_same,  # refactor: type hint for callable should specificy input/return type
         transform: bool = None,
-    ) -> new_seq.SequenceCollection:
+    ) -> SequenceCollection:
         """Returns new SequenceCollection containing sequences similar to target.
 
         Parameters
@@ -1022,23 +1508,28 @@ class SequenceCollection:
             minimum similarity that will be kept. Default 0.0.
         max_similarity
             maximum similarity that will be kept. Default 1.0.
-            (Note that both min_similarity and max_similarity are inclusive.)
         metric
-            similarity function to use. Must be f(first_seq, second_seq).
+            a similarity function to use. Must be f(first_seq, second_seq).
             The default metric is fraction similarity, ranging from 0.0 (0%
-            identical) to 1.0 (100% identical). The Sequence classes have lots
+            identical) to 1.0 (100% identical). The Sequence class have lots
             of methods that can be passed in as unbound methods to act as the
             metric, e.g. frac_same_gaps.
         transform
-            transformation function to use on the sequences before
-            the metric is calculated. If None, uses the whole sequences in each
-            case. A frequent transformation is a function that returns a specified
-            range of a sequence, e.g. eliminating the ends. Note that the
-            transform applies to both the real sequence and the target sequence.
-
-        WARNING: if the transformation changes the type of the sequence (e.g.
-        extracting a string from an RnaSequence object), distance metrics that
-        depend on instance data of the original class may fail.
+            transformation function to use on the sequences before the metric
+            is calculated. If None, uses the whole sequences in each case. A
+            frequent transformation is a function that returns a specified range
+            of a sequence, e.g. eliminating the ends. Note that the transform
+            applies to both the real sequence and the target sequence.
+
+        Notes
+        -----
+        both min_similarity and max_similarity are inclusive.
+
+        Warning
+        -------
+        if the transformation changes the type of the sequence (e.g. extracting
+        a string from an RnaSequence object), distance metrics that depend on
+        instance data of the original class may fail.
         """
         if transform:
             target = transform(target)
@@ -1067,11 +1558,11 @@ class SequenceCollection:
         return FORMATTERS["fasta"](self.to_dict())
 
     def __eq__(
-        self, other: Union[new_seq.SequenceCollection, dict]
+        self, other: Union[SequenceCollection, dict]
     ) -> bool:  # refactor: design
         return id(self) == id(other)
 
-    def __ne__(self, other: new_seq.SequenceCollection) -> bool:
+    def __ne__(self, other: SequenceCollection) -> bool:
         return not self.__eq__(other)
 
     def __repr__(self):
@@ -1359,7 +1850,7 @@ def _(seqs: dict) -> SupportsFeatures:
 def coerce_to_seqs_data_dict(
     data, label_to_name: Callable = None
 ) -> dict[
-    str, Union[PrimitiveSeqTypes, new_seq.Sequence]
+    str, PrimitiveSeqTypes
 ]:  # refactor: type hint for callable should specificy input/return type
     raise NotImplementedError(
         f"coerce_to_seqs_data_dict not implemented for {type(data)}"
@@ -1389,6 +1880,15 @@ def _(data: set, label_to_name: Callable = None) -> dict[str, PrimitiveSeqTypes]
     return coerce_to_seqs_data_dict(labelled_seqs, label_to_name=label_to_name)
 
 
+@coerce_to_seqs_data_dict.register
+def _(
+    data: SequenceCollection, label_to_name: Callable = None
+) -> dict[str, PrimitiveSeqTypes]:
+    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]
@@ -1409,6 +1909,11 @@ def _(first: list, data: Union[list, set]) -> dict[str, PrimitiveSeqTypes]:
     return {name_seq[0]: name_seq[1] for name_seq in data}
 
 
+@assign_names.register
+def _(first: tuple, data: Union[list, set]) -> dict[str, PrimitiveSeqTypes]:
+    return {name_seq[0]: name_seq[1] for name_seq in data}
+
+
 @singledispatch
 def make_unaligned_seqs(
     data: Union[dict[str, PrimitiveSeqTypes], SeqsData, list],
@@ -1418,7 +1923,7 @@ def make_unaligned_seqs(
     info: dict = None,
     source: Union[str, Path] = None,
     annotation_db: SupportsFeatures = None,
-) -> SequenceCollection:  # refactor: design
+) -> SequenceCollection:  # refactor: design/simplify
     """Initialise an unaligned collection of sequences.
 
     Parameters
@@ -1489,7 +1994,11 @@ def _(
     info["source"] = str(info.get("source", "unknown"))
 
     return SequenceCollection(
-        seqs_data=data, moltype=moltype, info=info, annotation_db=annotation_db
+        seqs_data=data,
+        moltype=moltype,
+        info=info,
+        annotation_db=annotation_db,
+        source=source,
     )
 
 
diff --git a/src/cogent3/core/new_moltype.py b/src/cogent3/core/new_moltype.py
index 1f67367b0..e84a106e1 100644
--- a/src/cogent3/core/new_moltype.py
+++ b/src/cogent3/core/new_moltype.py
@@ -520,7 +520,7 @@ class MolType:
 
     @is_gapped.register
     def _(self, seq: str) -> bool:
-        return self.gapped_alphabet.gap_char in seq
+        return any(gap in seq for gap in self.gaps)
 
     @is_gapped.register
     def _(self, seq: bytes) -> bool:
@@ -532,31 +532,125 @@ class MolType:
 
     @is_gapped.register
     def _(self, seq: numpy.ndarray) -> bool:
-        return (seq == self.degen_gapped_alphabet.gap_index).any()
+        gaps_indices = [self.degen_gapped_alphabet.index(gap) for gap in self.gaps]
+        return numpy.isin(seq, gaps_indices).any()
 
-    @functools.singledispatchmethod
-    def get_degenerate_positions(self, seq, include_gap=True) -> numpy.ndarray:
-        """returns a boolean array indicating degenerate positions"""
-        raise TypeError(f"{type(seq)} not supported")
-
-    @get_degenerate_positions.register
-    def _(self, seq: numpy.ndarray, include_gap=True) -> numpy.ndarray:
+    def get_degenerate_positions(
+        self, seq: StrORBytesORArray, include_gap: bool = True
+    ) -> list[int]:
+        seq = self.degen_gapped_alphabet.to_indices(seq)
         for index, val in enumerate(self.degen_gapped_alphabet):
             if include_gap and val in self.gap or val in self.ambiguities:
                 break
-        return seq >= index
+        degens = seq >= index
+        return numpy.where(degens)[0].tolist()
 
-    @get_degenerate_positions.register
-    def _(self, seq: str, include_gap=True) -> numpy.ndarray:
-        return self.get_degenerate_positions(
-            self.degen_gapped_alphabet.to_indices(seq), include_gap
-        )
+    @functools.singledispatchmethod
+    def degap(self, seq) -> StrORBytesORArray:
+        """removes all gap and missing characters from a sequence"""
+        # refactor: design
+        # cache translation callables (using alpabet.convert_alphabet)
+        # previous implementation had support for tuples -- is this necessary?
+        raise TypeError(f"{type(seq)} not supported")
 
-    @get_degenerate_positions.register
-    def _(self, seq: bytes, include_gap=True) -> numpy.ndarray:
-        return self.get_degenerate_positions(
-            self.degen_gapped_alphabet.to_indices(seq), include_gap
-        )
+    @degap.register
+    def _(self, seq: bytes) -> bytes:
+        return seq.translate(None, delete="".join(self.gaps).encode("utf8"))
+
+    @degap.register
+    def _(self, seq: str) -> str:
+        degapped = self.degap(seq.encode("utf8"))
+        return degapped.decode("utf8")
+
+    @degap.register
+    def _(self, seq: numpy.ndarray) -> numpy.ndarray:
+        degapped = self.degen_gapped_alphabet.array_to_bytes(seq)
+        return self.degen_gapped_alphabet.to_indices(self.degap(degapped))
+
+    def is_ambiguity(self, query_motif: str) -> bool:
+        """Return True if querymotif is an amibiguity character in alphabet.
+
+        Parameters
+        ----------
+        query_motif
+            the motif being queried.
+
+        """
+        ambigs_missing = {
+            self.missing,
+            *frozenset(self.ambiguities.keys()),
+        }
+        return query_motif in ambigs_missing
+
+    def resolve_ambiguity(
+        self,
+        ambig_motif: str,
+        alphabet: typing.Optional[new_alphabet.CharAlphabet] = 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.
+        """
+        # refactor: simplify
+        ambiguities = {
+            **self.ambiguities,
+            self.gap: frozenset(self.gap),
+            self.missing: set([*self.alphabet, self.gap]),
+        }
+        for m in self.alphabet:
+            ambiguities[m] = frozenset(m)
+        if alphabet is None:
+            word_alpha = self.alphabet.get_kmer_alphabet(
+                k=len(ambig_motif), include_gap=allow_gap
+            )
+            # refactor: design
+            # with_gap_motif() is not implemented on KmerAlphabet, check whether
+            # allow_gap makes sense in this case
+            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 as e:
+            raise new_alphabet.AlphabetError(ambig_motif) from e
+
+        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 new_alphabet.AlphabetError(ambig_motif)
+
+        return result
+
+    def strand_symmetric_motifs(self, motif_length: int = 1):
+        """returns ordered pairs of strand complementary motifs"""
+        if len(self.alphabet) != 4:
+            raise TypeError("moltype must be DNA or RNA")
+
+        motif_set = self.alphabet.get_kmer_alphabet(k=motif_length)
+        return {tuple(sorted([m, self.complement(m)])) for m in motif_set}
 
     def get_css_style(
         self,


Test patch:
diff --git a/tests/test_core/test_alignment.py b/tests/test_core/test_alignment.py
index ad7c8a04b..d1a22e54e 100644
--- a/tests/test_core/test_alignment.py
+++ b/tests/test_core/test_alignment.py
@@ -515,7 +515,7 @@ class SequenceCollectionBaseTests(object):
         aln = self.Class({"s1": "ATGRY?", "s2": "T-AG??"}, moltype=DNA)
         self.assertEqual(aln.degap(), {"s1": "ATGRY", "s2": "TAG"})
 
-    def test_degap_info(self):
+    def test_degap_info(self):  # ported
         """.degap should preserve info attributes"""
         aln = self.Class({"s1": "ATGRY?", "s2": "T-AG??"}, moltype=DNA)
         aln.info.path = "blah"
@@ -554,7 +554,7 @@ class SequenceCollectionBaseTests(object):
         self.assertEqual(f1(s3), False)
         self.assertEqual(f3(s4), True)
 
-    def test_omit_gap_seqs(self):
+    def test_omit_gap_seqs(self):  # will not port
         """SequenceCollection omit_gap_seqs should return alignment w/o seqs with gaps"""
         # check default params
         self.assertEqual(self.gaps.omit_gap_seqs(), self.gaps.omit_gap_seqs(0))
@@ -602,7 +602,7 @@ class SequenceCollectionBaseTests(object):
             isinstance(self.gaps_rna.omit_gap_seqs(3.0 / 7), _SequenceCollectionBase)
         )
 
-    def test_omit_gap_runs(self):
+    def test_omit_gap_runs(self):  # will not port
         """SequenceCollection omit_gap_runs should return alignment w/o runs of gaps"""
         # negative value will still let through ungapped sequences
         self.assertEqual(self.gaps.omit_gap_runs(-5), {"a": "AAAAAAA"})
@@ -619,7 +619,7 @@ class SequenceCollectionBaseTests(object):
         self.assertIsNot(self.gaps.omit_gap_runs(6), self.gaps)
         self.assertTrue(isinstance(self.gaps.omit_gap_runs(6), _SequenceCollectionBase))
 
-    def test_consistent_gap_degen_handling(self):
+    def test_consistent_gap_degen_handling(self):  # ported
         """gap degen character should be treated consistently"""
         # the degen character '?' can be a gap, so when we strip gaps it should
         # be gone too
@@ -634,7 +634,7 @@ class SequenceCollectionBaseTests(object):
         seqs = self.Class(data=[("a", dna), ("b", dna)])
         self.assertEqual(seqs.degap().to_fasta(), expect)
 
-    def test_pad_seqs(self):
+    def test_pad_seqs(self):  # ported
         """SequenceCollection pad_seqs should work on alignment."""
         # pad to max length
         padded1 = self.ragged_padded.pad_seqs()
@@ -658,7 +658,7 @@ class SequenceCollectionBaseTests(object):
         dna = seqs.to_moltype(DNA)
         self.assertEqual(dna.info["key"], "value")
 
-    def test_get_lengths(self):
+    def test_get_lengths(self):  # ported
         """get_lengths handles motif length, allow_gaps etc.."""
         data = {"a": "AAAA??????", "b": "CCCGGG--NN"}
         coll = self.Class(data=data, moltype=DNA)
@@ -674,7 +674,7 @@ class SequenceCollectionBaseTests(object):
         expect = {"a": 10, "b": 10}
         self.assertEqual(got, expect)
 
-    def test_strand_symmetry(self):
+    def test_strand_symmetry(self):  # ported
         """exercising strand symmetry test"""
         data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
         seqs = self.Class(data, moltype=DNA)
@@ -682,7 +682,7 @@ class SequenceCollectionBaseTests(object):
         assert_allclose(result["seq1"].observed.array, [[3, 2], [2, 2]])
         assert_allclose(result["seq2"].observed.array, [[3, 0], [2, 1]])
 
-    def test_rename_seqs(self):
+    def test_rename_seqs(self):  # ported
         """successfully rename sequences"""
         data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
         seqs = self.Class(data, moltype=DNA)
@@ -696,7 +696,7 @@ class SequenceCollectionBaseTests(object):
             for seq in new.seqs:
                 self.assertFalse("-" in str(seq.data))
 
-    def test_apply_pssm(self):
+    def test_apply_pssm(self):  # ported
         """should successfully produce pssm scores"""
         from cogent3.parse import jaspar
 
@@ -803,14 +803,14 @@ class SequenceCollectionBaseTests(object):
         os.environ.pop(env_name, None)
         self.assertEqual(got.count(token), 3 * orig.count(token))
 
-    def test_get_seq_entropy(self):
+    def test_get_seq_entropy(self):  # ported
         """get_seq_entropy should get entropy of each seq"""
         a = self.Class(dict(a="ACCC", b="AGTA"), moltype=DNA)
         entropy = a.entropy_per_seq()
         e = 0.81127812445913283  # sum(p log_2 p) for p = 0.25, 0.75
         assert_allclose(entropy, array([e, 1.5]))
 
-    def test_write_to_json(self):
+    def test_write_to_json(self):  # ported
         # test writing to json file
         aln = self.Class([("a", "AAAA"), ("b", "TTTT"), ("c", "CCCC")])
         with TemporaryDirectory(".") as dirname:
@@ -836,11 +836,11 @@ class SequenceCollectionTests(SequenceCollectionBaseTests, TestCase):
         """SequenceCollection seq_len get should work for ragged seqs"""
         self.assertEqual(self.ragged.seq_len, 6)
 
-    def test_is_ragged_ragged(self):
+    def test_is_ragged_ragged(self):  # ported
         """SequenceCollection is_ragged should return True if ragged"""
         self.assertTrue(self.ragged.is_ragged())
 
-    def test_Seqs_ragged(self):
+    def test_Seqs_ragged(self):  # ported
         """SequenceCollection seqs should work on ragged alignment"""
         self.ragged.names = "bac"
         self.assertEqual(list(self.ragged.seqs), ["AAA", "AAAAAA", "AAAA"])
@@ -855,7 +855,7 @@ class SequenceCollectionTests(SequenceCollectionBaseTests, TestCase):
         self.assertIs(seqs[1], seqs[2])
         self.assertIs(seqs[0], self.ragged.named_seqs["b"])
 
-    def test_toPHYLIP_ragged(self):
+    def test_toPHYLIP_ragged(self):  # ported
         """SequenceCollection should refuse to convert ragged seqs to phylip"""
         align_rag = self.Class(
             [
@@ -870,7 +870,7 @@ class SequenceCollectionTests(SequenceCollectionBaseTests, TestCase):
         with self.assertRaises(ValueError):
             align_rag.to_phylip()
 
-    def test_pad_seqs_ragged(self):
+    def test_pad_seqs_ragged(self):  # ported
         """SequenceCollection pad_seqs should work on ragged alignment."""
         # pad to max length
         padded1 = self.ragged.pad_seqs()
@@ -887,13 +887,13 @@ class SequenceCollectionTests(SequenceCollectionBaseTests, TestCase):
         # assertRaises error when pad_length is less than max seq length
         self.assertRaises(ValueError, self.ragged.pad_seqs, 5)
 
-    def test_info_source(self):
+    def test_info_source(self):  # ported
         """info.source exists if load seqs given a filename"""
         path = pathlib.Path("data/brca1.fasta")
         seqs = load_unaligned_seqs(path)
         self.assertEqual(seqs.info.source, str(path))
 
-    def test_apply_pssm2(self):
+    def test_apply_pssm2(self):  # ported
         """apply_pssm fail if ragged sequences"""
         data = {
             "ENSMUSG00000056468": "GCCAGGGGGGAAAGGGAGAA",
@@ -903,7 +903,7 @@ class SequenceCollectionTests(SequenceCollectionBaseTests, TestCase):
         with self.assertRaises(AssertionError):
             _ = seqs.apply_pssm(path="data/sample.jaspar", show_progress=False)
 
-    def test_construction(self):
+    def test_construction(self):  # ported
         """correctly construct from list of sequences of length 2"""
         seq1 = make_seq("AC", name="seq1")
         seq2 = make_seq("AC", name="seq2")
@@ -2467,7 +2467,7 @@ def test_get_seq_entropy(cls):
 
 
 @pytest.mark.parametrize("moltype", ("dna", "rna"))
-def test_distance_matrix_singleton_collection(moltype):
+def test_distance_matrix_singleton_collection(moltype):  # ported
     """SequenceCollection.distance_matrix() should raise error if collection
     only contains a single sequence"""
     collection = make_unaligned_seqs(data={"s1": "ACGTACGTAGTCGCG"}, moltype=moltype)
@@ -2476,7 +2476,7 @@ def test_distance_matrix_singleton_collection(moltype):
 
 
 @pytest.mark.parametrize("moltype", ("dna", "rna"))
-def test_collection_distance_matrix_same_seq(moltype):
+def test_collection_distance_matrix_same_seq(moltype):  # ported
     """Identical seqs should return distance measure of 0.0"""
     data = dict(
         [("s1", "ACGTACGTAGTCGCG"), ("s2", "GTGTACGTATCGCG"), ("s3", "GTGTACGTATCGCG")]
@@ -2494,7 +2494,7 @@ def test_collection_distance_matrix_same_seq(moltype):
 
 
 @pytest.mark.parametrize("moltype", ("protein", "text", "bytes"))
-def test_distance_matrix_fails_wrong_moltype(moltype):
+def test_distance_matrix_fails_wrong_moltype(moltype):  # ported
     data = [("s1", "ACGTA"), ("s2", "ACGTA")]
     seqs = make_unaligned_seqs(data=data, moltype=moltype)
     with pytest.raises(NotImplementedError):
@@ -2502,7 +2502,7 @@ def test_distance_matrix_fails_wrong_moltype(moltype):
 
 
 @pytest.mark.parametrize("moltype", ("dna", "rna"))
-def test_distance_matrix_passes_correct_moltype(moltype):
+def test_distance_matrix_passes_correct_moltype(moltype):  # ported
     data = [("s1", "ACGTA"), ("s2", "ACGTA")]
     seqs = make_unaligned_seqs(data=data, moltype=moltype)
     seqs.distance_matrix()
@@ -2512,7 +2512,7 @@ def test_distance_matrix_passes_correct_moltype(moltype):
 @pytest.mark.parametrize(
     "seqs", ({"seq1": "GATTTT", "seq2": "GATC??"}, {"seq1": "GAT---", "seq2": "?GATCT"})
 )
-def test_get_translation2(cls, seqs):
+def test_get_translation2(cls, seqs):  # ported for SequenceCollection
     """SequenceCollection.get_translation translates each seq"""
     alignment = cls(data=seqs, moltype=DNA)
     got = alignment.get_translation()
@@ -2521,7 +2521,7 @@ def test_get_translation2(cls, seqs):
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
-def test_get_translation_with_stop(cls):
+def test_get_translation_with_stop(cls):  # ported for SequenceCollection
     seqs = {"seq1": "GATTAG", "seq2": "?GATCT"}
     alignment = cls(data=seqs, moltype=DNA)
     got = alignment.get_translation(include_stop=True)
@@ -2544,7 +2544,7 @@ def test_get_translation_trim_stop(cls):  # ported for SequenceCollection
 @pytest.mark.parametrize(
     "seqs", ({"seq1": "GATTTT", "seq2": "GATC??"}, {"seq1": "GAT---", "seq2": "?GATCT"})
 )
-def test_get_translation_error(cls, seqs):
+def test_get_translation_error(cls, seqs):  # ported for SequenceCollection
     """SequenceCollection.get_translation translates each seq"""
     # check for a failure when no moltype specified
     alignment = cls(data=seqs)
@@ -2640,7 +2640,7 @@ def test_aligned_rich_dict(reverse):
         {"seq1": "GAT---", "seq2": "?GATCT"},
     ),
 )
-def test_get_translation_info(cls, seqs):
+def test_get_translation_info(cls, seqs):  # ported for SequenceCollection
     """SequenceCollection.get_translation preserves info attribute"""
     alignment = cls(data=seqs, moltype=DNA, info={"key": "value"})
     got = alignment.get_translation()
@@ -2656,7 +2656,7 @@ def test_get_translation_info(cls, seqs):
         (2, ("GATTTT", "TCCAGG")),
     ),
 )
-def test_has_terminal_stop_true(cls, gc, seqs):  # ported
+def test_has_terminal_stop_true(cls, gc, seqs):  # ported for SequenceCollection
     gc = get_code(gc)
     data = {f"s{i}": s for i, s in enumerate(seqs)}
     seqs = cls(data=data, moltype="dna")
@@ -2668,7 +2668,7 @@ def test_has_terminal_stop_true(cls, gc, seqs):  # ported
     "gc,seqs",
     ((1, ("TCCTCA", "GATTTT")), (2, ("GATTTT", "TCCCGG")), (1, ("CCTCA", "ATTTT"))),
 )
-def test_has_terminal_stop_false(cls, gc, seqs):  # ported
+def test_has_terminal_stop_false(cls, gc, seqs):  # ported for SequenceCollection
     gc = get_code(gc)
     data = {f"s{i}": s for i, s in enumerate(seqs)}
     seqs = cls(data=data, moltype="dna")
@@ -2676,7 +2676,7 @@ def test_has_terminal_stop_false(cls, gc, seqs):  # ported
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
-def test_has_terminal_stop_strict(cls):
+def test_has_terminal_stop_strict(cls):  # ported for SequenceCollection
     gc = get_code(1)
     data = {f"s{i}": s for i, s in enumerate(("CCTCA", "ATTTT"))}
     seqs = cls(data=data, moltype="dna")
@@ -2748,7 +2748,7 @@ def test_trim_stop_codons_info(cls):  # ported for SequenceCollection
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
-def test_get_translation_incomplete(cls):
+def test_get_translation_incomplete(cls):  # ported for SequenceCollection
     """get translation works on incomplete codons"""
     alignment = cls(data={"seq1": "GATN--", "seq2": "?GATCT"}, moltype=DNA)
     got = alignment.get_translation(incomplete_ok=True)
diff --git a/tests/test_core/test_moltype.py b/tests/test_core/test_moltype.py
index eea959f8a..01c2d49d0 100644
--- a/tests/test_core/test_moltype.py
+++ b/tests/test_core/test_moltype.py
@@ -508,7 +508,7 @@ class MolTypeTests(TestCase):
         # should raise exception on unknown disambiguation method
         self.assertRaises(NotImplementedError, d, s, "xyz")
 
-    def test_degap(self):
+    def test_degap(self):  # ported
         """MolType degap should remove all gaps from sequence"""
         g = RnaMolType.degap
         self.assertEqual(g(""), "")
@@ -945,7 +945,7 @@ class DinucAlphabet(_AlphabetTestCase):
         alpha = self.alpha.get_subset(motif_freqs)
         self.assertEqualSets(alpha, ["AA", "CA", "GT"])
 
-    def test_strand_symmetric_motifs(self):
+    def test_strand_symmetric_motifs(self):  # ported
         """construction of strand symmetric motif sets"""
         # fails for a moltype with no strand complement
         with self.assertRaises(TypeError):
@@ -1116,13 +1116,13 @@ class TestCodonAlphabet(_AlphabetTestCase):
         self.assertEqual(alpha_int, alpha_name)
 
 
-def test_resolve_ambiguity_nucs():
+def test_resolve_ambiguity_nucs():  # ported
     got = DNA.resolve_ambiguity("AT?", allow_gap=False)
     assert len(got) == 4
     assert len(got[0]) == 3
 
 
-def test_resolve_ambiguity_codons():
+def test_resolve_ambiguity_codons():  # ported
     from cogent3 import get_code
 
     gc = get_code(1)
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
index 05f5b82de..1e4436361 100644
--- a/tests/test_core/test_new_alignment.py
+++ b/tests/test_core/test_new_alignment.py
@@ -1,4 +1,7 @@
 import os
+import re
+
+from warnings import catch_warnings, filterwarnings
 
 import numpy
 import pytest
@@ -8,11 +11,16 @@ import cogent3.core.new_alphabet as new_alpha
 import cogent3.core.new_moltype as new_moltype
 import cogent3.core.new_sequence as new_seq
 
-# todo: kath, update this to using new Sequence load_seq when it is ready
+# todo: kath, update to using new_sequence.load_seq when implemented
 from cogent3 import load_seq
 from cogent3.core.annotation_db import GffAnnotationDb, load_annotations
 
 
+@pytest.fixture(scope="session")
+def tmp_path(tmpdir_factory):
+    return tmpdir_factory.mktemp("tmp_path")
+
+
 @pytest.fixture
 def seq1():
     return "ACTG"
@@ -122,13 +130,14 @@ def gff_db(DATA_DIR):
     return load_annotations(path=DATA_DIR / "simple.gff")
 
 
-@pytest.fixture(scope="session")
-def seqcoll_db():
-    fasta_path = os.path.join("data/c_elegans_WS199_dna_shortened.fasta")
-    gff3_path = os.path.join("data/c_elegans_WS199_shortened_gff.gff3")
+@pytest.fixture(scope="function")
+def seqcoll_db(DATA_DIR):
+    fasta_path = os.path.join(DATA_DIR / "c_elegans_WS199_dna_shortened.fasta")
     seq = load_seq(fasta_path, moltype="dna")
-    seq_coll = new_aln.make_unaligned_seqs({seq.name: seq}, moltype="dna")
-    seq_coll.annotate_from_gff(gff3_path)
+    seq_coll = new_aln.make_unaligned_seqs({seq.name: str(seq)}, moltype="dna")
+    seq_coll.annotation_db = load_annotations(
+        path=DATA_DIR / "c_elegans_WS199_shortened_gff.gff3"
+    )
     return seq_coll
 
 
@@ -137,12 +146,6 @@ def test_seqs_data_default_attributes(dna_sd: new_aln.SeqsData):
     assert isinstance(dna_sd.alphabet, new_alpha.CharAlphabet)
 
 
-@pytest.mark.xfail(reason="SeqsData currently expects correctly formatted data")
-def test_seqs_data_seq_if_str(seq1: str, alpha):
-    with pytest.raises(NotImplementedError):
-        new_aln.SeqsData(data=seq1, alphabet=alpha)
-
-
 def test_seqs_data_view_zero_step_raises(dna_sd):
     with pytest.raises(ValueError):
         new_aln.SeqDataView(seqs=dna_sd, step=0, seq_len=4)
@@ -599,7 +602,7 @@ def test_sequence_collection_names(seqs):
         seqs.names = ["seq1", "seq2", "seq3", "seq4"]
 
 
-def test_init_seq():
+def test_make_unaligned_seqs_list_str():
     """SequenceCollection init from list of sequences should use indices as keys"""
     seqs = ["TTTTT", "CCCCC", "GGGGG"]
     a = new_aln.make_unaligned_seqs(seqs, moltype="dna")
@@ -610,10 +613,7 @@ def test_init_seq():
     assert a.names == ["seq_0", "seq_1", "seq_2"]
 
 
-@pytest.mark.xfail(
-    reason="todo: decide if we support init from list of (key,val) pairs"
-)
-def test_init_pairs():
+def test_make_unaligned_seqs_pairs():
     """SequenceCollection init from list of (key,val) pairs should work correctly"""
     seqs = [["a", "AAA"], ["t", "TTT"], ["c", "CCC"]]
     a = new_aln.make_unaligned_seqs(seqs, moltype="dna")
@@ -624,7 +624,15 @@ def test_init_pairs():
     assert a.names == ["a", "t", "c"]
 
 
-def test_init_ordered(ordered1, ordered2):
+def test_make_unaligned_seqs_list_sequences():
+    """correctly construct from list of sequences of length 2"""
+    seq1 = new_moltype.DNA.make_seq(seq="AC", name="seq1")
+    seq2 = new_moltype.DNA.make_seq(seq="AC", name="seq2")
+    coll = new_aln.make_unaligned_seqs([seq1, seq2], moltype="dna")
+    assert isinstance(coll, new_aln.SequenceCollection)
+
+
+def test_sequence_collection_init_ordered(ordered1, ordered2):
     """SequenceCollection should iterate over seqs correctly even if ordered"""
     first = ordered1
     sec = ordered2
@@ -641,7 +649,7 @@ def test_sequence_collection_init_ambig():
     _ = new_aln.make_unaligned_seqs(["A?A", "CC-"], moltype="dna")
 
 
-def test_iter_seqs_ragged_padded(ragged_padded):
+def test_sequence_collection_iter_seqs_ragged_padded(ragged_padded):
     """SequenceCollection.iter_seqs() method should support reordering of seqs"""
     seqs = list(ragged_padded.iter_seqs())
     assert seqs == ["AAAAAA", "AAA---", "AAAA--"]
@@ -773,7 +781,7 @@ def test_sequence_collection_set_repr_policy_valid_input(seqs):
 
 def test_sequence_collection_take_seqs(ragged_padded):
     """SequenceCollection take_seqs should return new SequenceCollection with selected seqs."""
-    a = ragged_padded.take_seqs(list("bc"))
+    a = ragged_padded.take_seqs(["b", "c"])
     assert isinstance(a, new_aln.SequenceCollection)
     assert a.names == ["b", "c"]
     assert str(a.seqs["b"]) == "AAA---"
@@ -800,10 +808,10 @@ def test_sequence_collection_take_seqs_str(ragged_padded):
 
 def test_sequence_collection_take_seqs_info():
     """take_seqs should preserve info attribute"""
-    moltype = new_moltype.get_moltype("dna")
+    data = {"a": "CCCCCC", "b": "AAA---", "c": "AAAA--"}
     orig = new_aln.make_unaligned_seqs(
-        {"a": "CCCCCC", "b": "AAA---", "c": "AAAA--"},
-        moltype=moltype,
+        data,
+        moltype="dna",
         info={"key": "value"},
     )
     subset = orig.take_seqs(list("ab"))
@@ -812,29 +820,37 @@ def test_sequence_collection_take_seqs_info():
 
 def test_sequence_collection_take_seqs_moltype():
     """take_seqs should preserve the MolType"""
-    moltype = new_moltype.get_moltype("dna")
-    orig = new_aln.make_unaligned_seqs(
-        {"a": "CCCCCC", "b": "AAA---", "c": "AAAA--"}, moltype=moltype
-    )
+    data = {"a": "CCCCCC", "b": "AAA---", "c": "AAAA--"}
+    orig = new_aln.make_unaligned_seqs(data, moltype="dna")
     subset = orig.take_seqs(list("ab"))
     assert set(subset.moltype) == set(orig.moltype)
 
 
 def test_sequence_collection_take_seqs_empty_names():
-    moltype = new_moltype.get_moltype("dna")
-    orig = new_aln.make_unaligned_seqs(
-        {"a": "CCCCCC", "b": "AAA---", "c": "AAAA--"}, moltype=moltype
-    )
+    """take_seqs should raise ValueError if no seqs are selected."""
+    data = {"a": "CCCCCC", "b": "AAA---", "c": "AAAA--"}
+    orig = new_aln.make_unaligned_seqs(data, moltype="dna")
     with pytest.raises(ValueError):
         _ = orig.take_seqs([])
 
 
+def test_sequence_collection_take_seqs_copy_annotations(gff_db):
+    data = {"test_seq": "ACGT", "test_seq2": "CGTTTA"}
+    seq_coll = new_aln.make_unaligned_seqs(data, moltype="dna", annotation_db=gff_db)
+    seq_coll.annotation_db = gff_db
+    # if copy annotations is true, only the annotations for the selected seqs should be copied
+    just_seq2 = seq_coll.take_seqs(names="test_seq2", copy_annotations=True)
+    assert len(just_seq2.annotation_db) == 1
+
+    # if copy annotations is false, the annotation db should be the same as original
+    just_seq1 = seq_coll.take_seqs(names="test_seq", copy_annotations=False)
+    assert just_seq1.annotation_db == gff_db
+
+
 def test_sequence_collection_num_seqs():
     """SequenceCollection.num_seqs should count seqs."""
-
-    seqs = new_aln.make_unaligned_seqs(
-        {"seq1": "ACGU", "seq2": "CGUA", "seq3": "CCGU"}, moltype="rna"
-    )
+    data = {"seq1": "ACGU", "seq2": "CGUA", "seq3": "CCGU"}
+    seqs = new_aln.make_unaligned_seqs(data, moltype="rna")
     assert seqs.num_seqs == 3
 
 
@@ -861,7 +877,7 @@ def is_any(x):
     return len(x) > 0
 
 
-def test_get_seq_names_if(ragged_padded):
+def test_sequence_collection_get_seq_names_if(ragged_padded):
     """SequenceCollection.get_seq_names_if should return names of seqs where f(row) is True"""
 
     assert ragged_padded.get_seq_names_if(is_long) == []
@@ -874,7 +890,7 @@ def test_get_seq_names_if(ragged_padded):
     assert ragged_padded.get_seq_names_if(is_any, negate=True) == []
 
 
-def test_take_seqs_if(ragged_padded):
+def test_sequence_collection_take_seqs_if(ragged_padded):
     """SequenceCollection take_seqs_if should return seqs where f(seq) is True"""
 
     with pytest.raises(ValueError):
@@ -902,15 +918,13 @@ def test_sequence_collection_to_dict():
 
 def test_sequence_collection_get_seq():
     """SequenceCollection.get_seq should return specified seq"""
-    seqs = new_aln.make_unaligned_seqs(
-        {"seq1": "GATTTT", "seq2": "GATC??"}, moltype="dna"
-    )
+    data = {"seq1": "GATTTT", "seq2": "GATC??"}
+    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
     assert seqs.get_seq("seq1") == "GATTTT"
     with pytest.raises(KeyError):
         seqs.get_seq("seqx")
 
 
-@pytest.mark.xfail(reason="AttributeError: 'MolType' object has no attribute 'degap'")
 def test_sequence_collection_degap():
     """SequenceCollection.degap should strip gaps from each seq"""
     seqs = new_aln.make_unaligned_seqs({"s1": "ATGRY?", "s2": "T-AG??"}, moltype="dna")
@@ -919,33 +933,43 @@ def test_sequence_collection_degap():
 
 def test_sequence_collection_to_fasta():
     """SequenceCollection should return correct FASTA string"""
-    seqs = new_aln.make_unaligned_seqs(["AAA", "CCC"], moltype="dna")
+    data = {"seq_0": "AAA", "seq_1": "CCC"}
+    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
     assert seqs.to_fasta() == ">seq_0\nAAA\n>seq_1\nCCC\n"
     assert seqs.to_fasta(block_size=2) == ">seq_0\nAA\nA\n>seq_1\nCC\nC\n"
 
-    seqs = new_aln.make_unaligned_seqs(["GCATGCAT", "TCAGACGT"], moltype="dna")
+    data = {"seq_0": "GCATGCAT", "seq_1": "TCAGACGT"}
+    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
     assert seqs.to_fasta() == ">seq_0\nGCATGCAT\n>seq_1\nTCAGACGT\n"
     assert seqs.to_fasta(block_size=4) == ">seq_0\nGCAT\nGCAT\n>seq_1\nTCAG\nACGT\n"
     assert seqs.to_fasta(block_size=3) == ">seq_0\nGCA\nTGC\nAT\n>seq_1\nTCA\nGAC\nGT\n"
 
 
-def test_is_ragged(ragged, ragged_padded):
+def test_sequence_collection_is_ragged(ragged, ragged_padded):
     """SequenceCollection is_ragged should return true if ragged alignment"""
     assert ragged.is_ragged()
     assert not ragged_padded.is_ragged()
 
 
+def test_sequence_collection_ragged(ragged):
+    """SequenceCollection seqs should work on ragged alignment"""
+    ragged.names = "bac"
+    assert [ragged.seqs.get_seq_str(seqid=name) for name in ragged.names] == [
+        "AAA",
+        "AAAAAA",
+        "AAAA",
+    ]
+
+
 def test_sequence_collection_to_phylip():
     """SequenceCollection should return PHYLIP string format correctly"""
-    align_norm = new_aln.make_unaligned_seqs(
-        [
-            "ACDEFGHIKLMNPQRSTUVWY-",
-            "ACDEFGHIKLMNPQRSUUVWF-",
-            "ACDEFGHIKLMNPERSKUVWC-",
-            "ACNEFGHIKLMNPQRS-UVWP-",
-        ],
-        moltype="protein",
-    )
+    data = {
+        "seq_0": "ACDEFGHIKLMNPQRSTUVWY-",
+        "seq_1": "ACDEFGHIKLMNPQRSUUVWF-",
+        "seq_2": "ACDEFGHIKLMNPERSKUVWC-",
+        "seq_3": "ACNEFGHIKLMNPQRS-UVWP-",
+    }
+    align_norm = new_aln.make_unaligned_seqs(data, moltype="protein")
 
     assert (
         align_norm.to_phylip()
@@ -953,6 +977,15 @@ def test_sequence_collection_to_phylip():
     )
 
 
+def test_sequence_collection_to_phylip_ragged():
+    """SequenceCollection should refuse to convert ragged seqs to phylip"""
+    data = {"seq1": "KGA-", "seq2": "KGA"}
+    align_rag = new_aln.make_unaligned_seqs(data, moltype="protein")
+
+    with pytest.raises(ValueError):
+        align_rag.to_phylip()
+
+
 @pytest.mark.parametrize(
     "gc,seqs",
     (
@@ -961,23 +994,162 @@ def test_sequence_collection_to_phylip():
         (2, ("GATTTT", "TCCAGG")),
     ),
 )
-def test_has_terminal_stop_true(gc, seqs):
+def test_sequence_collection_has_terminal_stop_true(gc, seqs):
     data = {f"s{i}": s for i, s in enumerate(seqs)}
-    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
-    assert seqs.has_terminal_stop(gc=gc)
+    seq_coll = new_aln.make_unaligned_seqs(data, moltype="dna")
+    assert seq_coll.has_terminal_stop(gc=gc)
 
 
 @pytest.mark.parametrize(
     "gc,seqs",
     ((1, ("TCCTCA", "GATTTT")), (2, ("GATTTT", "TCCCGG")), (1, ("CCTCA", "ATTTT"))),
 )
-def test_has_terminal_stop_false(gc, seqs):
+def test_sequence_collection_has_terminal_stop_false(gc, seqs):
     data = {f"s{i}": s for i, s in enumerate(seqs)}
+    seq_coll = new_aln.make_unaligned_seqs(data, moltype="dna")
+    assert not seq_coll.has_terminal_stop(gc=gc)
+
+
+def test_sequence_collection_has_terminal_stop_strict():
+    data = {f"s{i}": s for i, s in enumerate(("CCTCA", "ATTTT"))}
+    seq_coll = new_aln.make_unaligned_seqs(data, moltype="dna")
+    with pytest.raises(new_alpha.AlphabetError):
+        seq_coll.has_terminal_stop(gc=1, strict=True)
+
+
+@pytest.mark.parametrize(
+    "gc,seqs",
+    ((1, ("T-CTGC", "GATAA?")), (2, ("GATTTT", "TCCCGG")), (1, ("CCTGC", "GATAA"))),
+)
+def test_sequence_collection_trim_terminal_stops_no_stop(gc, seqs):
+    data = {f"s{i}": s for i, s in enumerate(seqs)}
+    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
+    got = seqs.trim_stop_codons(gc=gc)
+    assert got is seqs
+
+
+@pytest.mark.parametrize(
+    "data", ({"s1": "CCTCA", "s2": "ATTTT"}, {"s1": "CCTCA-", "s2": "ATTTTA"})
+)
+def test_sequence_collection_trim_terminal_stops_strict(data):
     seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
-    assert not seqs.has_terminal_stop(gc=gc)
+    with pytest.raises(new_alpha.AlphabetError):
+        seqs.trim_stop_codons(gc=1, strict=True)
 
 
-def test_get_similar():
+def test_sequence_collection_trim_stop_codons_info():
+    """trim_stop_codons should preserve info attribute"""
+    data = {"seq1": "ACGTAA", "seq2": "ACGACG", "seq3": "ACGCGT"}
+    coll = new_aln.make_unaligned_seqs(
+        data,
+        moltype="dna",
+        info={"key": "value"},
+    )
+    coll = coll.trim_stop_codons()
+    assert coll.info["key"] == "value"
+
+
+@pytest.fixture(scope="function")
+def dna_seqs_with_dupes():
+    data = {
+        "a": "ACGG",
+        "b": "ACGG",  # strict identical
+        "c": "ACGN",  # non-strict matches above
+        "d": "ACGT",
+        "e": "ACGT",
+        "k": "ACGT",  # strict identical
+        "f": "RAAA",
+        "g": "YAAA",  # non-strict identical
+    }
+
+    return new_aln.make_unaligned_seqs(data, moltype="dna")
+
+
+def test_sequence_collection_get_identical_sets_dna(dna_seqs_with_dupes):
+    """correctly identify sets of identical sequences"""
+    got = dna_seqs_with_dupes.get_identical_sets(
+        mask_degen=False
+    )  # a strict comparison
+    # convert to frozenset, so we can do a comparison robust to set order
+    got = frozenset(frozenset(s) for s in got)
+    expect = [{"a", "b"}, {"d", "e", "k"}]
+    expect = frozenset(frozenset(s) for s in expect)
+    assert got == expect
+
+
+def test_sequence_collection_get_identical_sets_dna_mask_degen(dna_seqs_with_dupes):
+    got = dna_seqs_with_dupes.get_identical_sets(mask_degen=True)
+    got = frozenset(frozenset(s) for s in got)
+    expect = [{"a", "b", "c"}, {"d", "e", "k"}, {"f", "g"}]
+    expect = frozenset(frozenset(s) for s in expect)
+    assert got == expect
+
+
+@pytest.fixture(scope="function")
+def protein_seqs_with_dupes():
+    data = {
+        "a": "ACGT",
+        "b": "ACGT",  # strict identical
+        "c": "ACGX",  # non-strict matches above
+        "d": "TTTT",
+        "e": "TTTT",
+        "k": "TTTT",  # strict identical
+        "f": "BAAA",
+        "g": "ZAAA",  # non-strict identical
+    }
+
+    return new_aln.make_unaligned_seqs(data, moltype="protein")
+
+
+def test_sequence_collection_get_identical_sets_protein(protein_seqs_with_dupes):
+    got = protein_seqs_with_dupes.get_identical_sets(
+        mask_degen=False
+    )  # a strict comparison
+    # convert to frozenset, so we can do a comparison robust to set order
+    got = frozenset(frozenset(s) for s in got)
+    expect = [{"a", "b"}, {"d", "e", "k"}]
+    expect = frozenset(frozenset(s) for s in expect)
+    assert got == expect
+
+
+def test_sequence_collection_get_identical_sets_protein_mask_degen(
+    protein_seqs_with_dupes,
+):
+    got = protein_seqs_with_dupes.get_identical_sets(mask_degen=True)
+    got = frozenset(frozenset(s) for s in got)
+    expect = [{"a", "b", "c"}, {"d", "e", "k"}, {"f", "g"}]
+    expect = frozenset(frozenset(s) for s in expect)
+    assert got == expect
+
+
+def test_sequence_collection_get_identical_sets_text():
+    data = {
+        "a": "ACGT",
+        "b": "ACGT",  # strict identical
+        "c": "ACGX",  # non-strict matches above
+        "d": "TTTT",
+        "e": "TTTT",
+        "k": "TTTT",  # strict identical
+        "f": "BAAA",
+        "g": "ZAAA",  # non-strict identical
+    }
+    seqs = new_aln.make_unaligned_seqs(data, moltype="text")
+    got = seqs.get_identical_sets(mask_degen=False)
+    # convert to frozenset, so we can do a comparison robust to set order
+    got = frozenset(frozenset(s) for s in got)
+    expect = [{"a", "b"}, {"d", "e", "k"}]
+    expect = frozenset(frozenset(s) for s in expect)
+    assert got == expect
+
+    with catch_warnings():
+        filterwarnings("ignore", category=UserWarning)
+        got = seqs.get_identical_sets(mask_degen=True)
+
+    got = frozenset(frozenset(s) for s in got)
+    assert got == expect
+
+
+def test_sequence_collection_get_similar():
     data = {
         "a": "AAAAAAAAAA",  # target
         "b": "AAAAAAAAAA",  # identical
@@ -1001,32 +1173,80 @@ def test_get_similar():
 def test_sequence_collection_init_annotated_seqs():
     """correctly construct from list with annotated seq"""
     seq = new_moltype.DNA.make_seq(seq="GCCAGGGGGGAAAG-GGAGAA", name="seq1")
-    _ = seq.add_feature(biotype="exon", name="name", spans=[(4, 10)])
+    seq.add_feature(biotype="exon", name="name", spans=[(4, 10)])
     coll = new_aln.make_unaligned_seqs([seq], moltype="dna")
     features = list(coll.get_features(biotype="exon"))
     assert len(features) == 1
 
 
-def test_sequence_collection_make_unaligned_seqs_annotated():
-    """annotate_from_gff should work on data from gff3 files"""
-    fasta_path = os.path.join("data/c_elegans_WS199_dna_shortened.fasta")
-    gff3_path = os.path.join("data/c_elegans_WS199_shortened_gff.gff3")
-    seq = load_seq(fasta_path, moltype="dna")
-    seq = new_moltype.DNA.make_seq(seq=str(seq), name="seq1")
-    seq.annotate_from_gff(gff3_path)
+def test_sequence_collection_counts_per_seq():
+    """SequenceCollection.counts_per_seq handles motif length, allow_gaps etc.."""
+    data = {"a": "AAAA??????", "b": "CCCGGG--NN", "c": "CCGGTTCCAA"}
+    coll = new_aln.make_unaligned_seqs(data, moltype="dna")
+    mtype = coll.moltype
+    got = coll.counts_per_seq()
+    assert got["a", "A"] == 4
+    assert len(got.motifs) == len(mtype.alphabet)
+    got = coll.counts_per_seq(include_ambiguity=True, allow_gap=True)
+    # N, -, ? are the additional states
+    assert len(got.motifs) == 7
+    expect = {"-": 2, "?": 0, "A": 0, "C": 3, "G": 3, "N": 2, "T": 0}
+    b = got["b"].to_dict()
+    for k in expect:
+        assert b[k] == expect[k]
+
+    got = coll.counts_per_seq(motif_length=2)
+    assert len(got.motifs), len(mtype.alphabet) ** 2
+    assert got["a", "AA"] == 2
+    assert got["b", "GG"] == 1
+    got = coll.counts_per_seq(exclude_unobserved=True)
+    expect = {"C": 4, "G": 2, "T": 2, "A": 2}
+    c = got["c"].to_dict()
+    for k in expect:
+        assert c[k] == expect[k]
+
+
+def test_sequence_collection_counts():
+    """SequenceCollection.counts handles motif length, allow_gaps etc.."""
+    data = {"a": "AAAA??????", "b": "CCCGGG--NN"}
+    coll = new_aln.make_unaligned_seqs(data, moltype="dna")
+    got = coll.counts()
+    expect = dict(A=4, C=3, G=3)
+    assert all(got[k] == v for k, v in expect.items())
+
+    got = coll.counts(motif_length=2)
+    expect = dict(AA=2, CC=1, CG=1, GG=1)
+    assert all(got[k] == v for k, v in expect.items())
+
+    got = coll.counts(motif_length=2, allow_gap=True)
+    expect.update({"--": 1})
+    assert all(got[k] == v for k, v in expect.items())
+
+    got = coll.counts(motif_length=2, include_ambiguity=True, allow_gap=True)
+    expect = dict(AA=2, CC=1, CG=1, GG=1, NN=1)
+    expect.update({"??": 3, "--": 1})
+    assert all(got[k] == v for k, v in expect.items())
 
 
 def test_sequence_collection_annotation_db_assign_none():
     """assigning None to annotation_db breaks conection"""
-    seq_coll = new_aln.make_unaligned_seqs(
-        {"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"}, moltype="rna"
-    )
+    data = {"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"}
+    seq_coll = new_aln.make_unaligned_seqs(data, moltype="rna")
     seq_coll.add_feature(seqid="seq1", biotype="xyz", name="abc", spans=[(1, 2)])
     seq_coll.add_feature(seqid="seq2", biotype="xyzzz", name="abc", spans=[(1, 2)])
+    assert seq_coll.annotation_db is not None
     seq_coll.annotation_db = None
     assert seq_coll.annotation_db is None
 
 
+def test_sequence_collection_annotation_db_assign_same(gff_db):
+    """assigning the same annotation_db"""
+    data = {"test_seq": "ACGT", "test_seq2": "CGTTTA"}
+    seq_coll = new_aln.make_unaligned_seqs(data, moltype="dna", annotation_db=gff_db)
+    seq_coll.annotation_db = gff_db
+    assert seq_coll.annotation_db is gff_db
+
+
 def test_sequence_collection_get_annotations_from_any_seq():
     """get_annotations_from_any_seq returns correct annotations"""
     data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
@@ -1057,9 +1277,8 @@ def test_sequence_collection_get_annotations_from_any_seq():
 
 def test_sequence_collection_copy_annotations(gff_db):
     """copy_annotations copies records from annotation db"""
-    seq_coll = new_aln.make_unaligned_seqs(
-        {"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"}, moltype="rna"
-    )
+    data = {"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"}
+    seq_coll = new_aln.make_unaligned_seqs(data, moltype="rna")
     seq_coll.add_feature(seqid="seq1", biotype="xyz", name="abc", spans=[(1, 2)])
     seq_coll.add_feature(seqid="seq2", biotype="xyzzz", name="abc", spans=[(1, 2)])
     expect = seq_coll.annotation_db.num_matches() + gff_db.num_matches()
@@ -1069,11 +1288,11 @@ def test_sequence_collection_copy_annotations(gff_db):
 
 def test_sequence_collection_get_seq_annotated():
     """SequenceCollection.get_seq should return specified seq"""
-    seqs = new_aln.make_unaligned_seqs(
-        {"seq1": "GATTTT", "seq2": "GATC??"}, moltype="dna"
-    )
+
+    data = {"seq1": "GATTTT", "seq2": "GATC??"}
+    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
     seqs.add_feature(seqid="seq1", biotype="xyz", name="abc", spans=[(1, 2)])
-    seqs.add_feature(seqid="seq2", biotype="xyzzz", name="abc", spans=[(1, 2)])
+    seqs.add_feature(seqid="seq2", biotype="xy", name="ab", spans=[(1, 2)])
 
     with_annos = seqs.get_seq("seq1", copy_annotations=True)
     assert len(with_annos.annotation_db) == 1
@@ -1092,33 +1311,26 @@ def _make_seq(name):
     return seq
 
 
-@pytest.mark.xfail(
-    reason="construction of Sequences from SequenceCollection does not propagate \
-    annotations to get a sequence with annotations, use get_seq with copy_annotations=True"
-)
 def test_sequence_collection_init_seqs_have_annotations():
     """annotations on input seqs correctly merged and propagated"""
-
-    seq_coll = new_aln.make_unaligned_seqs(
-        {"seq1": _make_seq("seq1"), "seq2": _make_seq("seq2")}, moltype="dna"
-    )
+    data = {"seq1": _make_seq("seq1"), "seq2": _make_seq("seq2")}
+    seq_coll = new_aln.make_unaligned_seqs(data, moltype="dna")
     coll_db = seq_coll.annotation_db
     assert len(coll_db) == 4
-    for seq in seq_coll.seqs:
+    for name in seq_coll.names:
+        seq = seq_coll.get_seq(name)
         db = seq.annotation_db
         assert db is coll_db
 
 
-@pytest.mark.xfail(
-    reason="annotating sequences does not propagate annotations to the SequenceCollection"
-)
-def test_add_to_seq_updates_coll():
+def test_sequence_collection_add_to_seq_updates_coll():
     """annotating a seq updates the db of the propagated"""
+    data = {
+        "x": "AACCCAAAATTTTTTGGGGGGGGGGCCCC",
+        "y": "AACCCAAAATTTTTTGGGGGGGGGGCCCC",
+    }
     seq_coll = new_aln.make_unaligned_seqs(
-        {
-            "x": "AACCCAAAATTTTTTGGGGGGGGGGCCCC",
-            "y": "AACCCAAAATTTTTTGGGGGGGGGGCCCC",
-        },
+        data,
         moltype="dna",
     )
     x = seq_coll.get_seq("x")
@@ -1127,19 +1339,7 @@ def test_add_to_seq_updates_coll():
     assert len(seq_coll.annotation_db) == len(x.annotation_db) == 1
 
 
-@pytest.mark.xfail(reason="load_seq creating old-style Sequence")
-def test_copy_annotations_incompat_fails(seqcoll_db, gb_db):
-    """copy_annotations copies records from annotation db"""
-    db = seqcoll_db.annotation_db
-    seqcoll_db.annotation_db = db
-    with pytest.raises(TypeError):
-        seqcoll_db.copy_annotations(gb_db)
-
-
-@pytest.mark.xfail(reason="load_seq creating old-style Sequence")
-def test_copy_annotations_incompat_type_fails(seqcoll_db, seqs):
-    """copy_annotations copies records from annotation db"""
-
+def test_sequence_collection_copy_annotations_incompat_type_fails(seqcoll_db, seqs):
     with pytest.raises(TypeError):
         seqcoll_db.copy_annotations(seqs)
 
@@ -1177,14 +1377,22 @@ def test_sequence_collection_to_moltype_annotation_db():
     assert len(dna.annotation_db) == 3
 
 
-def test_dotplot():
+def test_sequence_collection_to_moltype_same_moltype():
+    data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
+    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
+    to_same_seqs = seqs.to_moltype("dna")
+    assert to_same_seqs is seqs
+
+
+def test_sequence_collection_dotplot():
     """exercising dotplot method"""
+    data = {
+        "Human": "CAGATTTGGCAGTT-",
+        "Mouse": "CAGATTCAGCAGGTG",
+        "Rat": "CAGATTCAGCAGGTG",
+    }
     seqs = new_aln.make_unaligned_seqs(
-        {
-            "Human": "CAGATTTGGCAGTT-",
-            "Mouse": "CAGATTCAGCAGGTG",
-            "Rat": "CAGATTCAGCAGGTG",
-        },
+        data,
         moltype="dna",
     )
     _ = seqs.dotplot()
@@ -1197,9 +1405,265 @@ def test_sequence_collection_dotplot_annotated():
     db = GffAnnotationDb()
     db.add_feature(seqid="Human", biotype="exon", name="fred", spans=[(10, 15)])
 
-    seqs = new_aln.make_unaligned_seqs(
-        {"Human": "CAGATTTGGCAGTT-", "Mouse": "CAGATTCAGCAGGTG"}, moltype="dna"
-    )
+    data = {"Human": "CAGATTTGGCAGTT-", "Mouse": "CAGATTCAGCAGGTG"}
+    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
     seqs.annotation_db = db
     seqs = seqs.take_seqs(["Human", "Mouse"], copy_annotations=True)
     _ = seqs.dotplot(show_progress=False)
+
+
+def test_sequence_collection_add_seqs():
+    data = dict(
+        [("name1", "AAA"), ("name2", "AAA"), ("name3", "AAA"), ("name4", "AAA")]
+    )
+    data2 = dict([("name5", "BBB"), ("name6", "CCC")])
+    aln = new_aln.make_unaligned_seqs(data, moltype="text", info={"key": "foo"})
+    out_aln = aln.add_seqs(data2)
+    assert len(out_aln.names) == 6
+
+
+def test_sequence_collection_add_seqs_duplicate_raises():
+    """add_seqs should raise an error if duplicate names"""
+    data = dict(
+        [("name1", "AAA"), ("name2", "AAA"), ("name3", "AAA"), ("name4", "AAA")]
+    )
+    data2 = dict([("name5", "BBB"), ("name1", "CCC")])
+    aln = new_aln.make_unaligned_seqs(data, moltype="text", info={"key": "foo"})
+    with pytest.raises(ValueError):
+        _ = aln.add_seqs(data2)
+
+    # but it should work if we allow duplicates
+    out_aln = aln.add_seqs(data2, force_unique_keys=False)
+    assert len(out_aln.names) == 5
+
+
+def test_sequence_collection_add_seqs_info():
+    """add_seqs should preserve info attribute"""
+    data = dict(
+        [("name1", "AAA"), ("name2", "AAA"), ("name3", "AAA"), ("name4", "AAA")]
+    )
+    data2 = dict([("name5", "BBB"), ("name6", "CCC")])
+    aln = new_aln.make_unaligned_seqs(data, moltype="text", info={"key": "foo"})
+    aln2 = new_aln.make_unaligned_seqs(data2, moltype="text", info={"key": "bar"})
+    out_aln = aln.add_seqs(aln2)
+    assert out_aln.info["key"] == "foo"
+
+
+def test_sequence_collection_write(tmp_path):
+    """SequenceCollection.write should write in correct format"""
+    data = {"a": "AAAA", "b": "TTTT", "c": "CCCC"}
+    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
+    fn = tmp_path / "seqs.fasta"
+    seqs.write(fn)
+    with open(fn, newline=None) as infile:
+        result = infile.read()
+    assert result == ">a\nAAAA\n>b\nTTTT\n>c\nCCCC\n"
+
+
+def test_sequence_collection_write_gapped(tmp_path):
+    data = {"a": "AAA--", "b": "TTTT", "c": "CCCC"}
+    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
+    fn = tmp_path / "seqs.fasta"
+    seqs.write(fn)
+    with open(fn, newline=None) as infile:
+        result = infile.read()
+    assert result == ">a\nAAA--\n>b\nTTTT\n>c\nCCCC\n"
+
+
+def test_get_ambiguous_positions():
+    """SequenceCollection.get_ambiguous_positions should return pos"""
+    aln = new_aln.make_unaligned_seqs({"s1": "ATGRY?", "s2": "T-AG??"}, moltype="dna")
+    assert aln.get_ambiguous_positions() == {
+        "s2": {4: "?", 5: "?"},
+        "s1": {3: "R", 4: "Y", 5: "?"},
+    }
+
+
+def test_sequence_collection_consistent_gap_degen_handling():
+    """gap degen character should be treated consistently"""
+    # the degen character '?' can be a gap, so when we strip gaps it should
+    # be gone too
+    raw_seq = "---??-??TC-GGCG-GCA-G-GC-?-C-TAN-GCGC-CCTC-AGGA?-???-??--"
+    raw_ungapped = re.sub("[-?]", "", raw_seq)
+    re.sub("[N?]+", "", raw_seq)
+    dna = new_moltype.DNA.make_seq(seq=raw_seq)
+
+    aln = new_aln.make_unaligned_seqs({"a": dna, "b": dna}, moltype="dna")
+    expect = new_aln.make_unaligned_seqs(
+        {"a": raw_ungapped, "b": raw_ungapped}, moltype="dna"
+    ).to_fasta()
+    assert aln.degap().to_fasta() == expect
+
+
+def test_sequence_collection_pad_seqs(ragged):
+    # pad to max length
+    padded1 = ragged.pad_seqs()
+    seqs1 = list(padded1.iter_seqs(seq_order=["a", "b", "c"]))
+    assert list(map(str, seqs1)) == ["AAAAAA", "AAA---", "AAAA--"]
+
+    # pad to alternate length
+    padded2 = ragged.pad_seqs(pad_length=10)
+    seqs2 = list(padded2.iter_seqs(seq_order=["a", "b", "c"]))
+    assert list(map(str, seqs2)) == ["AAAAAA----", "AAA-------", "AAAA------"]
+
+    # assertRaises error when pad_length is less than max seq length
+    with pytest.raises(ValueError):
+        _ = ragged.pad_seqs(pad_length=5)
+
+
+@pytest.fixture
+def ambigs_coll():
+    data = {"a": "AAAA??????", "b": "CCCGGG--NN"}
+    return new_aln.make_unaligned_seqs(data, moltype="dna")
+
+
+def test_sequence_collection_get_lengths(ambigs_coll):
+    got = ambigs_coll.get_lengths()
+    expect = {"a": 4, "b": 6}
+    assert got == expect
+
+
+def test_sequence_collection_get_lengths_allow_gap(ambigs_coll):
+    got = ambigs_coll.get_lengths(include_ambiguity=True, allow_gap=True)
+    expect = {"a": 10, "b": 10}
+    assert got == expect
+
+
+def test_sequence_collection_strand_symmetry():
+    """exercising strand symmetry test"""
+    data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
+    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
+    result = seqs.strand_symmetry()
+    assert numpy.allclose(result["seq1"].observed.array, [[3, 2], [2, 2]])
+    assert numpy.allclose(result["seq2"].observed.array, [[3, 0], [2, 1]])
+
+
+def test_sequence_collection_apply_pssm():
+    """should successfully produce pssm scores"""
+    from cogent3.parse import jaspar
+
+    _, pwm = jaspar.read("data/sample.jaspar")
+    data = {
+        "ENSMUSG00000056468": "GCCAGGGGGGAAAGGGAGAA",
+        "ENSMUSG00000039616": "GCCCTTCAAATTTGGTTTCT",
+        "ENSMUSG00000024091": "TTTCCAGGGCAGACAAGACG",
+        "ENSMUSG00000024056": "ACAATAATGCCGAGAGCCAG",
+        "ENSMUSG00000054321": "TATGAAAATTTTTGCCAGGC",
+        "ENSMUSG00000052469": "CCTGTTTGCCTTTAAATATT",
+        "ENSMUSG00000024261": "CAGACAAGAAACCAGCAACA",
+        "ENSMUSG00000052031": "AGCGAGTATNCACGCACAGA",
+        "ENSMUSG00000067872": "ACACAGCTCTGACAACTCAT",
+        "ENSMUSG00000023892": "GTAACATCAGTACAGCACAG",
+    }
+    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
+    max_seq_len = max(seqs.seqs.seq_lengths().values())
+
+    scores = seqs.apply_pssm(path="data/sample.jaspar", show_progress=False)
+    assert scores.shape == (len(data), max_seq_len - pwm.shape[0] + 1)
+    scores = seqs.apply_pssm(pssm=pwm.to_pssm(), show_progress=False)
+    assert scores.shape == (len(data), max_seq_len - pwm.shape[0] + 1)
+
+    # using the names argument works to return scores in the correct order
+    seqs = new_aln.make_unaligned_seqs(
+        {"ENSMUSG00000056468": "GCCAGGGGGGAAAGGGAGAA"}, moltype="dna"
+    )
+    expect = []
+    expect.extend(seqs.apply_pssm(pssm=pwm.to_pssm(), show_progress=False))
+    seqs = new_aln.make_unaligned_seqs(
+        {"ENSMUSG00000039616": "GCCCTTCAAATTTGGTTTCT"}, moltype="dna"
+    )
+    expect.extend(seqs.apply_pssm(pssm=pwm.to_pssm(), show_progress=False))
+    expect = numpy.array(expect)
+    seqs = new_aln.make_unaligned_seqs(
+        {
+            "ENSMUSG00000056468": "GCCAGGGGGGAAAGGGAGAA",
+            "ENSMUSG00000039616": "GCCCTTCAAATTTGGTTTCT",
+        },
+        moltype="dna",
+    )
+    got = seqs.apply_pssm(
+        pssm=pwm.to_pssm(), show_progress=False, names="ENSMUSG00000056468"
+    )
+    assert numpy.allclose(got, expect[:1])
+    got = seqs.apply_pssm(
+        pssm=pwm.to_pssm(), show_progress=False, names=["ENSMUSG00000039616"]
+    )
+    assert numpy.allclose(got, expect[1:])
+    got = seqs.apply_pssm(
+        pssm=pwm.to_pssm(),
+        show_progress=False,
+        names=["ENSMUSG00000056468", "ENSMUSG00000039616"],
+    )
+    assert numpy.allclose(got, expect)
+    got = seqs.apply_pssm(
+        pssm=pwm.to_pssm(),
+        show_progress=False,
+        names=["ENSMUSG00000039616", "ENSMUSG00000056468"],
+    )
+    assert numpy.allclose(got, expect[::-1])
+
+
+def test_sequence_collection_apply_pssm2():
+    """apply_pssm fail if ragged sequences"""
+    data = {
+        "ENSMUSG00000056468": "GCCAGGGGGGAAAGGGAGAA",
+        "ENSMUSG00000039616": "GCCCTTCAAATTT",
+    }
+    seqs = new_aln.make_unaligned_seqs(data, moltype="dna")
+    with pytest.raises(AssertionError):
+        _ = seqs.apply_pssm(path="data/sample.jaspar", show_progress=False)
+
+
+def test_sequence_collection_get_seq_entropy():
+    """get_seq_entropy should get entropy of each seq"""
+    a = new_aln.make_unaligned_seqs(dict(a="ACCC", b="AGTA"), moltype="dna")
+    entropy = a.entropy_per_seq()
+    e = 0.81127812445913283  # sum(p log_2 p) for p = 0.25, 0.75
+    assert numpy.allclose(entropy, numpy.array([e, 1.5]))
+
+
+@pytest.mark.parametrize("moltype", ("dna", "rna"))
+def test_sequence_collection_distance_matrix_singleton_collection(moltype):
+    """SequenceCollection.distance_matrix() should raise error if collection
+    only contains a single sequence"""
+    collection = new_aln.make_unaligned_seqs({"s1": "ACGTACGTAGTCGCG"}, moltype=moltype)
+    with pytest.raises(ValueError):
+        _ = collection.distance_matrix()
+
+
+@pytest.mark.parametrize("moltype", ("dna", "rna"))
+def test_sequence_collection_distance_matrix_same_seq(moltype):
+    """Identical seqs should return distance measure of 0.0"""
+    data = dict([("s1", "ACGACGAGCGCG"), ("s2", "GGACGACGCG"), ("s3", "GGACGACGCG")])
+    collection = new_aln.make_unaligned_seqs(data, moltype=moltype)
+    with catch_warnings():
+        filterwarnings("ignore", category=DeprecationWarning)
+        dists = collection.distance_matrix(calc="pdist")
+
+    # all comparison of a sequence to itself should be zero
+    for seq in collection.names:
+        assert dists[(seq, seq)] == 0.0
+
+    # s2 and s3 are identical, so should be zero
+    assert dists[("s2", "s3")] == 0.0
+    assert dists[("s3", "s2")] == 0.0
+
+    # s1 is different from s2 and s3
+    assert dists[("s1", "s2")] > 0.0
+    assert dists[("s1", "s3")] > 0.0
+
+
+@pytest.mark.parametrize("moltype", ("protein", "text", "bytes"))
+def test_sequence_collection_distance_matrix_fails_wrong_moltype(moltype):
+    data = {"s1": "ACGTA", "s2": "ACGTA"}
+    seqs = new_aln.make_unaligned_seqs(data, moltype=moltype)
+    with pytest.raises(NotImplementedError):
+        seqs.distance_matrix()
+
+
+@pytest.mark.parametrize("moltype", ("dna", "rna"))
+def test_distance_matrix_passes_correct_moltype(moltype):
+    data = {"s1": "ACGTA", "s2": "ACGTA"}
+    seqs = new_aln.make_unaligned_seqs(data, moltype=moltype)
+
+    seqs.distance_matrix()
diff --git a/tests/test_core/test_new_moltype.py b/tests/test_core/test_new_moltype.py
index 25f668202..453b51a5f 100644
--- a/tests/test_core/test_new_moltype.py
+++ b/tests/test_core/test_new_moltype.py
@@ -1,7 +1,7 @@
 import numpy
 import pytest
 
-from cogent3.core import moltype, new_moltype, new_sequence
+from cogent3.core import moltype, new_alphabet, new_moltype, new_sequence
 
 
 def test_make_pairs():
@@ -167,31 +167,117 @@ def test_gap_index_constant(moltype):
 def test_get_degenerate_positions(data_type, moltype):
     seq = make_typed("ASA", data_type, moltype)
     got = moltype.get_degenerate_positions(seq)
-    expect = numpy.array([False, True, False], dtype=bool)
-    assert numpy.array_equal(got, expect)
+    expect = [1]
+    assert got == expect
 
     seq = make_typed("A-SA", data_type, moltype)
     got = moltype.get_degenerate_positions(seq)
-    expect = numpy.array([False, True, True, False], dtype=bool)
-    assert numpy.array_equal(got, expect)
+    expect = [1, 2]
+    assert got == expect
 
     got = moltype.get_degenerate_positions(seq, include_gap=False)
-    expect = numpy.array([False, False, True, False], dtype=bool)
-    assert numpy.array_equal(got, expect)
+    expect = [2]
+    assert got == expect
 
     seq = make_typed("BAB", data_type, moltype)
     got = moltype.get_degenerate_positions(seq)
-    expect = numpy.array([True, False, True], dtype=bool)
-    assert numpy.array_equal(got, expect)
+    expect = [0, 2]
+    assert got == expect
 
     seq = make_typed("---", data_type, moltype)
     got = moltype.get_degenerate_positions(seq)
-    expect = numpy.array([True, True, True], dtype=bool)
+    expect = [0, 1, 2]
+    assert got == expect
 
     seq = make_typed("", data_type, moltype)
     got = moltype.get_degenerate_positions(seq)
-    expect = numpy.array([], dtype=bool)
-    assert numpy.array_equal(got, expect)
+    expect = []
+    assert got == expect
+
+
+def test_resolve_ambiguity_nucs():
+    got = new_moltype.DNA.resolve_ambiguity("AT?", allow_gap=False)
+    assert len(got) == 4
+    assert len(got[0]) == 3
+
+
+def test_resolve_ambiguity_codons():
+    # refactor to using new_genetic_code when codon alphabet class is implemented
+    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(new_moltype.DNA.resolve_ambiguity("AT?", alphabet=codon_alpha_w_gap)) == 4
+    )
+    assert (
+        len(new_moltype.DNA.resolve_ambiguity("???", alphabet=codon_alpha_w_gap)) == 62
+    )
+    assert (
+        len(new_moltype.DNA.resolve_ambiguity("---", alphabet=codon_alpha_w_gap)) == 1
+    )
+
+    assert len(new_moltype.DNA.resolve_ambiguity("AT?", alphabet=codon_alpha)) == 4
+    assert len(new_moltype.DNA.resolve_ambiguity("???", alphabet=codon_alpha)) == 61
+
+    with pytest.raises(new_alphabet.AlphabetError):
+        new_moltype.DNA.resolve_ambiguity("at-")
+    with pytest.raises(new_alphabet.AlphabetError):
+        new_moltype.DNA.resolve_ambiguity("---", alphabet=codon_alpha)
+
+
+@pytest.mark.parametrize("char", ("N", "R", "Y", "W", "S", "M", "?"))
+def test_is_ambiguity_true(char):
+    assert new_moltype.DNA.is_ambiguity(char)
+
+
+@pytest.mark.parametrize("char", ("-", "A", "T", "C", "G"))
+def test_is_ambiguity_false(char):
+    assert not new_moltype.DNA.is_ambiguity(char)
+
+
+@pytest.mark.parametrize("data_type", (str, bytes, numpy.ndarray))
+@pytest.mark.parametrize(
+    "seq, expect",
+    (
+        ("", ""),
+        ("GAUGgaug", "GAUGgaug"),
+        ("----", ""),
+        ("--GAUG--", "GAUG"),
+        ("?gaug-", "gaug"),
+        ("-gaug", "gaug"),
+        ("gaug-", "gaug"),
+        ("-g?a-u?g-", "gaug"),
+    ),
+)
+def test_degap(seq, expect, data_type):
+    """MolType degap should remove all gaps from sequence"""
+    degap = new_moltype.RNA.degap
+    seq = make_typed(seq, data_type, new_moltype.RNA)
+    expect = make_typed(expect, data_type, new_moltype.RNA)
+    got = degap(seq)
+    assert (
+        numpy.array_equal(got, expect) if data_type == numpy.ndarray else got == expect
+    )
+
+
+def test_strand_symmetric_motifs():
+    """construction of strand symmetric motif sets"""
+    # fails for a moltype with no strand complement
+    with pytest.raises(TypeError):
+        new_moltype.PROTEIN.strand_symmetric_motifs()
+
+    got = new_moltype.DNA.strand_symmetric_motifs(motif_length=1)
+    expect = set([("A", "T"), ("C", "G")])
+    assert got == expect
+    got = new_moltype.RNA.strand_symmetric_motifs(motif_length=1)
+    expect = set([("A", "U"), ("C", "G")])
+    assert got == expect
+    got = new_moltype.DNA.strand_symmetric_motifs(motif_length=2)
+    assert len(got) == 8
+    got = new_moltype.DNA.strand_symmetric_motifs(motif_length=3)
+    assert len(got) == 32
 
 
 @pytest.mark.parametrize(