Repository: cogent3/cogent3
Pull request #2099 (2024-11-06 00:49:09)

ENH: methods for converting moltype and degapping relative to a reference 
## Summary by Sourcery

Introduce methods for converting molecular types and degapping sequences relative to a reference. Refactor the to_alphabet method and port several alignment methods for improved consistency. Add and enhance tests to cover new and updated functionalities.

New Features:
- Introduce methods for converting molecular types and degapping sequences relative to a reference.

Enhancements:
- Refactor the to_alphabet method to handle alphabet conversion with additional parameters and checks.
- Port several methods in the alignment module to improve code consistency and maintainability.

Tests:
- Add tests for the new get_degapped_relative_to method to ensure it removes columns with gaps in specified sequences.
- Enhance existing tests to cover new functionality and refactored methods, including parameterized tests for sequence collection conversions.


Patch:
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index cecd283c2..9b55e2a17 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -2574,7 +2574,7 @@ class AlignmentI(object):
 
     positions = property(iter_positions)  # ported
 
-    def take_positions(self, cols, negate=False):
+    def take_positions(self, cols, negate=False):  # ported
         """Returns new Alignment containing only specified positions.
 
         By default, the seqs will be lists, but an alternative constructor
@@ -3916,13 +3916,13 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
         self.seq_len = curr_seqs.shape[1] if len(curr_seqs) else 0
 
     @property
-    def positions(self):
+    def positions(self):  # ported
         """Override superclass positions to return positions as symbols."""
         from_indices = self.alphabet.from_indices
         return [list(from_indices(pos)) for pos in self.array_positions]
 
     @property
-    def named_seqs(self):
+    def named_seqs(self):  # ported
         if self._named_seqs is None:
             seqs = [self.alphabet.to_string(seq) for seq in self.array_seqs]
             if self.moltype:
@@ -3933,7 +3933,7 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
             self._named_seqs = _make_named_seqs(self.names, seqs)
         return self._named_seqs
 
-    def __iter__(self):
+    def __iter__(self):  # ported
         """iter(aln) iterates over positions, returning array slices.
 
         Each item in the result is be a position ('column' in standard
@@ -3945,7 +3945,7 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
         """
         return iter(self.positions)
 
-    def __getitem__(self, item):
+    def __getitem__(self, item):  # ported
         if not isinstance(item, slice):
             data = self.array_seqs[:, item]
             data = vstack(data)
@@ -3969,7 +3969,7 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
 
     def get_sub_alignment(
         self, seqs=None, pos=None, negate_seqs=False, negate_pos=False
-    ):
+    ):  # will not port (can be achieved with take_seqs and take_positions)
         """Returns subalignment of specified sequences and positions.
 
         seqs and pos can be passed in as lists of sequence indices to keep
@@ -4014,7 +4014,7 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
             moltype=self.moltype,
         )
 
-    def __str__(self):
+    def __str__(self):  # ported
         """Returns FASTA-format string.
 
         Should be able to handle joint alphabets, e.g. codons.
@@ -4048,7 +4048,7 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
         motif_length=1,
         randint=randint,
         permutation=permutation,
-    ):
+    ):  # ported
         """Returns random sample of positions from self, e.g. to bootstrap.
 
         Parameters
@@ -4093,7 +4093,9 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
             names=self.names,
         )
 
-    def filtered(self, predicate, motif_length=1, drop_remainder=True, **kwargs):
+    def filtered(
+        self, predicate, motif_length=1, drop_remainder=True, **kwargs
+    ):  # ported
         """The alignment positions where predicate(column) is true.
 
         Parameters
@@ -4154,7 +4156,7 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
                 s = s.replace(gapchar, ambig)
         return s
 
-    def get_degapped_relative_to(self, name):
+    def get_degapped_relative_to(self, name):  # ported
         """Remove all columns with gaps in sequence with given name.
 
         Returns Alignment object of the same class.
@@ -4178,7 +4180,9 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
             new, names=self.names, moltype=self.moltype, info=self.info
         )
 
-    def add_from_ref_aln(self, ref_aln, before_name=None, after_name=None):
+    def add_from_ref_aln(
+        self, ref_aln, before_name=None, after_name=None
+    ):  # will not port
         """
         Insert sequence(s) to self based on their alignment to a reference
         sequence. Assumes the first sequence in ref_aln.names[0] is the
@@ -4295,11 +4299,11 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
             new_seqarr, names=self.names, moltype=seqs.moltype, info=self.info
         )
 
-    def to_moltype(self, moltype):
+    def to_moltype(self, moltype):  # ported
         """returns copy of self with moltype seqs"""
         return super().to_moltype(moltype)
 
-    def get_identical_sets(self, mask_degen=False):
+    def get_identical_sets(self, mask_degen=False):  # ported
         """returns sets of names for sequences that are identical
 
         Parameters
@@ -4379,7 +4383,7 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
         result._repr_policy.update(self._repr_policy)
         return result
 
-    def no_degenerates(self, motif_length=1, allow_gap=False):
+    def no_degenerates(self, motif_length=1, allow_gap=False):  # ported
         """returns new alignment without degenerate characters
 
         Parameters
@@ -4418,7 +4422,7 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
         allow_gap=False,
         exclude_unobserved=False,
         warn=False,
-    ):
+    ):  # ported
         """counts of non-overlapping motifs per sequence
 
         Parameters
@@ -4468,7 +4472,7 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
             counts[i] = c.tolist(motifs)
         return MotifCountsArray(counts, motifs, row_indices=self.names)
 
-    def omit_gap_pos(self, allowed_gap_frac=1 - eps, motif_length=1):
+    def omit_gap_pos(self, allowed_gap_frac=1 - eps, motif_length=1):  # ported
         """Returns new alignment where all cols (motifs) have <= allowed_gap_frac gaps.
 
         Parameters
@@ -4499,7 +4503,7 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
         result = self.filtered(gaps_ok, motif_length=motif_length)
         return result
 
-    def get_position_indices(self, f, native=False, negate=False):
+    def get_position_indices(self, f, native=False, negate=False):  # ported
         """Returns list of column indices for which f(col) is True.
 
         f : callable
@@ -4526,7 +4530,7 @@ class ArrayAlignment(AlignmentI, _SequenceCollectionBase):
 
         return result
 
-    def get_gap_array(self, include_ambiguity=True):
+    def get_gap_array(self, include_ambiguity=True):  # ported
         """returns bool array with gap state True, False otherwise
 
         Parameters
@@ -4664,7 +4668,7 @@ class Alignment(AlignmentI, SequenceCollection):
             align.append((sliced.name, sliced))
         return self.__class__(moltype=self.moltype, data=align, info=self.info)
 
-    def gapped_by_map(self, keep, **kwargs):
+    def gapped_by_map(self, keep, **kwargs):  # ported
         # keep is a Map
         # seqs = [seq[keep] for seq in self.seqs]
         seqs = []
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index f9a2fcc46..6e39d51b3 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -521,9 +521,19 @@ class SeqsData(SeqsDataABC):
         self, alphabet: new_alphabet.AlphabetABC, check_valid=True
     ) -> SeqsData:
         # refactor: design -- map directly between arrays?
-        # refactor: design -- better way to check if alphabets are DNA and RNA?
-        if len(alphabet) == len(self.alphabet):
-            return self.__class__(data=self._data, alphabet=alphabet)
+        # if the length of the two alphabets are the same and the only difference
+        # between the sets of characters is U/T, then we have the special case of
+        # converting between DNA and RNA alphabets, which we can achieved easily.
+        if (
+            len(self.alphabet) == len(alphabet)
+            and len({(a, b) for a, b in zip(self.alphabet, alphabet) if a != b}) == 1
+        ):
+            return self.__class__(
+                data=self._data,
+                alphabet=alphabet,
+                offset=self._offset,
+                reversed=self._reversed,
+            )
 
         new_data = {}
         old = self.alphabet.as_bytes()
@@ -538,7 +548,7 @@ class SeqsData(SeqsDataABC):
             as_new_alpha = convert_bytes_to_new(convert_old_to_bytes(seq_data))
 
             if check_valid and not alphabet.is_valid(as_new_alpha):
-                raise ValueError(
+                raise new_moltype.MolTypeError(
                     f"Changing from old alphabet={self.alphabet} to new "
                     f"{alphabet=} is not valid for this data"
                 )
@@ -947,8 +957,8 @@ class SequenceCollection:
         alpha = mtype.most_degen_alphabet()
         try:
             new_seqs_data = self._seqs_data.to_alphabet(alpha)
-        except ValueError as e:
-            raise ValueError(
+        except new_moltype.MolTypeError as e:
+            raise new_moltype.MolTypeError(
                 f"Failed to convert moltype from {self.moltype.label} to {moltype}"
             ) from e
 
@@ -3284,9 +3294,46 @@ class AlignedSeqsData(AlignedSeqsDataABC):
         else:
             raise ValueError(f"provided {names=} not found in collection")
 
-    def to_alphabet(self, alphabet: new_alphabet.AlphabetABC):
-        # todo: kath
-        ...
+    def to_alphabet(self, alphabet: new_alphabet.AlphabetABC, check_valid: bool = True):
+        """Returns a new AlignedSeqsData object with the same underlying data
+        with a new alphabet."""
+        if (
+            len(alphabet) == len(self.alphabet)
+            and len({(a, b) for a, b in zip(self.alphabet, alphabet) if a != b}) == 1
+        ):
+            # special case where mapping between dna and rna
+            return self.__class__(
+                seqs=self._seqs,
+                gaps=self._gaps,
+                alphabet=alphabet,
+                offset=self._offset,
+                align_len=self.align_len,
+            )
+
+        new_data = {}
+        old = self.alphabet.as_bytes()
+        new = alphabet.as_bytes()
+        convert_old_to_bytes = new_alphabet.array_to_bytes(old)
+        convert_bytes_to_new = new_alphabet.bytes_to_array(
+            new, dtype=new_alphabet.get_array_type(len(new))
+        )
+
+        for seqid in self.names:
+            seq_data = self.get_gapped_seq_array(seqid=seqid)
+            as_new_alpha = convert_bytes_to_new(convert_old_to_bytes(seq_data))
+
+            if check_valid and not alphabet.is_valid(as_new_alpha):
+                raise new_moltype.MolTypeError(
+                    f"Changing from old alphabet={self.alphabet} to new "
+                    f"{alphabet=} is not valid for this data"
+                )
+            new_data[seqid] = as_new_alpha
+
+        return self.from_seqs(
+            data=new_data,
+            alphabet=alphabet,
+            offset=self._offset,
+        )
 
     def to_rich_dict(self):
         # todo: kath
@@ -3690,6 +3737,13 @@ class Alignment(SequenceCollection):
         )
         return Aligned(data=data, moltype=self.moltype, name=seqid)
 
+    @property
+    def positions(self):
+        # refactor: design
+        # possibly rename to str_positions since we have array_positions
+        from_indices = self.moltype.most_degen_alphabet().from_indices
+        return [list(from_indices(pos)) for pos in self.array_positions]
+
     @property
     def array_seqs(self) -> numpy.ndarray:
         """Returns a numpy array of sequences, axis 0 is seqs in order
@@ -3797,6 +3851,7 @@ class Alignment(SequenceCollection):
             seqs_data=self._seqs_data,
             moltype=self.moltype,
             name_map=new_name_map,
+            slice_record=self._slice_record,
             info=self.info,
             annotation_db=self.annotation_db,
         )
@@ -3828,8 +3883,6 @@ class Alignment(SequenceCollection):
         for pos in pos_order:
             yield [str(self[seq][pos]) for seq in self.names]
 
-    positions = property(iter_positions)
-
     def get_position_indices(
         self, f: Callable, native: bool = False, negate: bool = False
     ) -> list[int]:
@@ -4268,6 +4321,38 @@ class Alignment(SequenceCollection):
         )
         return make_unaligned_seqs(data, moltype=self.moltype, info=self.info, **kwargs)
 
+    def get_degapped_relative_to(self, name: str):
+        """Remove all columns with gaps in sequence with given name.
+
+        Parameters
+        ----------
+        name
+            sequence name
+
+        Notes
+        -----
+        The returned alignment will not retain an annotation_db if present.
+        """
+
+        if name not in self.names:
+            raise ValueError(f"Alignment missing sequence named {name!r}")
+
+        gapindex = self.moltype.most_degen_alphabet().gap_index
+        seqindex = self.names.index(name)
+        indices = self.array_seqs[seqindex] != gapindex
+        new = self.array_seqs[:, indices]
+
+        new_seq_data = self._seqs_data.from_names_and_array(
+            names=self.names, data=new, alphabet=self.moltype.most_degen_alphabet()
+        )
+
+        return self.__class__(
+            seqs_data=new_seq_data,
+            name_map=self._name_map,
+            moltype=self.moltype,
+            info=self.info,
+        )
+
     def matching_ref(self, ref_name, gap_fraction, gap_run):
         """Returns new alignment with seqs well aligned with a reference.
 
@@ -4553,6 +4638,29 @@ class Alignment(SequenceCollection):
 
         return result
 
+    @extend_docstring_from(SequenceCollection.to_moltype)
+    def to_moltype(self, moltype: str):
+        mtype = new_moltype.get_moltype(moltype)
+        if mtype is self.moltype:
+            return self  # nothing to be done
+
+        alpha = mtype.most_degen_alphabet()
+        try:
+            new_seqs_data = self._seqs_data.to_alphabet(alpha)
+        except new_moltype.MolTypeError as e:
+            raise new_moltype.MolTypeError(
+                f"Failed to convert moltype from {self.moltype.label} to {moltype}"
+            ) from e
+
+        return self.__class__(
+            seqs_data=new_seqs_data,
+            moltype=mtype,
+            info=self.info,
+            source=self.source,
+            slice_record=self._slice_record,
+            annotation_db=self.annotation_db,
+        )
+
     @extend_docstring_from(SequenceCollection.get_translation)
     def get_translation(
         self,


Test patch:
diff --git a/tests/test_core/test_alignment.py b/tests/test_core/test_alignment.py
index f0087f48c..df2cadd90 100644
--- a/tests/test_core/test_alignment.py
+++ b/tests/test_core/test_alignment.py
@@ -1667,7 +1667,7 @@ class AlignmentTests(AlignmentBaseTests, TestCase):
         self.assertEqual(r1, {"seq1": "T-G", "seq2": "TCG"})
         self.assertEqual(r2, {"seq1": "--G", "seq2": "TCG"})
 
-    def test_get_degapped_relative_to_info(self):
+    def test_get_degapped_relative_to_info(self):  # ported
         """should remove all columns with a gap in sequence with given name
         while preserving info attribute"""
         aln = self.Class(
@@ -3324,7 +3324,7 @@ def test_dotplot_alignment(cls):
 
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
-def test_get_degapped_relative_to(cls):
+def test_get_degapped_relative_to(cls):  # ported
     """should remove all columns with a gap in sequence with given name"""
     aln = cls(
         [
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
index 0ad729fc1..eb72dabda 100644
--- a/tests/test_core/test_new_alignment.py
+++ b/tests/test_core/test_new_alignment.py
@@ -368,7 +368,7 @@ def test_seqs_data_to_alphabet_invalid():
     seqs = new_alignment.SeqsData(
         data={"a": "AAA", "b": "TTT", "c": "LLL"}, alphabet=ASCII
     )
-    with pytest.raises(ValueError):
+    with pytest.raises(new_moltype.MolTypeError):
         _ = seqs.to_alphabet(DNA)
 
 
@@ -1102,15 +1102,128 @@ def test_alignemnt_degap_sliced(gap_ambig_seqs):
     expect = {"s1": "YRGTA", "s2": "GAT"}
 
 
-def test_sequence_collection_to_fasta():
-    """SequenceCollection should return correct FASTA string"""
+def test_get_degapped_relative_to():
+    """should remove all columns with a gap in sequence with given name"""
+    aln = new_alignment.make_aligned_seqs(
+        [
+            ["name1", "-AC-DEFGHI---"],
+            ["name2", "XXXXXXXXXXXXX"],
+            ["name3", "YYYY-YYYYYYYY"],
+            ["name4", "-KL---MNPR---"],
+        ],
+        moltype="protein",
+    )
+    expect = dict(
+        [
+            ["name1", "ACDEFGHI"],
+            ["name2", "XXXXXXXX"],
+            ["name3", "YY-YYYYY"],
+            ["name4", "KL--MNPR"],
+        ]
+    )
+    result = aln.get_degapped_relative_to("name1")
+    assert result.to_dict() == expect
+
+    with pytest.raises(ValueError):
+        aln.get_degapped_relative_to("nameX")
+
+
+def test_get_degapped_relative_to_no_or_all_gaps():
+    """should handle case where no gaps are present in reference
+    or when the reference is all gaps"""
+    aln = new_alignment.make_aligned_seqs(
+        [
+            ["name1", "-AC-DEFGHI---"],
+            ["name2", "XXXXXXXXXXXXX"],
+            ["name3", "YYYY-YYYYYYYY"],
+            ["name4", "-------------"],
+        ],
+        moltype="protein",
+    )
+    expect = dict(
+        [
+            ["name1", "-AC-DEFGHI---"],
+            ["name2", "XXXXXXXXXXXXX"],
+            ["name3", "YYYY-YYYYYYYY"],
+            ["name4", "-------------"],
+        ]
+    )
+    got = aln.get_degapped_relative_to("name2").to_dict()
+    assert got == expect
+
+    got = aln.get_degapped_relative_to("name4").to_dict()
+    assert got == {"name1": "", "name2": "", "name3": "", "name4": ""}
+
+
+@pytest.mark.parametrize("rc", [False, True])
+def test_get_degapped_relative_to_sliced(rc):
+    aln = new_alignment.make_aligned_seqs(
+        [
+            ["name1", "-AC-TTT"],
+            ["name2", "AAAAAA-"],
+            ["name3", "YYYY-YY"],
+            ["name4", "-AC---G"],
+        ],
+        moltype="dna",
+    )
+    sliced = aln[:5].rc() if rc else aln[:5]
+    expect = dict(
+        [
+            ["name1", "ACT"],
+            ["name2", "AAA"],
+            ["name3", "YY-"],
+            ["name4", "AC-"],
+        ]
+    )
+    expect = (
+        {name: aln.moltype.complement(seq[::-1]) for name, seq in expect.items()}
+        if rc
+        else expect
+    )
+    result = sliced.get_degapped_relative_to("name1")
+    assert result.to_dict() == expect
+
+
+def test_get_degapped_relative_to_info():
+    """should remove all columns with a gap in sequence with given name
+    while preserving info attribute"""
+    aln = new_alignment.make_aligned_seqs(
+        [
+            ["name1", "-AC-DEFGHI---"],
+            ["name2", "XXXXXX--XXXXX"],
+            ["name3", "YYYY-YYYYYYYY"],
+            ["name4", "-KL---MNPR---"],
+        ],
+        moltype="protein",
+        info={"key": "foo"},
+    )
+    out_aln = new_alignment.make_aligned_seqs(
+        [
+            ["name1", "ACDEFGHI"],
+            ["name2", "XXXX--XX"],
+            ["name3", "YY-YYYYY"],
+            ["name4", "KL--MNPR"],
+        ],
+        moltype="protein",
+        info={"key": "bar"},
+    )
+    gdrt = aln.get_degapped_relative_to("name1")
+    assert gdrt.info["key"] == "foo"
+
+
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_to_fasta(mk_cls):
+    """SequenceCollection and Alignment should return correct FASTA string"""
     data = {"seq_0": "AAA", "seq_1": "CCC"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seqs = mk_cls(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"
 
     data = {"seq_0": "GCATGCAT", "seq_1": "TCAGACGT"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seqs = mk_cls(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"
@@ -1875,28 +1988,57 @@ def test_sequence_collection_copy_annotations_incompat_type_fails(seqcoll_db, se
         seqcoll_db.copy_annotations(seqs)
 
 
+@pytest.mark.parametrize(
+    "mk_cls, seq_cls",
+    [
+        (new_alignment.make_unaligned_seqs, new_sequence.Sequence),
+        (new_alignment.make_aligned_seqs, new_alignment.Aligned),
+    ],
+)
 @pytest.mark.parametrize("moltype", ("dna", "rna", "protein"))
-def test_sequence_collection_to_moltype(moltype):
+def test_sequence_collection_to_moltype(moltype, mk_cls, seq_cls):
     data = dict(s1="ACGAA-", s2="ACCCAA")
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="text")
+    seqs = mk_cls(data, moltype="text")
     mt_seqs = seqs.to_moltype(moltype=moltype)
     got = mt_seqs.seqs["s1"]
     assert got.moltype.label == moltype
-    assert isinstance(got, new_sequence.Sequence)
+    assert isinstance(got, seq_cls)
+
+
+@pytest.mark.parametrize("rc", (True, False))
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_to_moltype_rc(rc, mk_cls):
+    data = dict(s1="TCAG", s2="TCA-")
+    seqs = mk_cls(data, moltype="dna")
+    seqs = seqs.rc() if rc else seqs
+    got = seqs.to_moltype("rna").to_dict()
+    expect = {"s1": "CUGA", "s2": "-UGA"} if rc else dict(s1="UCAG", s2="UCA-")
+    assert got == expect
 
 
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
 @pytest.mark.parametrize("moltype", ("dna", "protein"))
-def test_sequence_collection_to_moltype_same_moltype(moltype):
+def test_sequence_collection_to_moltype_same_moltype(moltype, mk_cls):
     data = dict(s1="ACGTT", s2="ACCTT")
-    seqs = new_alignment.make_unaligned_seqs(data, moltype=moltype)
+    seqs = mk_cls(data, moltype=moltype)
     got = seqs.to_moltype(moltype=moltype)
     assert got is seqs
 
 
-def test_sequence_collection_to_moltype_with_gaps():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_to_moltype_with_gaps(mk_cls):
     """correctly convert to specified moltype"""
     data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="text")
+    seqs = mk_cls(data, moltype="text")
     dna_seqs = seqs.to_moltype("dna")
     assert dna_seqs.moltype.label == "dna"
     assert dna_seqs.to_dict() == data
@@ -1906,24 +2048,30 @@ def test_sequence_collection_to_moltype_with_gaps():
     assert rna_seqs.moltype.label == "rna"
 
     # but not from text to rna directly
-    with pytest.raises(ValueError):
+    with pytest.raises(new_moltype.MolTypeError):
         seqs.to_moltype("rna")
 
 
-def test_sequence_collection_to_moltype_info():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_to_moltype_info(mk_cls):
     """correctly convert to specified moltype"""
     data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
-    seqs = new_alignment.make_unaligned_seqs(
-        data, moltype="text", info={"key": "value"}
-    )
+    seqs = mk_cls(data, moltype="text", info={"key": "value"})
     dna = seqs.to_moltype("dna")
     assert dna.info["key"] == "value"
 
 
-def test_sequence_collection_to_moltype_annotation_db():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_to_moltype_annotation_db(mk_cls):
     """correctly convert to specified moltype"""
     data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="text")
+    seqs = mk_cls(data, moltype="text")
     db = GffAnnotationDb()
     db.add_feature(seqid="seq1", biotype="exon", name="annotation1", spans=[(3, 8)])
     db.add_feature(seqid="seq2", biotype="exon", name="annotation2", spans=[(1, 2)])
@@ -1933,10 +2081,14 @@ def test_sequence_collection_to_moltype_annotation_db():
     assert len(dna.annotation_db) == 3
 
 
-def test_sequence_collection_to_dna():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_to_dna(mk_cls):
     """correctly convert to dna"""
     data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="text")
+    seqs = mk_cls(data, moltype="text")
 
     # convert from text to dna
     dna_seqs = seqs.to_dna()
@@ -1945,7 +2097,7 @@ def test_sequence_collection_to_dna():
 
     # convert from rna to dna
     data = {"seq1": "ACGUACGUA", "seq2": "ACCGAA---", "seq3": "ACGUACGUU"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="RNA")
+    seqs = mk_cls(data, moltype="RNA")
     dna_seqs = seqs.to_dna()
     assert dna_seqs.moltype.label == "dna"
     assert dna_seqs.to_dict() == {
@@ -1953,9 +2105,13 @@ def test_sequence_collection_to_dna():
     }
 
 
-def test_sequence_collection_to_rna():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_to_rna(mk_cls):
     data = {"seq1": "ACGUACGUA", "seq2": "ACCGAA---", "seq3": "ACGUACGUU"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="text")
+    seqs = mk_cls(data, moltype="text")
 
     # convert from text to rna
     rna_seqs = seqs.to_rna()
@@ -1964,7 +2120,7 @@ def test_sequence_collection_to_rna():
 
     # convert from dna to rna
     data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seqs = mk_cls(data, moltype="dna")
     rna_seqs = seqs.to_rna()
     assert rna_seqs.moltype.label == "rna"
     assert rna_seqs.to_dict() == {
@@ -2037,12 +2193,12 @@ def test_sequence_collection_write(collection_maker, tmp_path):
 
 
 @pytest.mark.parametrize(
-    "collection_maker",
+    "mk_cls",
     [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
 )
-def test_sequence_collection_write_gapped(collection_maker, tmp_path):
+def test_sequence_collection_write_gapped(mk_cls, tmp_path):
     data = {"a": "AAA--", "b": "TTTTT", "c": "CCCCC"}
-    seqs = collection_maker(data, moltype="dna")
+    seqs = mk_cls(data, moltype="dna")
     fn = tmp_path / "seqs.fasta"
     seqs.write(fn)
     with open(fn, newline=None) as infile:
@@ -2050,18 +2206,23 @@ def test_sequence_collection_write_gapped(collection_maker, tmp_path):
     assert result == ">a\nAAA--\n>b\nTTTTT\n>c\nCCCCC\n"
 
 
-def test_get_ambiguous_positions():
-    """SequenceCollection.get_ambiguous_positions should return pos"""
-    aln = new_alignment.make_unaligned_seqs(
-        {"s1": "ATGRY?", "s2": "T-AG??"}, moltype="dna"
-    )
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_get_ambiguous_positions(mk_cls):
+    aln = mk_cls({"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():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_consistent_gap_degen_handling(mk_cls):
     """gap degen character should be treated consistently"""
     # the degen character '?' can be a gap, so when we strip gaps it should
     # be gone too
@@ -2070,10 +2231,8 @@ def test_sequence_collection_consistent_gap_degen_handling():
     re.sub("[N?]+", "", raw_seq)
     dna = new_moltype.DNA.make_seq(seq=raw_seq)
 
-    aln = new_alignment.make_unaligned_seqs({"a": dna, "b": dna}, moltype="dna")
-    expect = new_alignment.make_unaligned_seqs(
-        {"a": raw_ungapped, "b": raw_ungapped}, moltype="dna"
-    ).to_fasta()
+    aln = mk_cls({"a": dna, "b": dna}, moltype="dna")
+    expect = mk_cls({"a": raw_ungapped, "b": raw_ungapped}, moltype="dna").to_fasta()
     assert aln.degap().to_fasta() == expect
 
 
@@ -2127,10 +2286,14 @@ def test_sequence_collection_strand_symmetry():
     assert numpy.allclose(result["seq2"].observed.array, [[3, 0], [2, 1]])
 
 
-def test_sequence_collection_rename_seqs():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_rename_seqs(mk_cls):
     """successfully rename sequences"""
     data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seqs = mk_cls(data, moltype="dna")
     new = seqs.rename_seqs(lambda x: x.upper())
     expect = {n.upper() for n in data}
     assert set(new.names) == expect
@@ -2138,10 +2301,14 @@ def test_sequence_collection_rename_seqs():
     assert set(new._seqs_data.names) == set(data)
 
 
-def test_sequence_collection_subsequent_rename():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_subsequent_rename(mk_cls):
     """sequences can be renamed multiple times"""
     data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seqs = mk_cls(data, moltype="dna")
     new = seqs.rename_seqs(lambda x: x.upper())
     new_again = new.rename_seqs(lambda x: f"{x[0]}{x[-1]}")
     expect = {"S1", "S2", "S3"}
@@ -2150,14 +2317,28 @@ def test_sequence_collection_subsequent_rename():
     assert set(new._seqs_data.names) == set(data)
 
 
-def test_sequence_collection_rename_non_unique_fails():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_rename_non_unique_fails(mk_cls):
     """renaming to non-unique names should raise an error"""
     data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seqs = mk_cls(data, moltype="dna")
     with pytest.raises(ValueError):
         _ = seqs.rename_seqs(lambda x: x[:1])
 
 
+def test_alignment_rename_sliced():
+    """a sliced alignment that is renmed will retain slice information"""
+    data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    sliced = aln[:3]
+    renamed = sliced.rename_seqs(lambda x: x.upper())
+    expect = {n.upper(): seq[:3] for n, seq in data.items()}
+    assert renamed.to_dict() == expect
+
+
 def test_sequence_collection_apply_pssm():
     """should successfully produce pssm scores"""
     from cogent3.parse import jaspar