Repository: cogent3/cogent3 (language: python)

Issue:
DEV: views and seqs_data do not support serialisation
## Summary by Sourcery

Remove support for SeqView serialization and simplify the serialization process by directly using sequence strings. Update tests to reflect the removal of SeqView serialization.

Enhancements:
- Simplify the serialization process by removing the use of SeqView in the to_rich_dict method.

Tests:
- Remove tests related to SeqView serialization, as the feature is no longer supported.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml
index 5036fd424..4d85a7d54 100644
--- a/.github/workflows/linters.yml
+++ b/.github/workflows/linters.yml
@@ -18,7 +18,7 @@ jobs:
       - name: Set up Python
         uses: actions/setup-python@v5
         with:
-          python-version: '3.11'
+          python-version: '3.12'
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
diff --git a/.sourcery.yaml b/.sourcery.yaml
index accc95a5a..7dbe14d7c 100644
--- a/.sourcery.yaml
+++ b/.sourcery.yaml
@@ -1,2 +1,2 @@
 rule_settings:
-  python_version: '3.9'
\ No newline at end of file
+  python_version: '3.10'
\ No newline at end of file
diff --git a/changelog.d/20241121_100849_Gavin.Huttley.md b/changelog.d/20241121_100849_Gavin.Huttley.md
new file mode 100644
index 000000000..a615940a5
--- /dev/null
+++ b/changelog.d/20241121_100849_Gavin.Huttley.md
@@ -0,0 +1,41 @@
+<!--
+A new scriv changelog fragment.
+
+Uncomment the section that is right (remove the HTML comment wrapper).
+-->
+
+<!--
+### Contributors
+
+- A bullet item for the Contributors category.
+
+-->
+<!--
+### ENH
+
+- A bullet item for the ENH category.
+
+-->
+<!--
+### BUG
+
+- A bullet item for the BUG category.
+
+-->
+<!--
+### DOC
+
+- A bullet item for the DOC category.
+
+-->
+<!--
+### Deprecations
+
+- A bullet item for the Deprecations category.
+
+-->
+### Discontinued
+
+- We no longer support python 3.9.
+
+-->
diff --git a/changelog.md b/changelog.md
index 6994f36fa..79813052a 100755
--- a/changelog.md
+++ b/changelog.md
@@ -19,7 +19,7 @@ A single bugfix release.
 
 ## BUG
 
-- Fixed a regression in Tree's arising from new name / support split functionality.
+- Fixed a regression in Trees arising from new name / support split functionality.
 
 <a id='changelog-2024.7.19a7'></a>
 # Changes in release "2024.7.19a7"
diff --git a/doc/examples/nonstationary_inference.rst b/doc/examples/nonstationary_inference.rst
index 2c1b09856..2aa1b48c6 100644
--- a/doc/examples/nonstationary_inference.rst
+++ b/doc/examples/nonstationary_inference.rst
@@ -7,8 +7,8 @@
 .. _nonstationary-model-aa-inference:
 
 
-Infering Amino Acid frequencies with a nonstationary model
-==========================================================
+Inferring Amino Acid frequencies with a nonstationary model
+===========================================================
 
 .. sectionauthor:: Peter Goodman, Andrew Wheeler
 
diff --git a/pyproject.toml b/pyproject.toml
index f1147851b..cd26af737 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,7 +16,6 @@ license = { file = "LICENSE" }
 dependencies = ["chardet",
         "loky",
         "numpy",
-        "numba>0.53; python_version>='3.9' and python_version <'3.12'",
         "numba>0.54; python_version>='3.10' and python_version <'3.12'",
         "numba>=0.57.0; python_version=='3.11'",
         "numba>=0.59.0; python_version=='3.12'",
@@ -26,7 +25,7 @@ dependencies = ["chardet",
         "tqdm",
         "typing_extensions"]
 # remember to update version in requires-python and classifiers
-requires-python = ">=3.9,<3.13"
+requires-python = ">=3.10,<3.13"
 classifiers = [
         "Development Status :: 5 - Production/Stable",
         "Intended Audience :: Science/Research",
@@ -34,7 +33,6 @@ classifiers = [
         "Topic :: Scientific/Engineering :: Bio-Informatics",
         "Topic :: Software Development :: Libraries :: Python Modules",
         "Operating System :: OS Independent",
-        "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",
         "Programming Language :: Python :: 3.12",
diff --git a/src/cogent3/__init__.py b/src/cogent3/__init__.py
index 4acd6580c..d55f3ec7e 100644
--- a/src/cogent3/__init__.py
+++ b/src/cogent3/__init__.py
@@ -5,12 +5,11 @@
 import os
 import pathlib
 import pickle
-import sys
 import warnings
 from typing import Callable, Optional, Union
 
 from cogent3._version import __version__
-from cogent3.app import app_help, available_apps, get_app, open_data_store
+from cogent3.app import app_help, available_apps, get_app, open_data_store  # noqa
 from cogent3.core import annotation_db as _anno_db
 from cogent3.core.alignment import (
     Alignment,
@@ -18,21 +17,24 @@
     Sequence,
     SequenceCollection,
 )
-from cogent3.core.genetic_code import available_codes, get_code
+from cogent3.core.genetic_code import available_codes, get_code  # noqa
 
 # note that moltype has to be imported last, because it sets the moltype in
 # the objects created by the other modules.
 from cogent3.core.moltype import (
-    ASCII,
-    DNA,
-    PROTEIN,
-    RNA,
-    available_moltypes,
-    get_moltype,
+    ASCII,  # noqa
+    DNA,  # noqa
+    PROTEIN,  # noqa
+    RNA,  # noqa
+    available_moltypes,  # noqa
+    get_moltype,  # noqa
 )
 from cogent3.core.tree import PhyloNode, TreeBuilder, TreeError, TreeNode
-from cogent3.evolve.fast_distance import available_distances, get_distance_calculator
-from cogent3.evolve.models import available_models, get_model
+from cogent3.evolve.fast_distance import (  # noqa
+    available_distances,
+    get_distance_calculator,
+)
+from cogent3.evolve.models import available_models, get_model  # noqa
 from cogent3.parse.cogent3_json import load_from_json
 from cogent3.parse.newick import parse_string as newick_parse_string
 from cogent3.parse.sequence import get_parser, is_genbank
@@ -48,14 +50,6 @@
 __license__ = "BSD-3"
 
 
-_min_version = (3, 9)
-if (sys.version_info.major, sys.version_info.minor) < _min_version:
-    PY_VERSION = ".".join([str(n) for n in sys.version_info])
-    _min_version = ".".join(str(e) for e in _min_version)
-    raise RuntimeError(
-        f"Python-{_min_version} or greater is required, Python-{PY_VERSION} used."
-    )
-
 version = __version__
 version_info = tuple([int(v) for v in version.split(".") if v.isdigit()])
 
@@ -200,6 +194,7 @@ def make_aligned_seqs(
     label_to_name=None,
     info=None,
     source=None,
+    new_type=False,
     **kw,
 ):
     """Initialize an aligned collection of sequences.
@@ -219,9 +214,29 @@ def make_aligned_seqs(
     source
         origins of this data, defaults to 'unknown'. Converted to a string
         and added to info["source"].
+    new_type
+        if True, the returned Alignment will be of the new type,
+        (cogent3.core.new_sequence.Alignment). The default will be
+        changed to True in 2025.6. Support for the old style will be removed
+        as of 2025.12.
     **kw
         other keyword arguments passed to alignment class
     """
+    if new_type or "COGENT3_NEW_TYPE" in os.environ:
+        if moltype is None:
+            raise ValueError("Argument 'moltype' is required when 'new_type=True'")
+
+        from cogent3.core import new_alignment
+
+        return new_alignment.make_aligned_seqs(
+            data,
+            moltype=moltype,
+            label_to_name=label_to_name,
+            info=info,
+            source=source,
+            **kw,
+        )
+
     klass = ArrayAlignment if array_align else Alignment
     return _make_seq_container(
         klass,
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index ab068dc6a..306747553 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -111,7 +111,7 @@ class DataError(Exception):
 # factory functions for identifying whether character set conditions are satsified
 
 
-def AllowedCharacters(chars, is_array=False, negate=False):
+def AllowedCharacters(chars, is_array=False, negate=False):  # ported
     """factory function for evaluating whether a sequence contains only
     the specified gap characters"""
     try:
@@ -2254,9 +2254,6 @@ class Aligned:
     """One sequence in an alignment, a map between alignment coordinates and
     sequence coordinates"""
 
-    @c3warn.deprecated_args(
-        "2024.9", reason="now requires an IndelMap", discontinued=["length"]
-    )
     def __init__(self, map, data):
         self.map = map
         self.data = data
@@ -2454,7 +2451,9 @@ def to_moltype(self, moltype):
     def remapped_to(self, map):
         return Aligned(map[self.map.inverse()].inverse(), self.data)
 
-    def make_feature(self, feature: FeatureDataType, alignment: "Alignment") -> Feature:
+    def make_feature(
+        self, feature: FeatureDataType, alignment: "Alignment"
+    ) -> Feature:  # ported
         """returns a feature, not written into annotation_db"""
         annot = self.data.make_feature(feature)
         inverted = self.map.to_feature_map().inverse()
@@ -2509,7 +2508,7 @@ class AlignmentI(object):
     default_gap = "-"  # default gap character for padding
     gap_chars = dict.fromkeys("-?")  # default gap chars for comparisons
 
-    def __repr__(self):
+    def __repr__(self):  # ported
         seqs = []
         limit = 10
         delimiter = ""
@@ -2527,7 +2526,7 @@ def __repr__(self):
             f"{len(self.names)} x {self.seq_len} {self.moltype.label} alignment: {seqs}"
         )
 
-    def alignment_quality(self, app_name: str = "ic_score", **kwargs):
+    def alignment_quality(self, app_name: str = "ic_score", **kwargs):  # ported
         """
         Computes the alignment quality using the indicated app
 
@@ -2548,7 +2547,7 @@ def alignment_quality(self, app_name: str = "ic_score", **kwargs):
         app = cogent3.get_app(app_name, **kwargs)
         return app(self)
 
-    def iter_positions(self, pos_order=None):
+    def iter_positions(self, pos_order=None):  # ported
         """Iterates over positions in the alignment, in order.
 
         pos_order refers to a list of indices (ints) specifying the column
@@ -2570,9 +2569,9 @@ def iter_positions(self, pos_order=None):
         for pos in pos_order:
             yield [get(seq)[pos] for seq in seq_order]
 
-    positions = property(iter_positions)
+    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
@@ -2598,7 +2597,7 @@ def take_positions(self, cols, negate=False):
             result, names=self.names, info=self.info, moltype=self.moltype
         )
 
-    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
@@ -2622,11 +2621,11 @@ def new_f(x):
 
         return result
 
-    def take_positions_if(self, f, negate=False):
+    def take_positions_if(self, f, negate=False):  # ported
         """Returns new Alignment containing cols where f(col) is True."""
         return self.take_positions(self.get_position_indices(f, negate=negate))
 
-    def iupac_consensus(self, alphabet=None, allow_gap=True):
+    def iupac_consensus(self, alphabet=None, allow_gap=True):  # ported
         """Returns string containing IUPAC consensus sequence of the alignment."""
         if alphabet is None:
             alphabet = self.moltype
@@ -2639,7 +2638,7 @@ def iupac_consensus(self, alphabet=None, allow_gap=True):
             consensus.append(degen(coerce_to_string(col)))
         return coerce_to_string(consensus)
 
-    def majority_consensus(self):
+    def majority_consensus(self):  # ported
         """Returns list containing most frequent item at each position.
 
         Optional parameter transform gives constructor for type to which result
@@ -2656,7 +2655,7 @@ def majority_consensus(self):
 
     def probs_per_pos(
         self, motif_length=1, include_ambiguity=False, allow_gap=False, warn=False
-    ):
+    ):  # ported
         """returns MotifFreqsArray per position"""
         counts = self.counts_per_pos(
             motif_length=motif_length,
@@ -2668,7 +2667,7 @@ def probs_per_pos(
 
     def entropy_per_pos(
         self, motif_length=1, include_ambiguity=False, allow_gap=False, warn=False
-    ):
+    ):  # ported
         """returns shannon entropy per position"""
         probs = self.probs_per_pos(
             motif_length=motif_length,
@@ -2685,7 +2684,7 @@ def probs_per_seq(
         allow_gap=False,
         exclude_unobserved=False,
         warn=False,
-    ):
+    ):  # ported
         """return MotifFreqsArray per sequence
 
         Parameters
@@ -2720,7 +2719,7 @@ def entropy_per_seq(
         allow_gap=False,
         exclude_unobserved=True,
         warn=False,
-    ):
+    ):  # ported
         """returns the Shannon entropy per sequence
 
         Parameters
@@ -2753,7 +2752,7 @@ def entropy_per_seq(
         )
         return None if probs is None else probs.entropy()
 
-    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
@@ -2781,7 +2780,7 @@ def no_degenerates(self, motif_length=1, allow_gap=False):
         predicate = AllowedCharacters(chars, is_array=False)
         return self.filtered(predicate, motif_length=motif_length)
 
-    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
@@ -2805,7 +2804,7 @@ def omit_gap_pos(self, allowed_gap_frac=1 - eps, motif_length=1):
         )
         return self.filtered(gaps_ok, motif_length=motif_length)
 
-    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
@@ -2826,7 +2825,7 @@ def get_gap_array(self, include_ambiguity=True):
 
         return gapped
 
-    def count_gaps_per_pos(self, include_ambiguity=True):
+    def count_gaps_per_pos(self, include_ambiguity=True):  # ported
         """return counts of gaps per position as a DictArray
 
         Parameters
@@ -2844,7 +2843,7 @@ def count_gaps_per_pos(self, include_ambiguity=True):
 
     def count_gaps_per_seq(
         self, induced_by=False, unique=False, include_ambiguity=True, drawable=False
-    ):
+    ):  # ported
         """return counts of gaps per sequence as a DictArray
 
         Parameters
@@ -2899,7 +2898,7 @@ def count_gaps_per_seq(
 
         return result
 
-    def omit_bad_seqs(self, quantile=None):
+    def omit_bad_seqs(self, quantile=None):  #  ported
         """Returns new alignment without sequences with a number of uniquely
         introduced gaps exceeding quantile
 
@@ -2919,7 +2918,7 @@ def omit_bad_seqs(self, quantile=None):
         names = [name for name, count in gap_counts.items() if count <= cutoff]
         return self.take_seqs(names)
 
-    def matching_ref(self, ref_name, gap_fraction, gap_run):
+    def matching_ref(self, ref_name, gap_fraction, gap_run):  # ported
         """Returns new alignment with seqs well aligned with a reference.
 
         gap_fraction = fraction of positions that either have a gap in the
@@ -2938,7 +2937,7 @@ def sample(
         motif_length=1,
         randint=randint,
         permutation=permutation,
-    ):
+    ):  # ported
         """Returns random sample of positions from self, e.g. to bootstrap.
 
         Parameters
@@ -2983,7 +2982,7 @@ def sample(
 
         return self.__class__(new_seqs, info=self.info, moltype=self.moltype)
 
-    def sliding_windows(self, window, step, start=None, end=None):
+    def sliding_windows(self, window, step, start=None, end=None):  # ported
         """Generator yielding new alignments of given length and interval.
 
         Parameters
@@ -3005,7 +3004,7 @@ def sliding_windows(self, window, step, start=None, end=None):
             for pos in range(start, end, step):
                 yield self[pos : pos + window]
 
-    def _get_raw_pretty(self, name_order):
+    def _get_raw_pretty(self, name_order):  # ported
         """returns dict {name: seq, ...} for pretty print"""
         if name_order is not None:
             assert set(name_order) <= set(self.names), "names don't match"
@@ -3030,7 +3029,7 @@ def _get_raw_pretty(self, name_order):
 
         return names, output
 
-    def _repr_html_(self) -> str:
+    def _repr_html_(self) -> str:  # ported
         settings = self._repr_policy.copy()
         env_vals = get_setting_from_environ(
             "COGENT3_ALIGNMENT_REPR_POLICY",
@@ -3053,7 +3052,7 @@ def to_html(
         colors: Optional[Mapping[str, str]] = None,
         font_size: int = 12,
         font_family: str = "Lucida Console",
-    ) -> str:
+    ) -> str:  # ported
         """returns html with embedded styles for sequence colouring
 
         Parameters
@@ -3217,7 +3216,7 @@ def to_html(
         ]
         return "\n".join(text)
 
-    def to_pretty(self, name_order=None, wrap=None):
+    def to_pretty(self, name_order=None, wrap=None):  # ported
         """returns a string representation of the alignment in pretty print format
 
         Parameters
@@ -3259,7 +3258,7 @@ def make_line(label, seq):
 
     def counts_per_pos(
         self, motif_length=1, include_ambiguity=False, allow_gap=False, warn=False
-    ):
+    ):  # ported
         """return DictArray of counts per position
 
         Parameters
@@ -3311,7 +3310,7 @@ def counts_per_seq(
         allow_gap=False,
         exclude_unobserved=False,
         warn=False,
-    ):
+    ):  # ported
         """counts of non-overlapping motifs per sequence
 
         Parameters
@@ -3361,7 +3360,7 @@ def counts_per_seq(
             counts[i] = c.tolist(motifs)
         return MotifCountsArray(counts, motifs, row_indices=self.names)
 
-    def variable_positions(self, include_gap_motif=True):
+    def variable_positions(self, include_gap_motif=True):  # ported
         """Return a list of variable position indexes.
 
         Parameters
@@ -3381,7 +3380,7 @@ def variable_positions(self, include_gap_motif=True):
 
         return result
 
-    def to_type(self, array_align=False, moltype=None, alphabet=None):
+    def to_type(self, array_align=False, moltype=None, alphabet=None):  # will not port
         """returns alignment of type indicated by array_align
 
         Parameters
@@ -3413,7 +3412,9 @@ def to_type(self, array_align=False, moltype=None, alphabet=None):
                 moltype = self.moltype
         return klass(data=data, moltype=moltype, info=self.info, names=self.names)
 
-    def distance_matrix(self, calc="pdist", show_progress=False, drop_invalid=False):
+    def distance_matrix(
+        self, calc="pdist", show_progress=False, drop_invalid=False
+    ):  # ported
         """Returns pairwise distances between sequences.
 
         Parameters
@@ -3455,7 +3456,7 @@ def quick_tree(
         drop_invalid=False,
         show_progress=False,
         ui=None,
-    ):
+    ):  # ported
         """
         bootstrap : int or None
             Number of non-parametric bootstrap replicates. Resamples alignment
@@ -3516,7 +3517,7 @@ def quick_tree(
 
     def information_plot(
         self, width=None, height=None, window=None, stat="median", include_gap=True
-    ):
+    ):  # ported
         """plot information per position
 
         Parameters
@@ -3636,7 +3637,7 @@ def coevolution(
         show_progress=False,
         parallel=False,
         par_kw=None,
-    ):
+    ):  # ported
         """performs pairwise coevolution measurement
 
         Parameters
@@ -3743,7 +3744,9 @@ def coevolution(
 
         return result
 
-    def seqlogo(self, width=700, height=100, wrap=None, vspace=0.005, colours=None):
+    def seqlogo(
+        self, width=700, height=100, wrap=None, vspace=0.005, colours=None
+    ):  # ported
         """returns Drawable sequence logo using mutual information
 
         Parameters
@@ -3774,7 +3777,9 @@ def seqlogo(self, width=700, height=100, wrap=None, vspace=0.005, colours=None):
         )
 
     @extend_docstring_from(_SequenceCollectionBase.trim_stop_codons)
-    def trim_stop_codons(self, gc: Any = None, strict: bool = False, **kwargs):
+    def trim_stop_codons(
+        self, gc: Any = None, strict: bool = False, **kwargs
+    ):  # ported
         if not self.has_terminal_stop(gc=gc, strict=strict):
             return self
 
@@ -3803,7 +3808,7 @@ def trim_stop_codons(self, gc: Any = None, strict: bool = False, **kwargs):
     @extend_docstring_from(_SequenceCollectionBase.get_translation)
     def get_translation(
         self, gc=None, incomplete_ok=False, include_stop=False, trim_stop=True, **kwargs
-    ):
+    ):  # ported
         if len(self.moltype.alphabet) != 4:
             raise TypeError("Must be a DNA/RNA")
 
@@ -3912,13 +3917,13 @@ def _set_additional_attributes(self, curr_seqs):
         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:
@@ -3929,7 +3934,7 @@ def named_seqs(self):
             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
@@ -3941,7 +3946,7 @@ def __iter__(self):
         """
         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)
@@ -3965,7 +3970,7 @@ def _coerce_seqs(self, seqs):
 
     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
@@ -4010,7 +4015,7 @@ def get_sub_alignment(
             moltype=self.moltype,
         )
 
-    def __str__(self):
+    def __str__(self):  # ported
         """Returns FASTA-format string.
 
         Should be able to handle joint alphabets, e.g. codons.
@@ -4044,7 +4049,7 @@ def sample(
         motif_length=1,
         randint=randint,
         permutation=permutation,
-    ):
+    ):  # ported
         """Returns random sample of positions from self, e.g. to bootstrap.
 
         Parameters
@@ -4089,7 +4094,9 @@ def sample(
             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
@@ -4136,7 +4143,7 @@ def filtered(self, predicate, motif_length=1, drop_remainder=True, **kwargs):
             names=self.names,
         )
 
-    def get_gapped_seq(self, seq_name, recode_gaps=False):
+    def get_gapped_seq(self, seq_name, recode_gaps=False):  # ported
         """Return a gapped Sequence object for the specified seqname.
 
         Note: return type may depend on what data was loaded into the
@@ -4150,7 +4157,7 @@ def get_gapped_seq(self, seq_name, recode_gaps=False):
                 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.
@@ -4174,7 +4181,9 @@ def get_degapped_relative_to(self, name):
             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
@@ -4291,11 +4300,11 @@ def replace_seqs(self, seqs, aa_to_codon=True):
             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
@@ -4375,7 +4384,7 @@ def deepcopy(self, sliced=True):
         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
@@ -4414,7 +4423,7 @@ def counts_per_seq(
         allow_gap=False,
         exclude_unobserved=False,
         warn=False,
-    ):
+    ):  # ported
         """counts of non-overlapping motifs per sequence
 
         Parameters
@@ -4464,7 +4473,7 @@ def counts_per_seq(
             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
@@ -4495,7 +4504,7 @@ def omit_gap_pos(self, allowed_gap_frac=1 - eps, motif_length=1):
         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
@@ -4522,7 +4531,7 @@ def new_f(x):
 
         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
@@ -4544,7 +4553,7 @@ def get_gap_array(self, include_ambiguity=True):
         return gapped
 
 
-def make_gap_filter(template, gap_fraction, gap_run):
+def make_gap_filter(template, gap_fraction, gap_run):  # ported
     """Returns f(seq) -> True if no gap runs and acceptable gap fraction.
 
     Calculations relative to template.
@@ -4660,7 +4669,7 @@ def _mapped(self, slicemap):
             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 = []
@@ -4669,7 +4678,9 @@ def gapped_by_map(self, keep, **kwargs):
             seqs.append(selected)
         return self.__class__(moltype=self.moltype, data=seqs, **kwargs)
 
-    def get_projected_feature(self, *, seqid: str, feature: Feature) -> Feature:
+    def get_projected_feature(
+        self, *, seqid: str, feature: Feature
+    ) -> Feature:  # ported
         """returns an alignment feature projected onto the seqid sequence
 
         Parameters
@@ -4702,7 +4713,9 @@ def get_projected_feature(self, *, seqid: str, feature: Feature) -> Feature:
         self.annotation_db.add_feature(**feature.to_dict())
         return result
 
-    def get_projected_features(self, *, seqid: str, **kwargs) -> list[Feature]:
+    def get_projected_features(
+        self, *, seqid: str, **kwargs
+    ) -> list[Feature]:  # ported
         """projects all features from other sequences onto seqid"""
         # todo gah should there be a generator version,
         # iter_projected_features()?
@@ -4741,7 +4754,9 @@ def with_masked_annotations(self, biotypes, mask_char=None, shadow=False):
         return new
 
     @extend_docstring_from(ArrayAlignment.filtered)
-    def filtered(self, predicate, motif_length=1, drop_remainder=True, **kwargs):
+    def filtered(
+        self, predicate, motif_length=1, drop_remainder=True, **kwargs
+    ):  # ported
         length = self.seq_len
         if length % motif_length != 0 and not drop_remainder:
             raise ValueError(
@@ -4773,21 +4788,21 @@ def filtered(self, predicate, motif_length=1, drop_remainder=True, **kwargs):
 
         return self.gapped_by_map(keep, info=self.info)
 
-    def get_seq(self, seqname):
+    def get_seq(self, seqname):  # ported
         """Return a ungapped Sequence object for the specified seqname.
 
         Note: always returns Sequence object, not ArraySequence.
         """
         return self.named_seqs[seqname].data
 
-    def get_gapped_seq(self, seq_name, recode_gaps=False):
+    def get_gapped_seq(self, seq_name, recode_gaps=False):  # ported
         """Return a gapped Sequence object for the specified seqname.
 
         Note: always returns Sequence object, not ArraySequence.
         """
         return self.named_seqs[seq_name].get_gapped_seq(recode_gaps)
 
-    def iter_positions(self, pos_order=None):
+    def iter_positions(self, pos_order=None):  # ported
         """Iterates over positions in the alignment, in order.
 
         pos_order refers to a list of indices (ints) specifying the column
@@ -4944,7 +4959,7 @@ def add_from_ref_aln(self, ref_aln, before_name=None, after_name=None):
 
         return self.add_seqs(temp_aln, before_name, after_name)
 
-    def replace_seqs(self, seqs, aa_to_codon=True):
+    def replace_seqs(self, seqs, aa_to_codon=True):  # will not port
         """Returns new alignment with same shape but with data taken from seqs.
 
         Parameters
@@ -4984,7 +4999,9 @@ def replace_seqs(self, seqs, aa_to_codon=True):
 
         return self.__class__(new_seqs, info=self.info, moltype=self.moltype)
 
-    def get_drawables(self, *, biotype: Optional[str, Iterable[str]] = None) -> dict:
+    def get_drawables(
+        self, *, biotype: Optional[str, Iterable[str]] = None
+    ) -> dict:  # ported
         """returns a dict of drawables, keyed by type
 
         Parameters
@@ -5004,7 +5021,7 @@ def get_drawable(
         biotype: Optional[str, Iterable[str]] = None,
         width: int = 600,
         vertical: int = False,
-    ):
+    ):  # ported
         """make a figure from sequence features
 
         Parameters
@@ -5073,7 +5090,7 @@ def add_feature(
         parent_id: Optional[str] = None,
         strand: str = "+",
         on_alignment: Optional[bool] = None,
-    ) -> Feature:
+    ) -> Feature:  # ported
         """
         add feature on named sequence, or on the alignment itself
 
@@ -5131,7 +5148,7 @@ def make_feature(
         *,
         feature: FeatureDataType,
         on_alignment: Optional[bool] = None,
-    ) -> Feature:
+    ) -> Feature:  # ported
         """
         create a feature on named sequence, or on the alignment itself
 
@@ -5180,7 +5197,7 @@ def _get_seq_features(
         biotype: Optional[str] = None,
         name: Optional[str] = None,
         allow_partial: bool = False,
-    ) -> Iterator[Feature]:
+    ) -> Iterator[Feature]:  # ported
         """yields Feature instances
 
         Parameters
@@ -5250,7 +5267,7 @@ def get_features(
         name: Optional[str] = None,
         on_alignment: Optional[bool] = None,
         allow_partial: bool = False,
-    ) -> Iterator[Feature]:
+    ) -> Iterator[Feature]:  # ported
         """yields Feature instances
 
         Parameters
diff --git a/src/cogent3/core/annotation.py b/src/cogent3/core/annotation.py
index 254546970..f74ebf194 100644
--- a/src/cogent3/core/annotation.py
+++ b/src/cogent3/core/annotation.py
@@ -159,7 +159,7 @@ def remapped_to(self, grandparent, gmap):
             # due to separation of IndelMap and Map, change class
             gmap = gmap.to_feature_map()
 
-        seqid = grandparent.name or f"from {self.seqid!r}"
+        seqid = getattr(grandparent, "name", None) or f"from {self.seqid!r}"
         kwargs = {
             **self._serialisable,
             **{"map": gmap[self.map], "parent": grandparent, "seqid": seqid},
diff --git a/src/cogent3/core/annotation_db.py b/src/cogent3/core/annotation_db.py
index f4b1f0ecc..b8cb4eb1d 100644
--- a/src/cogent3/core/annotation_db.py
+++ b/src/cogent3/core/annotation_db.py
@@ -106,11 +106,6 @@ def from_dict(self, data): ...
 @typing.runtime_checkable
 class SupportsQueryFeatures(typing.Protocol):  # pragma: no cover
     # should be defined centrally
-    @c3warn.deprecated_args(
-        "2024.9",
-        reason="replace usage of SQL keyword as column name",
-        old_new=[("end", "stop")],
-    )
     def get_features_matching(
         self,
         *,
@@ -124,11 +119,6 @@ def get_features_matching(
         on_alignment: OptionalBool = None,
     ) -> typing.Iterator[FeatureDataType]: ...
 
-    @c3warn.deprecated_args(
-        "2024.9",
-        reason="replace usage of SQL keyword as column name",
-        old_new=[("end", "stop")],
-    )
     def get_feature_children(
         self,
         *,
@@ -138,11 +128,6 @@ def get_feature_children(
         **kwargs,
     ) -> typing.List[FeatureDataType]: ...
 
-    @c3warn.deprecated_args(
-        "2024.9",
-        reason="replace usage of SQL keyword as column name",
-        old_new=[("end", "stop")],
-    )
     def get_feature_parent(
         self,
         *,
@@ -163,11 +148,6 @@ def num_matches(
         on_alignment: OptionalBool = None,
     ) -> int: ...
 
-    @c3warn.deprecated_args(
-        "2024.9",
-        reason="replace usage of SQL keyword as column name",
-        old_new=[("end", "stop")],
-    )
     def subset(
         self,
         *,
@@ -806,11 +786,6 @@ def get_feature_parent(
                         )  # remove invalid field for the FeatureDataType
                         yield parent
 
-    @c3warn.deprecated_args(
-        "2024.9",
-        reason="replace usage of SQL keyword as column name",
-        old_new=[("end", "stop")],
-    )
     def get_records_matching(
         self,
         *,
@@ -835,11 +810,6 @@ def get_records_matching(
             for result in self._get_records_matching(table_name, **kwargs):
                 yield dict(zip(result.keys(), result))
 
-    @c3warn.deprecated_args(
-        "2024.9",
-        reason="replace usage of SQL keyword as column name",
-        old_new=[("end", "stop")],
-    )
     def get_features_matching(
         self,
         *,
@@ -1133,11 +1103,6 @@ def write(self, path: PathType) -> None:
             self.db.backup(backup)
         backup.close()
 
-    @c3warn.deprecated_args(
-        "2024.9",
-        reason="replace usage of SQL keyword as column name",
-        old_new=[("end", "stop")],
-    )
     def subset(
         self,
         *,
@@ -1469,11 +1434,6 @@ def _make_fake_id(self, record: dict) -> typing.List[str]:
         self._num_fakeids += 1
         return name
 
-    @c3warn.deprecated_args(
-        "2024.9",
-        reason="replace usage of SQL keyword as column name",
-        old_new=[("end", "stop")],
-    )
     def get_feature_children(
         self,
         name: str,
@@ -1522,11 +1482,6 @@ def get_feature_children(
                 feat.pop("parent_id")  # remove invalid field for the FeatureDataType
                 yield feat
 
-    @c3warn.deprecated_args(
-        "2024.9",
-        reason="replace usage of SQL keyword as column name",
-        old_new=[("end", "stop")],
-    )
     def get_feature_parent(
         self,
         name: str,
@@ -1602,7 +1557,7 @@ def convert_annotation_to_annotation_db(data: dict) -> SupportsFeatures:
         ann = ann.pop("annotation_construction")
         m = deserialise_map_spans(ann.pop("map"))
         spans = m.get_coordinates()
-        strand = "-" if m.reverse else "+"
+        strand = "-" if any(s.reverse for s in m.spans) else "+"
         biotype = ann.pop("type")
         name = ann.pop("name")
         db.add_feature(
diff --git a/src/cogent3/core/location.py b/src/cogent3/core/location.py
index 70bd1dc31..c81b7660a 100644
--- a/src/cogent3/core/location.py
+++ b/src/cogent3/core/location.py
@@ -8,6 +8,7 @@
 from functools import total_ordering
 from typing import Any, Iterator, Optional, Sequence, Union
 
+import numba
 import numpy
 from numpy.typing import NDArray
 
@@ -20,6 +21,8 @@
 
 _DEFAULT_GAP_DTYPE = numpy.int32
 
+OptInt = Optional[int]
+
 
 def _norm_index(i, length, default):
     """For converting s[:3] to s[0:3], s[-1] to s[len(s)-1] and s[0:lots] to s[0:len(s)]"""
@@ -39,7 +42,7 @@ def as_map(slice, length, cls):
         for i in slice:
             spans.extend(as_map(i, length, cls).spans)
         return cls(spans=spans, parent_length=length)
-    elif isinstance(slice, (FeatureMap, IndelMap, Map)):
+    elif isinstance(slice, (FeatureMap, IndelMap)):
         return slice
     else:
         lo, hi, step = _norm_slice(slice, length)
@@ -500,356 +503,6 @@ def __repr__(self):
 SeqCoordTypes = Sequence[Sequence[IntTypes]]
 
 
-class Map:  # pragma: no cover
-    """A map holds a list of spans."""
-
-    @c3warn.deprecated_callable(
-        version="2024.6",
-        reason="Replaced by IndelMap and FeatureMap",
-        is_discontinued=True,
-    )
-    def __init__(
-        self,
-        locations=None,
-        spans=None,
-        tidy=False,
-        parent_length=None,
-        termini_unknown=False,
-    ):
-        assert parent_length is not None
-        d = locals()
-        exclude = ("self", "__class__", "__slots__")
-        self._serialisable = {k: v for k, v in d.items() if k not in exclude}
-
-        if spans is None:
-            spans = []
-            for start, end in locations:
-                diff = 0
-                reverse = start > end
-                if max(start, end) < 0 or min(start, end) > parent_length:
-                    raise RuntimeError(
-                        f"located outside sequence: {(start, end, parent_length)}"
-                    )
-                if max(start, end) > parent_length and min(start, end) < 0:
-                    l_diff = min(start, end)
-                    r_diff = max(start, end) - parent_length
-                    start, end = (
-                        (0, parent_length) if start < end else (parent_length, 0)
-                    )
-                    spans += [
-                        LostSpan(abs(l_diff)),
-                        Span(start, end, tidy, tidy, reverse=reverse),
-                        LostSpan(abs(r_diff)),
-                    ]
-                elif min(start, end) < 0:
-                    diff = min(start, end)
-                    start = 0 if start < 0 else start
-                    end = 0 if end < 0 else end
-                    spans += [
-                        LostSpan(abs(diff)),
-                        Span(start, end, tidy, tidy, reverse=reverse),
-                    ]
-                elif max(start, end) > parent_length:
-                    diff = max(start, end) - parent_length
-                    start = parent_length if start > parent_length else start
-                    end = parent_length if end > parent_length else end
-                    spans += [
-                        Span(start, end, tidy, tidy, reverse=reverse),
-                        LostSpan(abs(diff)),
-                    ]
-                else:
-                    spans += [Span(start, end, tidy, tidy, reverse=reverse)]
-
-        self.offsets = []
-        self.useful = False
-        self.complete = True
-        self.reverse = None
-        posn = 0
-        for span in spans:
-            self.offsets.append(posn)
-            posn += span.length
-            if span.lost:
-                self.complete = False
-            elif not self.useful:
-                self.useful = True
-                (self.start, self.end) = (span.start, span.end)
-                self.reverse = span.reverse
-            else:
-                self.start = min(self.start, span.start)
-                self.end = max(self.end, span.end)
-                if self.reverse is not None and (span.reverse != self.reverse):
-                    self.reverse = None
-
-        if termini_unknown:
-            spans = list(spans)
-            if spans[0].lost:
-                spans[0] = TerminalPadding(spans[0].length)
-            if spans[-1].lost:
-                spans[-1] = TerminalPadding(spans[-1].length)
-
-        self.spans = tuple(spans)
-        self.length = posn
-        self.parent_length = parent_length
-        self.__inverse = None
-
-    def __len__(self):
-        return self.length
-
-    def __repr__(self):
-        return repr(list(self.spans)) + f"/{self.parent_length}"
-
-    def __getitem__(self, slice):
-        # A possible shorter map at the same level
-        slice = as_map(slice, len(self), self.__class__)
-        new_parts = []
-        for span in slice.spans:
-            new_parts.extend(span.remap_with(self))
-        return Map(spans=new_parts, parent_length=self.parent_length)
-
-    def __mul__(self, scale):
-        # For Protein -> DNA
-        new_parts = []
-        for span in self.spans:
-            new_parts.append(span * scale)
-        return Map(spans=new_parts, parent_length=self.parent_length * scale)
-
-    def __div__(self, scale):
-        # For DNA -> Protein
-        new_parts = []
-        for span in self.spans:
-            new_parts.append(span / scale)
-        return Map(spans=new_parts, parent_length=self.parent_length // scale)
-
-    def __add__(self, other):
-        if other.parent_length != self.parent_length:
-            raise ValueError("Those maps belong to different sequences")
-        return Map(spans=self.spans + other.spans, parent_length=self.parent_length)
-
-    def with_termini_unknown(self):
-        return Map(
-            self,
-            spans=self.spans[:],
-            parent_length=self.parent_length,
-            termini_unknown=True,
-        )
-
-    def get_covering_span(self):
-        if self.reverse:
-            span = (self.end, self.start)
-        else:
-            span = (self.start, self.end)
-        return Map([span], parent_length=self.parent_length)
-
-    def covered(self):
-        """>>> Map([(10,20), (15, 25), (80, 90)]).covered().spans
-        [Span(10,25), Span(80, 90)]"""
-
-        delta = {}
-        for span in self.spans:
-            if span.lost:
-                continue
-            delta[span.start] = delta.get(span.start, 0) + 1
-            delta[span.end] = delta.get(span.end, 0) - 1
-        positions = list(delta.keys())
-        positions.sort()
-        last_y = y = 0
-        last_x = start = None
-        result = []
-        for x in positions:
-            y += delta[x]
-            if x == last_x:
-                continue
-            if y and not last_y:
-                assert start is None
-                start = x
-            elif last_y and not y:
-                result.append((start, x))
-                start = None
-            last_x = x
-            last_y = y
-        assert y == 0
-        return Map(locations=result, parent_length=self.parent_length)
-
-    def reversed(self):
-        """Reversed location on same parent"""
-        spans = [s.reversed() for s in self.spans]
-        spans.reverse()
-        return Map(spans=spans, parent_length=self.parent_length)
-
-    def nucleic_reversed(self):
-        """Same location on reversed parent"""
-        spans = [s.reversed_relative_to(self.parent_length) for s in self.spans]
-        return Map(spans=spans, parent_length=self.parent_length)
-
-    def get_gap_coordinates(self):
-        """returns [(gap pos, gap length), ...]"""
-        gap_pos = []
-        for i, span in enumerate(self.spans):
-            if not span.lost:
-                continue
-
-            pos = self.spans[i - 1].end if i else 0
-            gap_pos.append((pos, len(span)))
-
-        return gap_pos
-
-    def gaps(self):
-        """The gaps (lost spans) in this map"""
-        locations = []
-        offset = 0
-        for s in self.spans:
-            if s.lost:
-                locations.append((offset, offset + s.length))
-            offset += s.length
-        return Map(locations, parent_length=len(self))
-
-    def shadow(self):
-        """The 'negative' map of the spans not included in this map"""
-        return self.inverse().gaps()
-
-    def nongap(self):
-        locations = []
-        offset = 0
-        for s in self.spans:
-            if not s.lost:
-                locations.append((offset, offset + s.length))
-            offset += s.length
-        return Map(locations, parent_length=len(self))
-
-    def without_gaps(self):
-        return Map(
-            spans=[s for s in self.spans if not s.lost],
-            parent_length=self.parent_length,
-        )
-
-    def inverse(self):
-        if self.__inverse is None:
-            self.__inverse = self._inverse()
-        return self.__inverse
-
-    def _inverse(self):
-        # can't work if there are overlaps in the map
-        # tidy ends don't survive inversion
-        if self.parent_length is None:
-            raise ValueError("Uninvertable. parent length not known")
-        posn = 0
-        temp = []
-        for span in self.spans:
-            if not span.lost:
-                if span.reverse:
-                    temp.append((span.start, span.end, posn + span.length, posn))
-                else:
-                    temp.append((span.start, span.end, posn, posn + span.length))
-            posn += span.length
-
-        temp.sort()
-        new_spans = []
-        last_hi = 0
-        for lo, hi, start, end in temp:
-            if lo > last_hi:
-                new_spans.append(LostSpan(lo - last_hi))
-            elif lo < last_hi:
-                raise ValueError(f"Uninvertable. Overlap: {lo} < {last_hi}")
-            new_spans.append(Span(start, end, reverse=start > end))
-            last_hi = hi
-        if self.parent_length > last_hi:
-            new_spans.append(LostSpan(self.parent_length - last_hi))
-        return Map(spans=new_spans, parent_length=len(self))
-
-    def get_coordinates(self):
-        """returns span coordinates as [(v1, v2), ...]
-
-        v1/v2 are (start, end) unless the map is reversed, in which case it will
-        be (end, start)"""
-
-        if self.reverse:
-            order_func = lambda x: (max(x), min(x))
-        else:
-            order_func = lambda x: x
-
-        coords = list(
-            map(order_func, [(s.start, s.end) for s in self.spans if not s.lost])
-        )
-
-        return coords
-
-    def to_rich_dict(self):
-        """returns dicts for contained spans [dict(), ..]"""
-        spans = [s.to_rich_dict() for s in self.spans]
-        data = copy.deepcopy(self._serialisable)
-        data.pop("locations", None)
-        data["spans"] = spans
-        data["type"] = get_object_provenance(self)
-        data["version"] = __version__
-        return data
-
-    def zeroed(self):
-        """returns a new instance with the first span starting at 0
-
-        Note
-        ----
-
-        Useful when an Annotatable object is sliced, but the connection to
-        the original parent is being deliberately broken as in the
-        Sequence.deepcopy(sliced=True) case.
-        """
-        # todo there's probably a more efficient way to do this
-        # create the new instance
-        from cogent3.util.deserialise import deserialise_map_spans
-
-        data = self.to_rich_dict()
-        zeroed = deserialise_map_spans(data)
-        zeroed.parent_length = len(zeroed.get_covering_span())
-        shift = min(zeroed.start, zeroed.end)
-        new_end = 0
-        for span in zeroed.spans:
-            if span.lost:
-                continue
-            span.start -= shift
-            span.end -= shift
-            new_end = max(new_end, span.end)
-
-        zeroed.start = 0
-        zeroed.end = new_end
-
-        return zeroed
-
-    T = Union[numpy.ndarray, int]
-
-    def absolute_position(self, rel_pos: T) -> T:
-        """converts rel_pos into an absolute position
-
-        Raises
-        ------
-        raises ValueError if rel_pos < 0
-        """
-        check = (
-            numpy.array([rel_pos], dtype=int) if isinstance(rel_pos, int) else rel_pos
-        )
-        if check.min() < 0:
-            raise ValueError(f"must positive, not {rel_pos=}")
-
-        if len(self) == self.parent_length:
-            # handle case of reversed here?
-            return rel_pos
-
-        return self.start + rel_pos
-
-    def relative_position(self, abs_pos: T) -> T:
-        """converts abs_pos into an relative position
-
-        Raises
-        ------
-        raises ValueError if abs_pos < 0
-        """
-        check = (
-            numpy.array([abs_pos], dtype=int) if isinstance(abs_pos, int) else abs_pos
-        )
-        if check.min() < 0:
-            raise ValueError(f"must positive, not {abs_pos=}")
-        return abs_pos - self.start
-
-
 class MapABC(ABC):
     """base class for genomic map objects"""
 
@@ -960,13 +613,14 @@ def spans_to_gap_coords(
     return numpy.array(gap_pos, dtype=dtype), numpy.array(cum_lengths, dtype=dtype)
 
 
+@numba.jit
 def _gap_spans(
     gap_pos: IntArrayTypes, cum_gap_lengths: IntArrayTypes
-) -> tuple[IntArrayTypes, IntArrayTypes]:
+) -> tuple[IntArrayTypes, IntArrayTypes]:  # pragma: no cover
     """returns 1D arrays in alignment coordinates of
     gap start, gap stop"""
     if not len(gap_pos):
-        r = numpy.array([], dtype=_DEFAULT_GAP_DTYPE)
+        r = numpy.empty((0,), dtype=gap_pos.dtype)
         return r, r
 
     ends = gap_pos + cum_gap_lengths
@@ -995,6 +649,210 @@ def _update_lengths(
     result_lengths[result_indices] += gap_lengths[other_indices]
 
 
+def _step_adjustment(gap_start: int, start: int, step: int) -> int:
+    """adjustment to a start, given a step size. The adjustment determines how
+    many more steps we need to take from the beginning of a gap/ungapped segment
+    to reach a position that is a multiple of the step"""
+    overstep = (gap_start - start) % step
+    return step - overstep if overstep else 0
+
+
+def _step_adjusted_length(start: int, end: int, adj: int, step: int) -> int:
+    """returns the adjusted length of a segment given a step size
+    and its start and stop index. The adjustment is the number of steps
+    needed to reach the next multiple of the step size"""
+    adjusted_length = -((end - start - adj) // -step)
+    return max(adjusted_length, 0)
+
+
+def _input_vals_pos_step(
+    seqlen: int, start: OptInt, stop: OptInt, step: int
+) -> tuple[int, int, int]:
+    """returns standardised start, stop, step values for positive step slicing."""
+    # The input values for start and stop can be +ve, -ve or None. The returned
+    # start and stop are strictly +ve and within the sequence length, and if not
+    # provided default to 0 and seqlen, respectively.
+
+    start = start or 0
+    if start > 0 and start >= seqlen:
+        # start beyond seq is an empty slice
+        return 0, 0, 1
+
+    stop = seqlen if stop is None else stop
+    if stop < 0 and abs(stop) >= seqlen:
+        # finished slice before we started seq!
+        return 0, 0, 1
+
+    start = max(seqlen + start, 0) if start < 0 else start
+
+    if stop > 0:
+        stop = min(seqlen, stop)
+    elif stop < 0:
+        stop += seqlen
+
+    if start >= stop:
+        start = stop = 0
+        step = 1
+
+    return start, stop, step
+
+
+def _input_vals_neg_step(
+    seqlen: int, start: OptInt, stop: OptInt, step: int
+) -> tuple[int, int, int]:
+    """returns standardised start, stop, step values for negative step slicing."""
+    # The input values for start and stop can be positive, negative or None. The
+    # returned start and stop are strictly negative and correspond to indices
+    # within the sequence length. If not provided, start defaults to -1 and stop
+    # to -seqlen-1.
+
+    # Note how Python reverse slicing works
+    # we need to make sure the start and stop are both negative, for example
+    # "abcd"[-1:-5:-1] returns "dcba". returning the complete string in reverse
+    # order is only possible with negative indexing
+
+    if start is None or start >= seqlen:  # set default
+        start = -1  # Done
+    elif start >= 0:  # convert to -ve index
+        start = start - seqlen
+    elif start < -seqlen:  # start is bounded by len(seq)
+        return 0, 0, 1
+
+    if stop is None:  # set default
+        stop = -seqlen - 1
+    elif stop >= 0:
+        stop -= seqlen
+
+    stop = max(stop, -seqlen - 1)  # stop should always be <= len(seq)
+
+    # checking for zero-length slice
+    return (0, 0, 1) if start < stop else (start, stop, step)
+
+
+@numba.jit
+def seq_to_align_index(
+    gap_pos: IntArrayTypes,
+    cum_lengths: IntArrayTypes,
+    parent_length: int,
+    num_gaps: int,
+    seq_index: int,
+    slice_stop: bool = False,
+) -> int:  # pragma: no cover
+    """convert a sequence index into an alignment index
+
+    Parameters
+    ----------
+    gap_pos
+        array of gap positions in sequence coordinates
+    cum_lengths
+        array of cumulative gap lengths
+    parent_length
+        length of parent sequence (i.e. aligned sequence without gaps)
+    num_gaps
+        the number of gaps
+    seq_index
+        coordinate on the sequence, must be < parent_length
+    slice_stop
+        set to True if the index is to be the end of an alignment slice.
+        In that case, and if seq_index is in gap_pos then it returns
+        the first alignment index of the gap run.
+    """
+    # NOTE I explicitly cast all returned values to python int's due to
+    # need for json serialisation, which does not support numpy int classes
+    assert len(gap_pos) == len(cum_lengths) == num_gaps
+    if seq_index < 0:
+        seq_index += parent_length
+
+    if seq_index < 0:
+        raise IndexError(f"{seq_index} negative seq_index beyond limit ")
+
+    if not num_gaps or seq_index < gap_pos[0]:
+        return int(seq_index)
+
+    # if slice_stop, check if the seq_index corresponds to a gap position
+    match = seq_index == gap_pos
+    if slice_stop and match.any():
+        # if so, we return the alignment coord for the first gap position
+        (idx,) = numpy.where(match)[0]
+        if idx:
+            gap_len = cum_lengths[idx] - cum_lengths[idx - 1]
+        else:
+            gap_len = cum_lengths[idx]
+        gap_end = gap_pos[idx] + cum_lengths[idx]
+        return int(gap_end - gap_len)
+
+    if seq_index >= gap_pos[-1]:
+        return int(seq_index + cum_lengths[-1])
+
+    # find gap position before seq_index
+    index = numpy.searchsorted(gap_pos, seq_index, side="left")
+    if seq_index < gap_pos[index]:
+        gap_lengths = cum_lengths[index - 1] if index else 0
+    else:
+        gap_lengths = cum_lengths[index]
+
+    return int(seq_index + gap_lengths)
+
+
+@numba.jit
+def align_to_seq_index(
+    gap_pos: IntArrayTypes,
+    cum_lengths: IntArrayTypes,
+    len_aligned: int,
+    num_gaps: int,
+    align_index: int,
+) -> int:  # pragma: no cover
+    """converts alignment index to sequence index
+
+    Parameters
+    ----------
+    gap_pos
+        array of gap positions in sequence coordinates
+    cum_lengths
+        array of cumulative gap lengths
+    len_aligned
+        length of aligned sequence
+    num_gaps
+        the number of gaps
+    align_index
+        coordinate on the alignment, must be < len_aligned
+    """
+    assert len(gap_pos) == len(cum_lengths) == num_gaps
+    # NOTE I explicitly cast all returned values to python int's due to
+    # need for json serialisation, which does not support numpy int classes
+    if align_index < 0:
+        align_index = len_aligned + align_index
+    if align_index < 0:
+        raise IndexError(f"{align_index} align_index beyond limit")
+
+    if not num_gaps or align_index < gap_pos[0]:
+        return align_index
+
+    # these are alignment indices for gaps
+    gap_starts, gap_ends = _gap_spans(gap_pos, cum_lengths)
+    if align_index >= gap_ends[-1]:
+        return int(align_index - cum_lengths[-1])
+
+    index = numpy.searchsorted(gap_ends, align_index, side="left")
+    if align_index < gap_starts[index]:
+        # before the gap at index
+        return int(align_index - cum_lengths[index - 1])
+
+    if align_index == gap_ends[index]:
+        # after the gap at index
+        return int(align_index - cum_lengths[index])
+
+    if gap_starts[index] <= align_index < gap_ends[index]:
+        # within the gap at index
+        # so the gap insertion position is the sequence position
+        return int(gap_pos[index])
+
+
+def all_gaps_modulo_factor(cum_lengths: numpy.ndarray, factor: int) -> bool:
+    """returns True if all gap lengths are multiples of factor"""
+    return numpy.all(cum_lengths % factor == 0)
+
+
 @dataclasses.dataclass
 class IndelMap(MapABC):
     """store locations of deletions in a Aligned sequence
@@ -1011,7 +869,7 @@ class IndelMap(MapABC):
         if ``True``, returns new instance with terminal gaps indicated as
         unknown character '?'
     parent_length
-        length of parent sequence (i.e. aligned sequence with gaps)
+        length of parent sequence (i.e. aligned sequence without gaps)
     """
 
     # gap data is gap positions, gap lengths on input, stored
@@ -1040,6 +898,9 @@ def __post_init__(self, gap_lengths: IntArrayTypes):
                 f"gap position {self.gap_pos[-1]} outside parent_length {self.parent_length}"
             )
 
+        # force all to int32
+        self.gap_pos = self.gap_pos.astype(numpy.int32)
+        self.cum_gap_lengths = self.cum_gap_lengths.astype(numpy.int32)
         # make gap array immutable
         self.gap_pos.flags.writeable = False
         self.cum_gap_lengths.flags.writeable = False
@@ -1121,98 +982,129 @@ def _(self, item: slice):
         # we're assuming that this gap object is associated with a sequence
         # that will also be sliced. Hence, we need to shift the gap insertion
         # positions relative to this newly sliced sequence.
-        if item.step is not None:
-            raise NotImplementedError(
-                f"{type(self).__name__!r} does not yet support strides"
-            )
-        zero_array = numpy.array([], dtype=_DEFAULT_GAP_DTYPE)
-        start = item.start or 0
-        stop = item.stop if item.stop is not None else len(self)
-
-        # convert negative indices
-        start = start if start >= 0 else len(self) + start
-        stop = stop if stop >= 0 else len(self) + stop
-        if min((start, stop)) < 0:
-            raise IndexError("item.start or item.stop is out of range")
-
-        if start >= stop:
-            # standard slice behaviour without negative step
-            return self.__class__(
-                gap_pos=zero_array.copy(),
-                cum_gap_lengths=zero_array.copy(),
-                parent_length=0,
-            )
 
-        # we address three easy cases:
-        # 1 - no gaps; 2 - slice before first gap; 3 - after last gap
-        no_gaps = self.__class__(
-            gap_pos=zero_array.copy(),
-            cum_gap_lengths=zero_array.copy(),
-            parent_length=stop - start,
-        )
-        if not self.num_gaps:
-            return no_gaps
+        # refactor: simplify
+
+        step = 1 if item.step is None else item.step
+        func = _input_vals_pos_step if step > 0 else _input_vals_neg_step
+        start, stop, step = func(len(self), item.start, item.stop, step)
+
+        if step < 0:
+            start = -start - 1
+            stop = -stop - 1
+            return self.nucleic_reversed()[start:stop:-step]
 
-        first_gap = self.gap_pos[0]
-        last_gap = self.gap_pos[-1] + self.cum_gap_lengths[-1]
-        if stop < first_gap or start >= last_gap:
-            return no_gaps
+        if self._check_no_gap_slice(start, stop):
+            return self._zero_imap(start, stop, step)
 
         gap_starts, gap_ends = _gap_spans(self.gap_pos, self.cum_gap_lengths)
-        gap_pos = self.gap_pos.copy()
-        cum_lengths = self.cum_gap_lengths.copy()
-        # we find where the slice starts
-        l = numpy.searchsorted(gap_ends, start, side="left")
+
+        # determine the gap index where the slice starts
+        l = numpy.searchsorted(gap_ends, start + 1, side="left")
+
+        # check if entire slice is within a single gap
         if gap_starts[l] <= start < gap_ends[l] and stop <= gap_ends[l]:
-            # entire span is within a single gap
-            # pos now 0
-            gap_pos = numpy.array([0], dtype=_DEFAULT_GAP_DTYPE)
-            cum_lengths = cum_lengths[l : l + 1]
-            cum_lengths[0] = stop - start
-            return self.__class__(
-                gap_pos=gap_pos, cum_gap_lengths=cum_lengths, parent_length=0
-            )
+            return self._single_imap(start, stop, step)
 
+        cum_seq_length = 0
+        adj_gaps = []
         lengths = self.get_gap_lengths()
-        if start < first_gap:
-            # start is before the first gap, we don't slice or shift
-            shift = start
-            begin = 0
-        elif gap_starts[l] <= start < gap_ends[l]:
+
+        # determine the beginning of the slice
+        if gap_starts[l] <= start < gap_ends[l]:
             # start is within a gap
-            # so the absolute gap_pos value remains unchanged, but we shorten
-            # the gap length
-            begin = l
-            begin_diff = start - gap_starts[l]
-            lengths[l] -= begin_diff
-            shift = (start - cum_lengths[l - 1] - begin_diff) if l else gap_pos[0]
-        elif start == gap_ends[l]:
-            # at gap boundary, so beginning of non-gapped segment
-            # no adjustment to gap lengths
-            begin = l + 1
-            shift = start - cum_lengths[l]
+            adj = start - gap_starts[l]
+            # we want "ceiling division" to determine how many steps are
+            # included which we can achieve with upside down "floor division"
+            # e.g., a gap of 3 with a step of 2 should be 2 steps
+            adj_gap_len = -((lengths[l] - adj) // -step)
+            adj_gaps.append([0, adj_gap_len])
         else:
-            # not within a gap
-            begin = l
-            shift = start - cum_lengths[l - 1] if l else start
-
-        # start search for stop from l index
+            # start is within a ungapped segment
+            if stop <= gap_starts[l]:
+                # the stop is within the same ungapped segment
+                return self._zero_imap(start, stop, step)
+
+            # determine the preceding seq length
+            cum_seq_length += _step_adjusted_length(
+                start=start, end=gap_starts[l], adj=0, step=step
+            )
+            # adj is how many more steps we need to take from the beginning of
+            # the gap to reach a position which is a multiple of the step
+            adj = _step_adjustment(gap_starts[l], start, step)
+            # check gap is not stepped over entirely
+            if adj < lengths[l]:
+                adj_gap_len = -((lengths[l] - adj) // -step)
+                adj_gaps.append([cum_seq_length, adj_gap_len])
+
+        # start search for rhs index
         r = numpy.searchsorted(gap_ends[l:], stop, side="right") + l
-        if r == self.num_gaps:
-            # stop is after last gap
-            end = r
-        elif gap_starts[r] < stop <= gap_ends[r]:
-            # within gap
-            end = r + 1
-            end_diff = gap_ends[r] - stop
-            lengths[r] -= end_diff
+
+        # iterate through the gaps that are fully within the slice
+        for j in range(l + 1, r):
+            # determine how long the preceding ungapped segment was
+            adj = _step_adjustment(gap_ends[j - 1], start, step)
+            adj_seq_len = _step_adjusted_length(
+                (gap_ends[j - 1]), gap_starts[j], adj, step
+            )
+            cum_seq_length += adj_seq_len
+
+            # calculate the adjustment to the gap
+            adj = _step_adjustment(gap_starts[j], start, step)
+            if adj < lengths[j]:
+                # if adj > length, then the gap is "stepped over" by the stride
+                adj_gap_len = -((lengths[j] - adj) // -step)
+
+                if adj_gaps and adj_gaps[-1][0] == cum_seq_length:
+                    # previous gap is contiguous with this one
+                    adj_gaps[-1][1] += adj_gap_len
+                else:
+                    adj_gaps.append([cum_seq_length, adj_gap_len])
+
+        # determine the end of the slice
+        if l == r:
+            # the stop was inside the first gap
+            adj = _step_adjustment(gap_starts[r], start, step)
+
+            if adj_gaps:
+                # start was in the gap
+                adj_gaps[-1][1] = _step_adjusted_length(gap_starts[r], stop, adj, step)
+            else:
+                # start was in the seq before the gap
+                adj_gaps.append(
+                    [
+                        cum_seq_length,
+                        _step_adjusted_length(gap_starts[r], stop, adj, step),
+                    ]
+                )
+
+        elif stop >= gap_ends[-1] or stop < gap_starts[r]:
+            # stop is within an ungapped segment
+            adj = _step_adjustment(gap_ends[r - 1], start, step)
+            cum_seq_length += _step_adjusted_length(gap_ends[r - 1], stop, adj, step)
         else:
-            end = r
+            # stop is within a gap
+            # determine length of previous ungapped segment
+            adj = _step_adjustment(gap_ends[r - 1], start, step)
+            cum_seq_length += _step_adjusted_length(
+                gap_ends[r - 1], gap_starts[r], adj, step
+            )
 
-        pos_result = gap_pos[begin:end]
-        pos_result -= shift
-        lengths = lengths[begin:end]
-        parent_length = self.get_seq_index(stop) - self.get_seq_index(start)
+            # determine length of the gap when considering the stop
+            adj = _step_adjustment(gap_starts[r], start, step)
+            adj_gap_len = _step_adjusted_length(gap_starts[r], stop, adj, step)
+
+            # check that we do not step over the gap entirely
+            if adj_gap_len > 0:
+                if adj_gaps and adj_gaps[-1][0] == cum_seq_length:
+                    # the previous gap is contiguous with this one
+                    adj_gaps[-1][1] += adj_gap_len
+                else:
+                    adj_gaps.append([cum_seq_length, adj_gap_len])
+
+        pos_result = numpy.array([x for x, _ in adj_gaps])
+        lengths = numpy.array([y for _, y in adj_gaps])
+        parent_length = cum_seq_length
 
         return self.__class__(
             gap_pos=pos_result,
@@ -1220,6 +1112,48 @@ def _(self, item: slice):
             parent_length=parent_length,
         )
 
+    def _zero_imap(self, start: int, stop: int, step: int) -> "IndelMap":
+        """returns a new IndelMap with zero gaps"""
+        zero_array = numpy.array([], dtype=_DEFAULT_GAP_DTYPE)
+        return self.__class__(
+            gap_pos=zero_array.copy(),
+            cum_gap_lengths=zero_array.copy(),
+            parent_length=_step_adjusted_length(start, stop, 0, step),
+        )
+
+    def _single_imap(self, start: int, stop: int, step: int) -> "IndelMap":
+        """returns a new IndelMap with a single gap"""
+        gap_pos = numpy.array([0], dtype=_DEFAULT_GAP_DTYPE)
+        cum_gap_length = numpy.array([_step_adjusted_length(start, stop, 0, step)])
+        return self.__class__(
+            gap_pos=gap_pos, cum_gap_lengths=cum_gap_length, parent_length=0
+        )
+
+    def _check_no_gap_slice(self, start: int, stop: int) -> bool:
+        """check for easy cases where slicing an indel maps results in no gaps.
+
+        Returns
+            True if no gaps are present in the slice
+        """
+        # note that this methods assumes positive indexing and a positive step
+        # (i.e. start, stop, step > 0)
+
+        # when slicing an indel maps, we can have four easy cases:
+        # 1 - invalid slice
+        if start == stop:
+            return True
+
+        # 2 - no gaps
+        if not self.num_gaps:
+            return True
+
+        first_gap = self.gap_pos[0]  # start of first gap
+        last_gap = self.gap_pos[-1] + self.cum_gap_lengths[-1]  # end of last gap
+
+        # 3 - slice before first gap; 4 - after last gap (for positive step)
+        if stop <= first_gap or start >= last_gap:
+            return True
+
     def get_align_index(self, seq_index: int, slice_stop: bool = False) -> int:
         """convert a sequence index into an alignment index
 
@@ -1232,73 +1166,30 @@ def get_align_index(self, seq_index: int, slice_stop: bool = False) -> int:
             In that case, and if seq_index is in gap_pos then it returns
             the first alignment index of the gap run.
         """
-        cum_lengths = self.cum_gap_lengths
-        gap_pos = self.gap_pos
+        index = seq_to_align_index(
+            self.gap_pos,
+            self.cum_gap_lengths,
+            self.parent_length,
+            self.num_gaps,
+            seq_index,
+            slice_stop,
+        )
         # NOTE I explicitly cast all returned values to python int's due to
         # need for json serialisation, which does not support numpy int classes
-        if seq_index < 0:
-            seq_index += self.parent_length
-
-        if seq_index < 0:
-            raise IndexError(f"{seq_index} negative seq_index beyond limit ")
-
-        if not self.num_gaps or seq_index < gap_pos[0]:
-            return int(seq_index)
-
-        # if stop_index, check if the seq_index corresponds to a gap position
-        if slice_stop and (match := seq_index == gap_pos).any():
-            # if so, we return the alignment coord for the first gap position
-            (idx,) = numpy.where(match)[0]
-            if idx:
-                gap_len = cum_lengths[idx] - cum_lengths[idx - 1]
-            else:
-                gap_len = cum_lengths[idx]
-            gap_end = gap_pos[idx] + cum_lengths[idx]
-            return int(gap_end - gap_len)
-
-        if seq_index >= gap_pos[-1]:
-            return int(seq_index + cum_lengths[-1])
-
-        # find gap position before seq_index
-        index = numpy.searchsorted(gap_pos, seq_index, side="left")
-        if seq_index < gap_pos[index]:
-            gap_lengths = cum_lengths[index - 1] if index else 0
-        else:
-            gap_lengths = cum_lengths[index]
-
-        return int(seq_index + gap_lengths)
+        return int(index)
 
     def get_seq_index(self, align_index: int) -> int:
         """converts alignment index to sequence index"""
+        val = align_to_seq_index(
+            self.gap_pos,
+            self.cum_gap_lengths,
+            len(self),
+            self.num_gaps,
+            align_index,
+        )
         # NOTE I explicitly cast all returned values to python int's due to
         # need for json serialisation, which does not support numpy int classes
-        if align_index < 0:
-            align_index = len(self) + align_index
-        if align_index < 0:
-            raise IndexError(f"{align_index} align_index beyond limit")
-
-        if not self.num_gaps or align_index < self.gap_pos[0]:
-            return align_index
-
-        # these are alignment indices for gaps
-        cum_lengths = self.cum_gap_lengths
-        gap_starts, gap_ends = _gap_spans(self.gap_pos, cum_lengths)
-        if align_index >= gap_ends[-1]:
-            return int(align_index - cum_lengths[-1])
-
-        index = numpy.searchsorted(gap_ends, align_index, side="left")
-        if align_index < gap_starts[index]:
-            # before the gap at index
-            return int(align_index - cum_lengths[index - 1])
-
-        if align_index == gap_ends[index]:
-            # after the gap at index
-            return int(align_index - cum_lengths[index])
-
-        if gap_starts[index] <= align_index < gap_ends[index]:
-            # within the gap at index
-            # so the gap insertion position is the sequence position
-            return int(self.gap_pos[index])
+        return int(val)
 
     def __len__(self) -> int:
         length_gaps = self.cum_gap_lengths[-1] if self.num_gaps else 0
@@ -1565,7 +1456,7 @@ def with_termini_unknown(self) -> "IndelMap":
         )
 
     def to_feature_map(self) -> "FeatureMap":
-        """returns a Map type, suited to Features"""
+        """returns a FeatureMap type, suited to Features"""
         return FeatureMap(spans=list(self.spans), parent_length=self.parent_length)
 
     def make_seq_feature_map(self, align_feature_map: "FeatureMap") -> "FeatureMap":
@@ -1670,6 +1561,46 @@ def _(self, other_gaps: numpy.ndarray):
             parent_length=self.parent_length,
         )
 
+    def scaled(self, scale_factor: float) -> "IndelMap":
+        """returns indel map scaled by scale_factor
+
+        Notes
+        -----
+        For usage with interconverting between codon and amino-acid alignments.
+        Set scale_factor to 3 for converting amino-acid alignment to codon
+        alignment. Set scale_factor to 1/3 for converting codon alignment to
+        amino-acid alignment.
+        """
+        if scale_factor == 1:
+            return self
+
+        # need to check that, if scale_factor is < 1, it's inverse is an integer
+        if scale_factor < 1:
+            if not numpy.allclose(1 / scale_factor, int(1 / scale_factor)):
+                raise ValueError(
+                    f"scale_factor {scale_factor} must be an integer or 1/integer"
+                )
+
+            if not all_gaps_modulo_factor(self.cum_gap_lengths, int(1 / scale_factor)):
+                raise ValueError(
+                    f"gap lengths must be multiples of {int(1 / scale_factor)}"
+                )
+
+        # make sure gap lengths are modulo factor
+        parent_len = int(self.parent_length * scale_factor)
+        gap_pos = (self.gap_pos * scale_factor).astype(self.gap_pos.dtype)
+        cum_gap_lengths = (self.cum_gap_lengths * scale_factor).astype(
+            self.cum_gap_lengths.dtype
+        )
+        return self.__class__(
+            gap_pos=gap_pos, cum_gap_lengths=cum_gap_lengths, parent_length=parent_len
+        )
+
+    @property
+    def array(self):
+        """returns 2D numpy array with columns gap position and cum gap lengths"""
+        return numpy.array([self.gap_pos, self.cum_gap_lengths]).T
+
 
 _empty = None, None
 
@@ -1885,7 +1816,7 @@ def get_covering_span(self) -> "FeatureMap":
         )
 
     def covered(self) -> "FeatureMap":
-        """>>> Map([(10,20), (15, 25), (80, 90)]).covered().spans
+        """>>> FeatureMap([(10,20), (15, 25), (80, 90)]).covered().spans
         [Span(10,25), Span(80, 90)]"""
 
         delta = {}
@@ -2029,11 +1960,7 @@ def inverse(self) -> "FeatureMap":
         return self.__class__(spans=new_spans, parent_length=len(self))
 
     def get_coordinates(self) -> SeqCoordTypes:
-        """returns span coordinates as [(v1, v2), ...]
-
-        v1/v2 are (start, end) unless the map is reversed, in which case it will
-        be (end, start)"""
-
+        """returns span coordinates as [(v1, v2), ...]"""
         return [(s.start, s.end) for s in self.spans if not s.lost]
 
     def to_rich_dict(self) -> dict[str, Any]:
@@ -2153,7 +2080,7 @@ def gap_coords_to_map(
 
     Returns
     -------
-    Map
+    IndelMap instance
     """
 
     if not gaps_lengths:
@@ -2177,6 +2104,10 @@ def deserialise_featuremap(data: dict) -> FeatureMap:
     return FeatureMap.from_rich_dict(data)
 
 
+@register_deserialiser("cogent3.core.location.Map")
+def deserialise_map(data: dict) -> FeatureMap: ...
+
+
 @functools.singledispatch
 def _norm_slice(index, length: int) -> tuple[int, int, Union[int, None]]:
     """_norm_slice(slice(1, -2, 3), 10) -> (1,8,3)"""
diff --git a/src/cogent3/core/moltype.py b/src/cogent3/core/moltype.py
index eff2c3f7c..86810ec66 100644
--- a/src/cogent3/core/moltype.py
+++ b/src/cogent3/core/moltype.py
@@ -1541,13 +1541,15 @@ def get_moltype(name, new_type: bool = False):
         The default will be changed to True in 2024.12. Support for the old
         style will be removed as of 2025.6.
     """
-    if new_type or "COGENT3_NEW_TYPE" in os.environ:
-        from cogent3.core.new_moltype import get_moltype as new_get_moltype
-
-        return new_get_moltype(name=name)
+    from cogent3.core import new_moltype
 
+    if new_type or "COGENT3_NEW_TYPE" in os.environ:
+        return new_moltype.get_moltype(name=name)
     if isinstance(name, MolType):
         return name
+    if isinstance(name, new_moltype.MolType):
+        return name
+
     name = name.lower()
     if name not in moltypes:
         raise ValueError(f"unknown moltype {name!r}")
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index 211fcbd69..cc825aa1d 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -1,6 +1,9 @@
 from __future__ import annotations
 
+import contextlib
+import dataclasses
 import json
+import os
 import re
 import typing
 import warnings
@@ -8,12 +11,20 @@
 from collections import defaultdict
 from functools import singledispatch, singledispatchmethod
 from pathlib import Path
-from typing import Any, Callable, Iterator, Mapping, Optional, Union
+from typing import Any, Callable, Iterable, Iterator, Mapping, Optional, Union
+from typing import Sequence as PySeq
 
+import numba
 import numpy
 
+from cogent3 import get_app
 from cogent3._version import __version__
-from cogent3.core import new_alphabet, new_moltype, new_sequence
+from cogent3.core import (
+    new_alphabet,
+    new_genetic_code,
+    new_moltype,
+    new_sequence,
+)
 from cogent3.core.annotation import Feature
 from cogent3.core.annotation_db import (
     BasicAnnotationDb,
@@ -21,63 +32,133 @@
     SupportsFeatures,
 )
 from cogent3.core.info import Info as InfoClass
-from cogent3.core.profile import (
-    PSSM,
-    MotifCountsArray,
-    MotifFreqsArray,
-    load_pssm,
+from cogent3.core.location import (
+    FeatureMap,
+    IndelMap,
 )
+from cogent3.core.profile import PSSM, MotifCountsArray, MotifFreqsArray, load_pssm
 from cogent3.format.alignment import save_to_filename
 from cogent3.format.fasta import seqs_to_fasta
 from cogent3.format.phylip import alignment_to_phylip
+from cogent3.maths.stats.number import CategoryCounter
 from cogent3.util import progress_display as UI
 from cogent3.util import warning as c3warn
 from cogent3.util.deserialise import deserialise_object, register_deserialiser
+from cogent3.util.dict_array import DictArray, DictArrayTemplate
 from cogent3.util.io import atomic_write, get_format_suffixes
 from cogent3.util.misc import (
+    extend_docstring_from,
     get_object_provenance,
     get_setting_from_environ,
     negate_condition,
 )
+from cogent3.util.union_dict import UnionDict
+
+# DESIGN NOTES
+# the sequence data collections (SeqsDataABC and AlignedSeqsDataABC)
+# have no concept of strand. All transformations with respect to strand
+# are applied by the sequence record objects that have a .moltype
+# attribute, i.e. Sequence and Aligned.
+
 
 DEFAULT_ANNOTATION_DB = BasicAnnotationDb
 
 OptInt = Optional[int]
+OptFloat = Optional[float]
 OptStr = Optional[str]
 OptList = Optional[list]
+OptIterStr = Optional[Iterable[str]]
+PySeqStr = PySeq[str]
+OptPySeqStr = Optional[PySeqStr]
 OptDict = Optional[dict]
+OptBool = Optional[bool]
+OptSliceRecord = Optional[new_sequence.SliceRecord]
+DictStrStr = dict[str, str]
+DictStrInt = dict[str, int]
 OptCallable = Optional[Callable]
 OptRenamerCallable = Optional[Callable[[str], str]]
 OptPathType = Union[str, Path, None]
-StrORBytesORArray = Union[str, bytes, numpy.ndarray]
+StrORArray = Union[str, numpy.ndarray[int]]
+StrORBytesORArray = Union[str, bytes, numpy.ndarray[int]]
+StrORBytesORArrayOrSeq = Union[str, bytes, numpy.ndarray[int], new_sequence.Sequence]
+MolTypes = Union[str, new_moltype.MolType]
 
+# small number: 1-EPS is almost 1, and is used for things like the
+# default number of gaps to allow in a column.
+EPS = 1e-6
 
-class MakeSeqCallable(typing.Protocol):
-    def __call__(
-        self,
-        seq: Union[StrORBytesORArray, new_sequence.Sequence, new_sequence.SeqViewABC],
-        name: OptStr = None,
-        check_seq: bool = True,
-    ) -> new_sequence.Sequence: ...
 
+class _SeqNamer:
+    def __init__(
+        self,
+        name_func: OptRenamerCallable = None,
+        base_name: str = "seq",
+        start_at: int = 0,
+    ):
+        self._base_name = base_name
+        self._num = start_at
+        self._name_func = name_func
+
+    def __call__(self, seq: StrORBytesORArray, name: OptStr = None) -> str:
+        name = name or getattr(seq, "name", name)
+
+        if not name:
+            name = f"{self._base_name}_{self._num}"
+            self._num += 1
+        elif self._name_func:
+            name = self._name_func(name)
+
+        return name
+
+
+@numba.njit(cache=True)
+def _gap_ok_vector_single(
+    data: numpy.ndarray[numpy.uint8],
+    gap_index: int,
+    missing_index: int,
+    num_allowed: int,
+) -> bool:  # pragma: no cover
+    """returns indicies for which the number of gaps & missing data is less than or equal to num_allowed"""
+    num = 0
+    for i in range(len(data)):
+        if data[i] == gap_index:
+            num += 1
+        elif missing_index is not None and data[i] == missing_index:
+            num += 1
+
+        if num > num_allowed:
+            break
+
+    return num <= num_allowed
+
+
+@numba.njit(cache=True)
+def _gap_ok_vector_multi(
+    motifs: numpy.ndarray,
+    gap_index: int,
+    missing_index: int,
+    motif_length: int,
+    num_allowed: int,
+) -> numpy.ndarray[bool]:  # pragma: no cover
+    """returns indicies for which the number of gaps & missing data in a vector of motifs is less than or equal to num_allowed"""
+    num = 0
+    for motif in motifs:
+        for j in range(motif_length):
+            if (
+                motif[j] == gap_index
+                or missing_index is not None
+                and motif[j] == missing_index
+            ):
+                num += 1
+                break
 
-def assign_sequential_names(num_seqs: int, base_name: str = "seq", start_at: int = 0):
-    """Returns list of sequential, unique names, e.g., ['seq_0' ... 'seq_n'] for
-     num_seqs = n+1
+        if num > num_allowed:
+            break
 
-    Parameters
-    ----------
-    num_seqs
-        number of names to generate
-    base_name
-        the sequence name prefix
-    start_at
-        the number to start the sequence names at
-    """
-    return [f"{base_name}_{i}" for i in range(start_at, start_at + num_seqs)]
+    return num <= num_allowed
 
 
-class SeqDataView(new_sequence.SeqViewABC, new_sequence.SliceRecordABC):
+class SeqDataView(new_sequence.SeqView):
     """
     A view class for SeqsData, providing methods for different representations
     of a single sequence.
@@ -94,88 +175,34 @@ class SeqDataView(new_sequence.SeqViewABC, new_sequence.SliceRecordABC):
     # array([3, 1, 2, 0], dtype=int8)
     """
 
-    __slots__ = ("seq", "start", "stop", "step", "_offset", "_seqid", "_seq_len")
-
-    def __init__(
-        self,
-        *,
-        seq: SeqsData,
-        seqid: str,
-        seq_len: int,
-        start: OptInt = None,
-        stop: OptInt = None,
-        step: OptInt = None,
-        offset: int = 0,
-    ):
-        if step == 0:
-            raise ValueError("step cannot be 0")
-        step = 1 if step is None else step
-        self._seq_len = self._checked_seq_len(seq_len)
-        func = (
-            new_sequence._input_vals_pos_step
-            if step > 0
-            else new_sequence._input_vals_neg_step
-        )
-        start, stop, step = func(self._seq_len, start, stop, step)
-        self.seq = seq
-        self.start = start
-        self.stop = stop
-        self.step = step
-        self._offset = offset
-        self._seqid = seqid
-
-    def _checked_seq_len(self, seq_len: int) -> int:
-        assert seq_len is not None
-        return seq_len
-
-    @property
-    def _zero_slice(self):
-        return self.__class__(
-            seq=self.seq, seqid=self.seqid, seq_len=self._seq_len, start=0, stop=0
-        )
-
-    @property
-    def seqid(self) -> str:
-        return self._seqid
-
-    @property
-    def seq_len(self) -> int:
-        return self._seq_len
-
-    def _get_init_kwargs(self):
-        return {"seq": self.seq, "seqid": self.seqid}
+    __slots__ = ("parent", "alphabet", "_seqid", "_parent_len", "_slice_record")
 
     @property
     def str_value(self) -> str:
         """returns the sequence as a string"""
-        raw = self.seq.get_seq_str(
-            seqid=self.seqid, start=self.parent_start, stop=self.parent_stop
-        )
-        return raw if self.step == 1 else raw[:: self.step]
+        return self.alphabet.from_indices(self.array_value)
 
     @property
     def array_value(self) -> numpy.ndarray:
         """returns the sequence as a numpy array"""
-        raw = self.seq.get_seq_array(
-            seqid=self.seqid, start=self.parent_start, stop=self.parent_stop
+        raw = self.parent.get_seq_array(
+            seqid=self.seqid,
+            start=self.slice_record.plus_start,
+            stop=self.slice_record.plus_stop,
+            step=self.slice_record.plus_step,
         )
-        return raw if self.step == 1 else raw[:: self.step]
+        return raw[::-1] if self.slice_record.is_reversed else raw
 
     @property
     def bytes_value(self) -> bytes:
         """returns the sequence as bytes"""
-        raw = self.seq.get_seq_bytes(
-            seqid=self.seqid, start=self.parent_start, stop=self.parent_stop
-        )
-        return raw if self.step == 1 else raw[:: self.step]
+        return self.str_value.encode("utf8")
 
     def __repr__(self) -> str:
-        # todo: add alphabet to the repr
         seq = f"{self[:10]!s}...{self[-5:]}" if len(self) > 15 else str(self)
         return (
-            f"{self.__class__.__name__}(seq={seq}, start={self.start}, "
-            f"stop={self.stop}, step={self.step}, offset={self.offset}, "
-            f"seqid={self.seqid!r}, seq_len={self.seq_len})"
+            f"{self.__class__.__name__}(seqid={self.seqid!r}, parent={seq}, "
+            f"slice_record={self.slice_record!r})"
         )
 
     # refactor: design, do we support copy? do we support copy with sliced?
@@ -198,18 +225,21 @@ def to_rich_dict(self) -> dict[str, str | dict[str, str]]:
 
         data = {"type": get_object_provenance(self), "version": __version__}
         data["init_args"] = self._get_init_kwargs()
-        data["init_args"]["step"] = self.step
 
-        if self.is_reversed:
-            adj = self.seq_len + 1
-            start, stop = self.stop + adj, self.start + adj
+        if self.slice_record.is_reversed:
+            adj = self.parent_len + 1
+            start, stop = self.slice_record.stop + adj, self.slice_record.start + adj
         else:
-            start, stop = self.start, self.stop
-
-        data["init_args"]["seq"] = self.str_value[start:stop]
-        data["init_args"]["offset"] = int(self.parent_start)
-        data["init_args"]["seq_len"] = len(self)
+            start, stop = self.slice_record.start, self.slice_record.stop
 
+        data["init_args"]["parent"] = self.str_value[start:stop]
+        new_sr = new_sequence.SliceRecord(
+            parent_len=(stop - start),
+            step=self.slice_record.step,
+            offset=self.slice_record.parent_start,
+        )
+        data["init_args"]["slice_record"] = new_sr.to_rich_dict()
+        data["init_args"]["alphabet"] = self.alphabet.to_rich_dict()
         return data
 
 
@@ -220,23 +250,28 @@ class SeqsDataABC(ABC):
 
     __slots__ = ()
 
+    @classmethod
+    @abstractmethod
+    def from_seqs(
+        cls,
+        *,
+        data: dict[str, StrORBytesORArray],
+        alphabet: new_alphabet.AlphabetABC,
+        **kwargs,
+    ): ...
+
     @abstractmethod
-    def seq_lengths(self) -> dict[str, int]: ...
+    def __eq__(self, value: object) -> bool: ...
 
-    @property
     @abstractmethod
-    def names(self) -> list: ...
+    def __ne__(self, value: object) -> bool: ...
 
-    @property
     @abstractmethod
-    def make_seq(self): ...
+    def get_seq_length(self, seqid: str) -> int: ...
 
-    @make_seq.setter
+    @property
     @abstractmethod
-    def make_seq(self, make_seq: MakeSeqCallable) -> None:
-        """Can be set with any callable function that takes 'seq' and 'name' as
-        keyword arguments. Typically set with '<moltype-instance>.make_seq'."""
-        ...
+    def names(self) -> tuple: ...
 
     @property
     @abstractmethod
@@ -244,7 +279,7 @@ def alphabet(self) -> new_alphabet.CharAlphabet: ...
 
     @property
     @abstractmethod
-    def reversed(self) -> dict[str, bool]: ...
+    def offset(self) -> dict[str, int]: ...
 
     @abstractmethod
     def get_seq_array(
@@ -262,22 +297,16 @@ def get_seq_bytes(
     ) -> bytes: ...
 
     @abstractmethod
-    def get_seq_view(self, seqid: str) -> new_sequence.SliceRecordABC: ...
+    def get_view(self, seqid: str) -> new_sequence.SeqViewABC: ...
 
     @abstractmethod
-    def subset(self, names: Union[str, typing.Sequence[str]]) -> SeqsDataABC: ...
+    def to_alphabet(self, alphabet: new_alphabet.AlphabetABC) -> SeqsDataABC: ...
 
     @abstractmethod
-    def to_alphabet(self, alphabet: new_alphabet.CharAlphabet) -> SeqsDataABC: ...
+    def add_seqs(self, seqs, **kwargs) -> SeqsDataABC: ...
 
     @abstractmethod
-    def add_seqs(self, seqs) -> SeqsDataABC: ...
-
-    @abstractmethod
-    def to_rich_dict(self) -> dict: ...
-
-    @abstractmethod
-    def __len__(self): ...
+    def __len__(self) -> int: ...
 
     @abstractmethod
     def __getitem__(
@@ -296,134 +325,149 @@ class SeqsData(SeqsDataABC):
         a dictionary of {name: sequence} pairs
     alphabet
         an instance of CharAlphabet valid for the sequences
-    make_seq
-        optional sequence constructor, takes 'seq' and 'name' as keyword arguments
-        and returns a Sequence instance. If not set, __getitem__ will return a
-        SeqDataView
-    reversed_seqs
-        a dictionary of {name: bool} pairs indicating if the sequence is reversed
+    offset
+        a dictionary of {name: offset} pairs indicating the offset of the sequence
+    check
+        a boolean indicating if the data should be checked for naming consistency
+        between arguments
     """
 
-    __slots__ = ("_data", "_alphabet", "_make_seq", "_reversed_seqs")
-    # todo: kath
-    # refactor: design
-    # SeqsData needs new fields that record the offsets
+    __slots__ = ("_data", "_alphabet", "_offset")
 
     def __init__(
         self,
         *,
         data: dict[str, StrORBytesORArray],
-        alphabet: new_alphabet.CharAlphabet,
-        make_seq: Optional[MakeSeqCallable] = None,
-        reversed_seqs: dict[str, bool] = None,
+        alphabet: new_alphabet.AlphabetABC,
+        offset: dict[str, int] = None,
+        check: bool = True,
     ):
         self._alphabet = alphabet
-        self._make_seq = make_seq
+        self._offset = offset or {}
+        if check:
+            assert (
+                self._offset.keys() <= data.keys()
+            ), "sequence name provided in offset not found in data"
+            if any(not alphabet.is_valid(seq) for seq in data.values()):
+                raise new_alphabet.AlphabetError(
+                    f"One or more sequences are invalid for alphabet {alphabet}"
+                )
         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
-        self._reversed_seqs = reversed_seqs or {}
 
-    @property
-    def names(self) -> list:
-        return list(self._data.keys())
+    def __eq__(self, other: SeqsDataABC) -> bool:
+        if not isinstance(other, self.__class__):
+            return False
+        for attr_name in ("_alphabet", "_offset"):
+            self_attr = getattr(self, attr_name)
+            other_attr = getattr(other, attr_name)
+            if self_attr != other_attr:
+                return False
+
+        # compare individuals sequences
+        if self._data.keys() != other._data.keys():
+            return False
+        return all(
+            numpy.array_equal(self._data[name], other._data[name])
+            for name in self._data
+        )
 
-    @property
-    def make_seq(self) -> Optional[MakeSeqCallable]:
-        """if set, returns a function that takes 'seq' and 'name' as keyword
-        arguments and returns a corresponding Sequence from the collection.
+    def __ne__(self, other: object) -> bool:
+        return not self == other
 
-        Notes
-        -----
-        Can be set with any callable function that takes 'seq' and 'name' as
-        keyword arguments. Typically set with '<moltype-instance>.make_seq'.
-        """
-        return self._make_seq
+    @classmethod
+    def from_seqs(
+        cls,
+        *,
+        data: dict[str, StrORBytesORArray],
+        alphabet: new_alphabet.AlphabetABC,
+        **kwargs,
+    ):
+        return cls(data=data, alphabet=alphabet, **kwargs)
 
-    @make_seq.setter
-    def make_seq(self, make_seq: MakeSeqCallable) -> None:
-        self._make_seq = make_seq
+    @property
+    def names(self) -> list:
+        return list(self._data.keys())
 
     @property
-    def alphabet(self) -> new_alphabet.CharAlphabet:
+    def alphabet(self) -> new_alphabet.AlphabetABC:
         return self._alphabet
 
     @property
-    def reversed(self) -> dict[str, bool]:
-        return self._reversed_seqs
+    def offset(self) -> dict[str, int]:
+        return {name: self._offset.get(name, 0) for name in self.names}
 
-    def seq_lengths(self) -> dict[str, int]:
-        """Returns lengths of sequences as dict of {name: length, ... }."""
-        return {name: seq.shape[0] for name, seq in self._data.items()}
+    def get_seq_length(self, seqid: str) -> int:
+        """return length for seqid"""
+        return self._data[seqid].shape[0]
 
     def get_seq_array(
-        self, *, seqid: str, start: OptInt = None, stop: OptInt = None
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
     ) -> numpy.ndarray:
-        return self._data[seqid][start:stop]
+        start = start or 0
+        stop = stop if stop is not None else self.get_seq_length(seqid)
+        step = step or 1
+
+        assert (
+            start >= 0 and stop >= 0 and step > 0
+        ), "start, stop, and step must be >= 0"
+
+        out_len = (stop - start + step - 1) // step
+        out = numpy.empty(out_len, dtype=self.alphabet.dtype)
+
+        out[:] = self._data[seqid][start:stop:step]
+        return out
 
     def get_seq_str(
-        self, *, seqid: str, start: OptInt = None, stop: OptInt = None
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
     ) -> str:
         return self._alphabet.from_indices(
-            self.get_seq_array(seqid=seqid, start=start, stop=stop)
+            self.get_seq_array(seqid=seqid, start=start, stop=stop, step=step)
         )
 
     def get_seq_bytes(
-        self, *, seqid: str, start: OptInt = None, stop: OptInt = None
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
     ) -> bytes:
-        return self.get_seq_str(seqid=seqid, start=start, stop=stop).encode("utf8")
-
-    def get_seq_view(self, seqid: str) -> SeqDataView:
-        seq_len = len(self._data[seqid])
-        step = -1 if self._reversed_seqs.get(seqid, False) else 1
-        return SeqDataView(seq=self, seqid=seqid, seq_len=seq_len, step=step)
-
-    def subset(self, names: Union[str, typing.Sequence[str]]) -> SeqsData:
-        """Returns a new SeqsData object with only the specified names."""
-        names = [names] if isinstance(names, str) else names
-        if data := {name: self._data.get(name) for name in names if name in self.names}:
-            return self.__class__(
-                data=data, alphabet=self.alphabet, make_seq=self.make_seq
-            )
-        else:
-            raise ValueError(f"provided {names=} not found in collection")
-
-    def reverse_seqs(self, seqids: Union[str, typing.Sequence[str]] = None) -> SeqsData:
-        """Returns a new SeqsData object with the specified sequences reversed.
-        If no seqids are provided, all sequences are reversed."""
-
-        if seqids is None:
-            seqids = self.names
-        elif isinstance(seqids, str):
-            seqids = [seqids]
-
-        reversed_seqs = {
-            seqid: not self._reversed_seqs.get(seqid, False) for seqid in seqids
-        }
-
-        return self.__class__(
-            data=self._data,
-            alphabet=self.alphabet,
-            make_seq=self.make_seq,
-            reversed_seqs=reversed_seqs,
+        return self.get_seq_str(seqid=seqid, start=start, stop=stop, step=step).encode(
+            "utf8"
         )
 
-    def rename_seqs(self, renamer: Callable[[str], str]) -> SeqsData:
-        new_data = {renamer(name): seq for name, seq in self._data.items()}
-
-        return self.__class__(
-            data=new_data,
+    def get_view(self, seqid: str) -> SeqDataView:
+        seq_len = len(self._data[seqid])
+        return SeqDataView(
+            parent=self,
+            seqid=seqid,
+            parent_len=seq_len,
             alphabet=self.alphabet,
-            make_seq=self.make_seq,
-            reversed_seqs=self.reversed,
+            offset=self._offset.get(seqid, 0),
         )
 
     def add_seqs(
-        self, seqs: dict[str, StrORBytesORArray], force_unique_keys=True
+        self,
+        seqs: dict[str, StrORBytesORArray],
+        force_unique_keys=True,
+        offset=None,
     ) -> SeqsData:
-        """Returns a new SeqsData object with added sequences."""
+        """Returns a new SeqsData object with added sequences. If force_unique_keys
+        is True, raises ValueError if any names already exist in the collection."""
         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 = {
@@ -431,16 +475,27 @@ def add_seqs(
             **{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
+            data=new_data,
+            alphabet=self.alphabet,
+            offset={**self._offset, **(offset or {})},
         )
 
     def to_alphabet(
-        self, alphabet: new_alphabet.CharAlphabet, check_valid=True
+        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,
+            )
 
         new_data = {}
         old = self.alphabet.as_bytes()
@@ -451,113 +506,127 @@ def to_alphabet(
         )
 
         for seqid in self.names:
-            # refactor: design
-            # this conversion needs to be a method on the alphabet
-            # so it can be used by Sequence.to_moltype
-
             seq_data = self.get_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 ValueError(
+                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.__class__(data=new_data, alphabet=alphabet)
+        return self.__class__(
+            data=new_data,
+            alphabet=alphabet,
+            offset=self._offset,
+            check=False,
+        )
 
     def __len__(self):
         return len(self.names)
 
     @singledispatchmethod
-    def __getitem__(
-        self, index: Union[str, int]
-    ) -> Union[new_sequence.Sequence, new_sequence.SeqViewABC]:
+    def __getitem__(self, index: Union[str, int]) -> new_sequence.SeqViewABC:
         raise NotImplementedError(f"__getitem__ not implemented for {type(index)}")
 
     @__getitem__.register
-    def _(self, index: str) -> Union[new_sequence.Sequence, new_sequence.SeqViewABC]:
-        sdv = self.get_seq_view(seqid=index)
-        return (
-            sdv
-            if self.make_seq is None
-            else self.make_seq(seq=sdv, name=index, check_seq=False)
-        )
+    def _(self, index: str) -> new_sequence.SeqViewABC:
+        # refactor: design
+        # note that this will always return the plus strand, even if the collection
+        # has been reversed
+        return self.get_view(seqid=index)
 
     @__getitem__.register
-    def _(self, index: int) -> Union[new_sequence.Sequence, new_sequence.SeqViewABC]:
+    def _(self, index: int) -> new_sequence.SeqViewABC:
         return self[self.names[index]]
 
-    def to_rich_dict(self) -> dict[str, str | dict[str, str]]:
-        """returns a json serialisable dict"""
-        # todo: kath
-        # this should also include offset when implemented
-        return {
-            "init_args": {
-                "data": {name: self.get_seq_str(seqid=name) for name in self.names},
-                "alphabet": self.alphabet.to_rich_dict(),
-                "reversed_seqs": self.reversed,
-            },
-            "type": get_object_provenance(self),
-            "version": __version__,
-        }
-
-    @classmethod
-    def from_rich_dict(cls, data: dict[str, str | dict[str, str]]) -> SeqsData:
-        """returns a new instance from a rich dict"""
-        alphabet = deserialise_object(data["init_args"]["alphabet"])
-        return cls(
-            data=data["init_args"]["data"],
-            alphabet=alphabet,
-            reversed_seqs=data["init_args"]["reversed_seqs"],
-        )
-
-
-@register_deserialiser(get_object_provenance(SeqsData))
-def deserialise_seqs_data(data: dict[str, str | dict[str, str]]) -> SeqsData:
-    return SeqsData.from_rich_dict(data)
-
 
 class SequenceCollection:
+    """A container of unaligned sequences"""
+
     def __init__(
         self,
         *,
         seqs_data: SeqsDataABC,
         moltype: new_moltype.MolType,
-        names: OptList = None,
         info: Optional[Union[dict, InfoClass]] = None,
         source: OptPathType = None,
         annotation_db: Optional[SupportsFeatures] = None,
+        name_map: OptDict = None,
+        is_reversed: bool = False,
     ):
+        """Initialises a new SequenceCollection.
+
+        Parameters
+        ----------
+        seqs_data
+            a SeqsDataABC instance containg the sequence data
+        moltype
+            the molecular type of the sequences
+        info
+            additional information about the collection
+        source
+            the source of the sequence data
+        annotation_db
+            a database of annotations for the sequences
+        name_map
+            map between the names specified in the collection and names used in
+            the underlying seqs_data. Used for when the names have been changed,
+            but we want to query for annotations using the original names.
+        is_reversed
+            flag indicating the sequences are reversed with respect to the
+            underlying data in seqs_data
+        """
         self._seqs_data = seqs_data
         self.moltype = moltype
-        self.names = names
-        self._seqs_data.make_seq = self.moltype.make_seq
+        self._name_map = name_map or {name: name for name in seqs_data.names}
         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()
+        self._seqs = None
+        self._is_reversed = is_reversed
+        self._post_init()
+
+    def _post_init(self):
+        # override in subclasses
+        self._seqs = _IndexableSeqs(self, make_seq=self._make_seq)
+
+    def _make_seq(self, name: str) -> new_sequence.Sequence:
+        # seqview is given the name of the parent (if different from the current name)
+        # the sequence is given the current name
+        seqid = self._name_map.get(name, name)
+        sv = self._seqs_data.get_view(seqid)
+        sv = sv[::-1] if self._is_reversed else sv
+        return self.moltype.make_seq(
+            seq=sv, name=name, annotation_db=self.annotation_db
+        )
 
-    @property
-    def names(self) -> list:
-        return self._names
+    def _get_init_kwargs(self) -> dict:
+        """dict of all the arguments needed to initialise a new instance"""
+        # both SequenceCollection and Alignment implement _get_init_kwargs,
+        # ensuring methods in SequenceCollection that are inherited by Alignment
+        # capture initialisation arguments unique to the subclass.
+        # mutable arguments are copied
+        return {
+            "seqs_data": self._seqs_data,
+            "moltype": self.moltype,
+            "name_map": self._name_map.copy(),
+            "info": self.info.copy(),
+            "annotation_db": self.annotation_db,
+            "is_reversed": self._is_reversed,
+        }
 
-    @names.setter
-    def names(self, names: list[str]):  # refactor: design
-        if names is None:
-            self._names = self._seqs_data.names
-        elif set(names) <= set(self._seqs_data.names):
-            self._names = names
-        else:
-            left_diff = set(names) - set(self._seqs_data.names)
-            raise ValueError(f"Provided names not found in collection: {left_diff}")
+    @property
+    def seqs(self) -> _IndexableSeqs:
+        return self._seqs
 
     @property
-    def seqs(self) -> SeqsDataABC:
-        return self._seqs_data
+    def names(self) -> list:
+        return list(self._name_map.keys())
 
     @property
     def num_seqs(self) -> int:
@@ -574,9 +643,6 @@ def annotation_db(self, value):
 
         self._annotation_db = value
 
-        for seq in self.seqs:
-            seq.replace_annotation_db(value, check=False)
-
     @property
     @c3warn.deprecated_callable(
         version="2025.5", reason=".seqs can now be indexed by name", new=".seqs"
@@ -595,11 +661,11 @@ def iter_seqs(
             list of seqids giving the order in which seqs will be returned.
             Defaults to self.names
         """
-
-        seqs = self.seqs
-        get = seqs.__getitem__
-        for key in seq_order or self.names:
-            yield get(key)
+        if seq_order is None:
+            yield from self.seqs
+        else:
+            for name in seq_order:
+                yield self.seqs[name]
 
     def take_seqs(
         self,
@@ -621,35 +687,35 @@ def take_seqs(
         copy_annotations
             if True, only annotations from selected seqs are copied to the annotation_db
             of the new collection
-
-        Notes
-        -----
-        The seqs in the new collection will be references to the same objects as
-        the seqs in the old collection.
         """
-        # refactor: design
-        # consider gavins comment that this method could operate on only the names
-        # list and not the seqs_data object
+
+        # to return a new collection with a subset of the sequences we dont
+        # want to modify the underlying data, instead we create a new collection
+        # with a subset of the names, recorded in the name_map dict.
+
+        # refactor: design, reimplement on Alignment. on which, if self.array_seqs
+        # defined, assign result of self._array_seqs.take(subset_name_indices) to
+        # resulting alignments _array_seqs attribute
 
         if isinstance(names, str):
             names = [names]
 
         if negate:
             names = [name for name in self.names if name not in names]
-        else:
-            names = [name for name in names if name in self.names]
 
         if not names:
             raise ValueError(f"{names=} and {negate=} resulted in no names")
 
-        seqs_data = self.seqs.subset(names)
+        assert set(names) <= set(
+            self.names
+        ), f"The following provided names not found in collection: {names - self.names}"
 
-        result = self.__class__(
-            seqs_data=seqs_data,
-            moltype=self.moltype,
-            info=self.info,
-            **kwargs,
-        )
+        selected_name_map = {name: self._name_map[name] for name in names}
+
+        init_kwargs = self._get_init_kwargs()
+        init_kwargs["name_map"] = selected_name_map
+
+        result = self.__class__(**init_kwargs)
         if self.annotation_db:
             if copy_annotations:
                 result.annotation_db = type(self.annotation_db)()
@@ -663,15 +729,28 @@ def take_seqs(
     def get_seq_names_if(
         self, f: Callable[[new_sequence.Sequence], bool], negate: bool = False
     ):
-        """Returns list of names of seqs where f(seq) is True."""
-        get = self.seqs.__getitem__
+        """Returns list of names of seqs where f(seq) is True.
+
+        Parameters
+        ----------
+        f
+            function that takes a sequence object and returns True or False
+        negate
+            select all sequences EXCEPT those where f(seq) is True
+
+        Notes
+        -----
+        Sequence objects can be converted into strings or numpy arrays using
+        str() and numpy.array() respectively.
+        """
+        get = self.seqs
 
         new_f = negate_condition(f) if negate else f
 
-        return [name for name in self.names if new_f(get(name))]
+        return [name for name in self.names if new_f(get[name])]
 
     def take_seqs_if(
-        self, f: Callable[[new_sequence.Sequence], bool], negate: bool = False, **kwargs
+        self, f: Callable[[new_sequence.Sequence], bool], negate: bool = False
     ):
         """Returns new collection containing seqs where f(seq) is True.
 
@@ -684,10 +763,10 @@ def take_seqs_if(
 
         Notes
         -----
-        The seqs in the new collection are the same objects as the
-        seqs in the old collection, not copies.
+        Sequence objects can be converted into strings or numpy arrays using
+        str() and numpy.array() respectively.
         """
-        return self.take_seqs(self.get_seq_names_if(f, negate), **kwargs)
+        return self.take_seqs(self.get_seq_names_if(f, negate))
 
     def get_seq(
         self, seqname: str, copy_annotations: bool = False
@@ -726,32 +805,37 @@ def add_seqs(
         seqs
             sequences to add
         """
-        data = coerce_to_seqs_data_dict(seqs, label_to_name=None)
-        seqs_data = self.seqs.add_seqs(data, **kwargs)
+        assign_names = _SeqNamer()
+        data, offsets, is_reversed, name_map = prep_for_seqs_data(
+            seqs, self.moltype, assign_names
+        )
+
+        if not name_map:
+            name_map = dict(zip(data, data))
+
+        kwargs["offset"] = offsets
+        seqs_data = self._seqs_data.add_seqs(data, **kwargs)
         return self.__class__(
             seqs_data=seqs_data,
             moltype=self.moltype,
+            name_map={**self._name_map, **name_map},
             info=self.info,
+            source=self.source,
             annotation_db=self.annotation_db,
         )
 
     def rename_seqs(self, renamer: Callable[[str], str]):
         """Returns new collection with renamed sequences."""
-        result = self.__class__(
-            seqs_data=self.seqs.rename_seqs(renamer),
-            moltype=self.moltype,
-            info=self.info,
-            source=self.source,
-        )
-        if self.annotation_db:
-            # refactor: design
-            # how to manage to keep track of aliases? possibly stored
-            # in the annotation db
-            name_map = {renamer(name): name for name in self.names}
-            result.info.name_map = name_map
-            result.annotation_db = self.annotation_db
+        new_name_map = {
+            renamer(name): old_name for name, old_name in self._name_map.items()
+        }
+        if len(new_name_map) != len(self._name_map):
+            raise ValueError(f"non-unique names produced by {renamer=}")
 
-        return result
+        init_args = self._get_init_kwargs()
+        init_args["name_map"] = new_name_map
+
+        return self.__class__(**init_args)
 
     def to_dict(self, as_array: bool = False) -> dict[str, Union[str, numpy.ndarray]]:
         """Return a dictionary of sequences.
@@ -761,42 +845,31 @@ def to_dict(self, as_array: bool = False) -> dict[str, Union[str, numpy.ndarray]
         as_array
             if True, sequences are returned as numpy arrays, otherwise as strings
         """
-        get = self.seqs.__getitem__
-        return {
-            name: (numpy.array(get(name)) if as_array else str(get(name)))
-            for name in self.names
-        }
+        return {s.name: (numpy.array(s) if as_array else str(s)) for s in self.seqs}
 
     def to_rich_dict(self) -> dict[str, str | dict[str, str]]:
         """returns a json serialisable dict
 
         Notes
         -----
-        Serialisation will not include the annotation_db if present.
+        Deserialisation the object produced by this method will not include the
+        annotation_db if present.
         """
-        moltype = self.moltype.label
-        info = {} if self.info is None else self.info
-
-        if info.get("Refs", None) is not None and "Refs" in info:
-            info.pop("Refs")
-
-        info = info or None
-
-        init_args = dict(
-            moltype=moltype,
-            names=self.names,
-            info=info,
-        )
-
-        if hasattr(self, "annotation_db") and self.annotation_db:
-            init_args["annotation_db"] = self.annotation_db.to_rich_dict()
+        kwargs = self._get_init_kwargs()
+        kwargs.pop("is_reversed", None)  # reversal is realised
+        kwargs["moltype"] = self.moltype.label
+        kwargs.pop("annotation_db", None)
+        kwargs.pop(
+            "offset", None
+        )  # no need for offset if we dont have an annotation_db
+        kwargs.pop("seqs_data", None)  # we serialise the seqs_data directly
 
         data = {
-            "init_args": init_args,
+            "init_args": kwargs,
             "type": get_object_provenance(self),
             "version": __version__,
         }
-        data["seqs_data"] = self.seqs.to_rich_dict()
+        data["seqs"] = {self._name_map[s.name]: str(s) for s in self.seqs}
 
         return data
 
@@ -805,42 +878,39 @@ def from_rich_dict(
         cls, data: dict[str, str | dict[str, str]]
     ) -> SequenceCollection:
         """returns a new instance from a rich dict"""
-        seqs_data = deserialise_object(data["seqs_data"])
-        data["init_args"].pop("annotation_db", None)
-        moltype = data["init_args"].pop("moltype")
-        moltype = new_moltype.get_moltype(moltype)
-
-        return cls(seqs_data=seqs_data, **data["init_args"], moltype=moltype)
+        return make_unaligned_seqs(data["seqs"], **data["init_args"])
 
     def to_json(self):
         """returns json formatted string"""
         return json.dumps(self.to_rich_dict())
 
     def degap(self) -> SequenceCollection:
-        """Returns new collection in which sequences have no gaps.
+        """Returns new collection in which sequences have no gaps or missing
+        characters.
 
         Notes
         -----
         The returned collection will not retain an annotation_db if present.
         """
-        seqs = {
-            name: self.moltype.degap(self.seqs.get_seq_array(seqid=name))
-            for name in self.names
-        }
-        seqs_data = self.seqs.__class__(
-            data=coerce_to_seqs_data_dict(seqs),
-            alphabet=self.seqs.alphabet,
-            make_seq=self.seqs.make_seq,
-            reversed_seqs=self.seqs.reversed,
-        )
-        return self.__class__(
-            seqs_data=seqs_data,
-            moltype=self.moltype,
-            info=self.info,
-            source=self.source,
+        data = {}
+        for name in self.names:
+            # because we are in a SequenceCollection, which cannot be sliced, so
+            # we can just interrogate the bound _seqs_data directly.
+            seq = self._seqs_data.get_seq_array(seqid=self._name_map.get(name, name))
+            data[name] = self.moltype.degap(seq)
+
+        init_kwargs = self._get_init_kwargs()
+        init_kwargs.pop("annotation_db", None)
+        init_kwargs["seqs_data"] = self._seqs_data.from_seqs(
+            data=data,
+            alphabet=self._seqs_data.alphabet,
+            offset=self._seqs_data.offset,
+            check=False,
         )
 
-    def to_moltype(self, moltype: str) -> SequenceCollection:
+        return self.__class__(**init_kwargs)
+
+    def to_moltype(self, moltype: MolTypes) -> SequenceCollection:
         """returns copy of self with changed moltype
 
         Parameters
@@ -859,18 +929,17 @@ def to_moltype(self, moltype: str) -> SequenceCollection:
 
         alpha = mtype.most_degen_alphabet()
         try:
-            new_seqs_data = self.seqs.to_alphabet(alpha)
-        except ValueError as e:
-            raise ValueError(
+            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,
-            annotation_db=self.annotation_db,
-        )
+        init_kwargs = self._get_init_kwargs()
+        init_kwargs["seqs_data"] = new_seqs_data
+        init_kwargs["moltype"] = mtype
+
+        return self.__class__(**init_kwargs)
 
     def to_dna(self):
         """returns copy of self as a collection of DNA moltype seqs"""
@@ -913,67 +982,53 @@ def get_translation(
         -----
         Translating will break the relationship to an annotation_db if present.
         """
-
-        if len(self.moltype.alphabet) != 4:
-            raise new_alphabet.AlphabetError("Sequences must be a DNA/RNA")
+        if not self.moltype.is_nucleic:
+            raise new_moltype.MolTypeError(
+                f"moltype must be a DNA/RNA, not {self.moltype.name!r}"
+            )
 
         translated = {}
         # do the translation
-        for seqname in self.names:
-            seq = self.seqs[seqname]
+        for seq in self.seqs:
             pep = seq.get_translation(
                 gc,
                 incomplete_ok=incomplete_ok,
                 include_stop=include_stop,
                 trim_stop=trim_stop,
             )
-            translated[seqname] = pep
-
-        seqs_data = coerce_to_seqs_data_dict(translated, label_to_name=None)
-        pep_moltype = pep.moltype
+            translated[seq._seq.seqid] = numpy.array(pep)
 
-        seqs_data = self.seqs.__class__(
-            data=seqs_data,
+        pep_moltype = new_moltype.get_moltype(
+            "protein_with_stop" if include_stop else "protein"
+        )
+        seqs_data = self._seqs_data.from_seqs(
+            data=translated,
             alphabet=pep_moltype.most_degen_alphabet(),
-            make_seq=pep_moltype.make_seq,
+            offset=self._seqs_data.offset,
         )
         return self.__class__(
             seqs_data=seqs_data,
             moltype=pep_moltype,
+            name_map=self._name_map,
             info=self.info,
             source=self.source,
+            is_reversed=False,  # reversed not meaningful for a protein
             **kwargs,
         )
 
     def rc(self):
         """Returns the reverse complement of all sequences in the collection.
         A synonym for reverse_complement.
-
-        Notes
-        -----
-        Reverse complementing the collection will break the relationship to an
-        annotation_db if present.
-
         """
-        return self.__class__(
-            seqs_data=self.seqs.reverse_seqs(),
-            names=self.names,
-            info=self.info,
-            moltype=self.moltype,
-            source=self.source,
-        )
+        init_kwargs = self._get_init_kwargs()
+        init_kwargs["is_reversed"] = not self._is_reversed
+        return self.__class__(**init_kwargs)
 
     def reverse_complement(self):
         """Returns the reverse complement of all sequences in the collection.
         A synonym for rc.
-
-        Notes
-        -----
-        Reverse complementing the collection will break the relationship to an
-        annotation_db if present.
-
-        """
-        return self.rc()
+        """
+        return self.rc()
 
     def distance_matrix(self, calc: str = "pdist"):
         """Estimated pairwise distance between sequences
@@ -1049,8 +1104,6 @@ def copy_annotations(self, seq_db: SupportsFeatures) -> None:
         if self.annotation_db is None:
             self.annotation_db = type(seq_db)()
 
-        # refactor
-        # if we update a db with the same db, it just hangs... need to fix
         if self.annotation_db.compatible(seq_db, symmetric=False):
             # our db contains the tables in other, so we update in place
             self.annotation_db.update(annot_db=seq_db, seqids=self.names)
@@ -1115,9 +1168,6 @@ def add_feature(
         -------
         Feature
         """
-        if not self.annotation_db:
-            self.annotation_db = DEFAULT_ANNOTATION_DB()
-
         if seqid and seqid not in self.names:
             raise ValueError(f"unknown {seqid=}")
 
@@ -1166,14 +1216,11 @@ def get_features(
         strictly starting after start will be returned.
 
         """
+
         if not self.annotation_db:
             return None
 
-        # create a map between original seqid and current seqname (if renamed)
-        seqid_to_seqname = {seq.parent_coordinates()[0]: seq.name for seq in self.seqs}
-        if seqid and (
-            seqid not in seqid_to_seqname and seqid not in seqid_to_seqname.values()
-        ):
+        if seqid and (seqid not in self.names):
             raise ValueError(f"unknown {seqid=}")
 
         for feature in self.annotation_db.get_features_matching(
@@ -1186,7 +1233,7 @@ def get_features(
             allow_partial=allow_partial,
             **kwargs,
         ):
-            seqname = seqid_to_seqname[feature["seqid"]]
+            seqname = feature["seqid"]
             seq = self.seqs[seqname]
             if offset := seq.annotation_offset:
                 feature["spans"] = (numpy.array(feature["spans"]) - offset).tolist()
@@ -1262,6 +1309,7 @@ def dotplot(
         width: int = 500,
         title: OptStr = None,
         rc: bool = False,
+        biotype: typing.Union[str, tuple[str]] = "gene",
         show_progress: bool = False,
     ):
         """make a dotplot between specified sequences. Random sequences
@@ -1291,6 +1339,8 @@ def dotplot(
         rc
             include dotplot of reverse compliment also. Only applies to Nucleic
             acids moltypes
+        biotype
+            if selected sequences are annotated, display only these biotypes
 
         Returns
         -------
@@ -1318,16 +1368,9 @@ def dotplot(
 
         seq1 = self.get_seq(seqname=name1, copy_annotations=False)
         seq2 = self.get_seq(seqname=name2, copy_annotations=False)
-
-        if seq1.is_annotated() or seq2.is_annotated():
-            annotated = True
-            data = getattr(seq1, "data", seq1)
-            bottom = data.get_drawable()
-            data = getattr(seq2, "data", seq2)
-            left = data.get_drawable(vertical=True)
-        else:
-            annotated = False
-
+        annotated = seq1.is_annotated(biotype=biotype) or seq2.is_annotated(
+            biotype=biotype
+        )
         dotplot = Dotplot(
             seq1,
             seq2,
@@ -1346,6 +1389,10 @@ def dotplot(
         )
 
         if annotated:
+            data = getattr(seq1, "data", seq1)
+            bottom = data.get_drawable(biotype=biotype)
+            data = getattr(seq2, "data", seq2)
+            left = data.get_drawable(biotype=biotype, vertical=True)
             dotplot = AnnotatedDrawable(
                 dotplot,
                 left_track=left,
@@ -1356,6 +1403,7 @@ def dotplot(
                 xrange=[0, len(seq1)],
                 yrange=[0, len(seq2)],
             )
+
         return dotplot
 
     @UI.display_wrap
@@ -1433,19 +1481,22 @@ def trim_stop_codons(self, gc: Any = 1, strict: bool = False):
         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}
-        seqs_data = self.seqs.__class__(
-            data=coerce_to_seqs_data_dict(new_seqs),
-            alphabet=self.seqs.alphabet,
-            make_seq=self.seqs.make_seq,
-            reversed_seqs=self.seqs.reversed,
-        )
-        result = self.__class__(
-            seqs_data=seqs_data,
-            moltype=self.moltype,
-            info=self.info,
-            source=self.source,
+        new_seqs = {
+            s.name: s.trim_stop_codon(gc=gc, strict=strict).to_array(
+                apply_transforms=False
+            )
+            for s in self.seqs
+        }
+
+        init_kwargs = self._get_init_kwargs()
+        init_kwargs.pop("annotation_db", None)
+        init_kwargs["seqs_data"] = self._seqs_data.from_seqs(
+            data=new_seqs,
+            alphabet=self._seqs_data.alphabet,
+            offset=self._seqs_data.offset,
+            check=False,
         )
+        result = self.__class__(**init_kwargs)
         if self.annotation_db:
             result.annotation_db = self.annotation_db
         return result
@@ -1532,7 +1583,7 @@ def counts(
 
     def get_motif_probs(
         self,
-        alphabet: new_alphabet.CharAlphabet = None,
+        alphabet: new_alphabet.AlphabetABC = None,
         include_ambiguity: bool = False,
         exclude_unobserved: bool = False,
         allow_gap: bool = False,
@@ -1699,7 +1750,7 @@ def get_lengths(
         return counts.row_sum()
 
     def pad_seqs(self, pad_length: OptInt = None):
-        """Returns copy in which sequences are padded to same length.
+        """Returns copy in which sequences are padded with the gap character to same length.
 
         Parameters
         ----------
@@ -1708,41 +1759,50 @@ def pad_seqs(self, pad_length: OptInt = None):
             length if pad_length is None or less than max length.
         """
 
-        max_len = max(self.seqs.seq_lengths().values())
+        max_len = max(
+            self._seqs_data.get_seq_length(n) for n in self._name_map.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
+            pad_length = max_len
 
-        seqs_data = self.seqs.__class__(
-            data=new_seqs,
-            alphabet=self.seqs.alphabet,
-            make_seq=self.seqs.make_seq,
-            reversed_seqs=self.seqs.reversed,
-        )
-        return self.__class__(
-            seqs_data=seqs_data,
-            moltype=self.moltype,
-            info=self.info,
-            source=self.source,
-            annotation_db=self.annotation_db,
+        padded_seqs = {}
+        for seq in self.seqs:
+            padded_seq = numpy.full(
+                shape=pad_length,
+                fill_value=self.moltype.gapped_alphabet.gap_index,
+                dtype=self.moltype.most_degen_alphabet().dtype,
+            )
+            padded_seq[: len(seq)] = numpy.array(seq)
+            # the padded_seqs dict will be used to create the seqs_data, so the
+            # keys should be the seqids from the original seqs_data, if this differs
+            # from the seq name, this will be recorded in the name_map
+            parent_name = seq._seq.seqid
+            padded_seqs[parent_name] = padded_seq
+
+        init_kwargs = self._get_init_kwargs()
+        # when we access .seqs, if the collection has been reversed, this will have
+        # been applied to the returned Sequence, so we reset it to False for the
+        # returned collection
+        init_kwargs["is_reversed"] = False
+        init_kwargs["seqs_data"] = self._seqs_data.from_seqs(
+            data=padded_seqs,
+            alphabet=self._seqs_data.alphabet,
+            offset=self._seqs_data.offset,
         )
+        return self.__class__(**init_kwargs)
 
     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
+        return (
+            len(set(self._seqs_data.get_seq_length(n) for n in self._name_map.values()))
+            > 1
+        )
 
     def has_terminal_stop(self, gc: Any = None, strict: bool = False) -> bool:
         """Returns True if any sequence has a terminal stop codon.
@@ -1909,10 +1969,16 @@ def __str__(self):
 
         return FORMATTERS["fasta"](self.to_dict())
 
-    def __eq__(
-        self, other: Union[SequenceCollection, dict]
-    ) -> bool:  # refactor: design
-        return id(self) == id(other)
+    def __eq__(self, other: SequenceCollection) -> bool:
+        self_init = self._get_init_kwargs()
+        other_init = other._get_init_kwargs()
+        for key, self_val in self_init.items():
+            if key in ("annotation_db", "slice_record"):
+                continue
+            other_val = other_init.get(key)
+            if self_val != other_val:
+                return False
+        return True
 
     def __ne__(self, other: SequenceCollection) -> bool:
         return not self.__eq__(other)
@@ -2005,7 +2071,9 @@ def to_html(
             colors=colors, font_size=font_size, font_family=font_family
         )
 
-        seq_lengths = numpy.array(list(self.seqs.seq_lengths().values()))
+        seq_lengths = numpy.array(
+            list(self._seqs_data.get_seq_length(n) for n in self._name_map.values())
+        )
         min_val = seq_lengths.min()
         max_val = seq_lengths.max()
         med_val = numpy.median(seq_lengths)
@@ -2204,92 +2272,136 @@ def _(seqs: dict) -> SupportsFeatures:
     return merged_db_collection(seqs.values())
 
 
+@dataclasses.dataclass
+class raw_seq_data:
+    seq: StrORBytesORArray
+    name: OptStr = None
+    parent_name: OptStr = None
+    offset: int = 0
+    is_reversed: bool = False
+
+
 @singledispatch
-def coerce_to_seqs_data_dict(
-    data, label_to_name: OptRenamerCallable = None
-) -> dict[str, StrORBytesORArray]:
-    # refactor: handle conversion of SeqView to SeqDataView.
-    raise NotImplementedError(
-        f"coerce_to_seqs_data_dict not implemented for {type(data)}"
-    )
+def coerce_to_raw_seq_data(
+    seq, moltype: new_moltype.MolType, name: OptStr = None
+) -> raw_seq_data:
+    if isinstance(seq, Aligned):
+        name = name or seq.name
+        seq = str(seq)
+        return coerce_to_raw_seq_data(seq, moltype, name)
+    raise TypeError(f"coerce_to_seq_data not implemented for {type(seq)}")
 
 
-@coerce_to_seqs_data_dict.register
+@coerce_to_raw_seq_data.register
 def _(
-    data: dict, label_to_name: OptRenamerCallable = None
-) -> dict[str, StrORBytesORArray]:
-    is_sequence = isinstance(next(iter(data.values()), None), new_sequence.Sequence)
-    return {
-        (label_to_name(k) if label_to_name else k): (
-            numpy.array(v) if is_sequence else v
-        )
-        for k, v in data.items()
-    }
+    seq: new_sequence.Sequence, moltype: new_moltype.MolType, name: str
+) -> raw_seq_data:
+    seq = seq.to_moltype(moltype)
+    parent_name, start, _, step = seq.parent_coordinates()
+    # we always get the "plus strand" seq
+    # because reverse complementing is done via the Sequence instance
+    # and the orientation with respect to records in a bound db needs
+    # to be preserved (that annotation db is extracted elsewhere)
+    raw_seq = seq.to_array(apply_transforms=False)
+    return raw_seq_data(
+        seq=raw_seq,
+        name=name or seq.name,
+        parent_name=parent_name,
+        offset=start,
+        is_reversed=step < 0,
+    )
 
 
-@coerce_to_seqs_data_dict.register
-def _(
-    data: list, label_to_name: OptRenamerCallable = None
-) -> dict[str, StrORBytesORArray]:
-    first = data[0]
-    labelled_seqs = assign_names(first, data=data)
-    return coerce_to_seqs_data_dict(labelled_seqs, label_to_name=label_to_name)
+@coerce_to_raw_seq_data.register
+def _(seq: str, moltype: new_moltype.MolType, name: OptStr = None) -> raw_seq_data:
+    return raw_seq_data(seq=seq, name=name)
 
 
-@coerce_to_seqs_data_dict.register
+@coerce_to_raw_seq_data.register
 def _(
-    data: set, label_to_name: OptRenamerCallable = None
-) -> dict[str, StrORBytesORArray]:
-    first = next(iter(data))
-    labelled_seqs = assign_names(first, data=data)
-    return coerce_to_seqs_data_dict(labelled_seqs, label_to_name=label_to_name)
+    seq: numpy.ndarray, moltype: new_moltype.MolType, name: OptStr = None
+) -> raw_seq_data:
+    return raw_seq_data(seq=seq, name=name)
 
 
-@coerce_to_seqs_data_dict.register
-def _(
-    data: SequenceCollection, label_to_name: OptRenamerCallable = None
-) -> dict[str, StrORBytesORArray]:
-    return coerce_to_seqs_data_dict(
-        data.to_dict(as_array=True), label_to_name=label_to_name
-    )
+@coerce_to_raw_seq_data.register
+def _(seq: bytes, moltype: new_moltype.MolType, name: OptStr = None) -> raw_seq_data:
+    return raw_seq_data(seq=seq, name=name)
+
+
+CT = tuple[dict[str, StrORBytesORArray], dict[str, int], int, dict[str, str]]
 
 
 @singledispatch
-def assign_names(
-    first, data: Union[list, set]
-) -> dict[str, Union[StrORBytesORArray, new_sequence.Sequence]]:
-    if isinstance(first, (str, bytes, numpy.ndarray)):
-        names = assign_sequential_names(len(data))
-        return dict(zip(names, data))
-    raise NotImplementedError(f"assign_names not implemented for {type(first)}")
+def prep_for_seqs_data(data, moltype: new_moltype.MolType, seq_namer: _SeqNamer) -> CT:
+    # refactor: handle conversion of SeqView to SeqDataView.
+    raise NotImplementedError(
+        f"coerce_to_seqs_data_dict not implemented for {type(data)}"
+    )
 
 
-@assign_names.register
-def _(
-    first: new_sequence.Sequence, data: Union[list, set]
-) -> dict[str, new_sequence.Sequence]:
-    return {seq.name: seq for seq in data}
+@prep_for_seqs_data.register
+def _(data: dict, moltype: new_moltype.MolType, seq_namer: _SeqNamer) -> CT:
+    seqs = {}  # for the (Aligned)SeqsDataABC
+    offsets = {}  # for the (Aligned)SeqsDataABC
+    rvd = 0
+    name_map = {}  # for the sequence collection
+    # if we have a dict of Sequences, {name: seq, ...}, then the provided names
+    # may differ to the name attribute on the Sequences. If the Sequences have
+    # annotations, then we will need to map between the names in order to
+    # query the annotation database. We do this by creating a name_map.
+    # Note that we do (seq.name or name) to handle when the Sequence name is
+    # None
+    for name, seq in data.items():
+        name = seq_namer(seq=seq, name=name)
+        seq_data = coerce_to_raw_seq_data(seq, moltype, name=name)
+        offsets[seq_data.parent_name or name] = seq_data.offset
+        seqs[seq_data.parent_name or seq_data.name] = seq_data.seq
+        rvd += 1 if seq_data.is_reversed else 0
+        name_map[name] = seq_data.parent_name or name
 
+    return seqs, offsets, rvd, name_map
 
-@assign_names.register
-def _(first: list, data: Union[list, set]) -> dict[str, StrORBytesORArray]:
-    return {name_seq[0]: name_seq[1] for name_seq in data}
 
+@prep_for_seqs_data.register
+def _(data: list, moltype: new_moltype.MolType, seq_namer: _SeqNamer) -> CT:
+    if not isinstance(data[0], new_sequence.Sequence):
+        with contextlib.suppress(ValueError):
+            return prep_for_seqs_data(dict(data), moltype, seq_namer)
 
-@assign_names.register
-def _(first: tuple, data: Union[list, set]) -> dict[str, StrORBytesORArray]:
-    return {name_seq[0]: name_seq[1] for name_seq in data}
+    result = {seq_namer(seq=record): record for record in data}
+    return prep_for_seqs_data(result, moltype, seq_namer)
+
+
+@prep_for_seqs_data.register
+def _(data: tuple, moltype: new_moltype.MolType, seq_namer: _SeqNamer) -> CT:
+    return prep_for_seqs_data(list(data), moltype, seq_namer)
+
+
+@prep_for_seqs_data.register
+def _(data: set, moltype: new_moltype.MolType, seq_namer: _SeqNamer) -> CT:
+    return prep_for_seqs_data(list(data), moltype, seq_namer)
+
+
+@prep_for_seqs_data.register
+def _(
+    data: SequenceCollection, moltype: new_moltype.MolType, seq_namer: _SeqNamer
+) -> CT:
+    return prep_for_seqs_data({seq_namer(s): s for s in data.seqs}, moltype, seq_namer)
 
 
 @singledispatch
 def make_unaligned_seqs(
-    data: Union[dict[str, StrORBytesORArray], SeqsData, list],
+    data: Union[dict[str, StrORBytesORArray], list, SeqsDataABC],
     *,
     moltype: Union[str, new_moltype.MolType],
     label_to_name: OptRenamerCallable = None,
-    info: dict = None,
+    info: OptDict = None,
     source: OptPathType = None,
     annotation_db: SupportsFeatures = None,
+    offset: typing.Optional[DictStrInt] = None,
+    name_map: typing.Optional[DictStrStr] = None,
+    is_reversed: OptBool = None,
 ) -> SequenceCollection:
     """Initialise an unaligned collection of sequences.
 
@@ -2308,40 +2420,77 @@ def make_unaligned_seqs(
         and added to info["source"].
     annotation_db
         annotation database to attach to the collection
+    offset
+        a dict mapping names to annotation offsets
+    name_map
+        a dict mapping sequence names to "parent" sequence names. The parent
+        name will be used for querying a annotation_db.
+    is_reversed
+        flag indicating whether the sequences are reversed with respect to the
+        underlying data.
 
     Notes
     -----
-    - If no annotation_db is provided, but the sequences are annotated, an
+    If no annotation_db is provided, but the sequences are annotated, an
     annotation_db is created by merging any annotation db's found in the sequences.
-    - If the sequences are annotated AND an annotation_db is provided, only the
+    If the sequences are annotated AND an annotation_db is provided, only the
     annotation_db is used.
-
     """
-    # refactor: design/simplify
-    # the dispatches above handle the different type of data that the previous
-    # make_unaligned_seqs handled. This includes dicts, lists (where items are
-    # either pairs of [name, seq], or just seqs), tuples, Sequences, etc.
-    # We could simplify it greatly by only supporting dicts, SeqsDataABC,
-    # or SequenceCollections.
+    # refactor: design
+    # rename offset to offsets as it could track potentially multiple offsets
+
+    # refactor: design
+    # define a source attribute rather than storing as .info["source"]
+    # this will also replace the previous name attribute
+
+    # refactor: design
+    # currently reversal can only be applied to the entire collection.
+    # This should be made more flexible.
+
+    moltype = new_moltype.get_moltype(moltype)
+    alphabet = moltype.most_degen_alphabet()
 
     if len(data) == 0:
         raise ValueError("data must be at least one sequence.")
 
     annotation_db = annotation_db or merged_db_collection(data)
 
-    seqs_data = coerce_to_seqs_data_dict(data, label_to_name=label_to_name)
-
-    moltype = new_moltype.get_moltype(moltype)
-    alphabet = moltype.most_degen_alphabet()
+    # if we have Sequences, we need to construct the name map before we construct
+    # the SeqsData object - however, if a name_map is provided, we assume that it
+    # corrects for any naming differences in data and skip this step
+    assign_names = _SeqNamer(name_func=label_to_name)
+    seqs_data, offs, rvd, nm = prep_for_seqs_data(data, moltype, assign_names)
+    offset = offset or {}
+    offset = {**offs, **offset}
+    # rvd is the count of sequences that have been reversed. It should be equal 0
+    # or the number of sequences. If inbetween, the sequences are a mix of the two
+    # which for now we do not support. However, if the user has provided a value
+    # for is_reversed, we will use that.
+    if is_reversed is not None:
+        rvd = is_reversed
+    elif rvd in {0, len(seqs_data)}:
+        rvd = bool(rvd)
+    else:
+        rvd = False
+        if annotation_db and len(annotation_db) > 0:
+            warnings.warn(
+                "Sequence strand is inconsistent, not applying the annotation db.",
+                UserWarning,
+            )
+            annotation_db = None
 
-    seqs_data = SeqsData(data=seqs_data, alphabet=alphabet)
+    name_map = nm if name_map is None else name_map
+    # seqs_data keys should be the same as the value of name_map, not the keys
+    seqs_data = SeqsData(data=seqs_data, alphabet=alphabet, offset=offset)
+    # we do not pass on offset/label_to_name as they are handled in this function
     return make_unaligned_seqs(
         seqs_data,
         moltype=moltype,
-        label_to_name=label_to_name,
         info=info,
         source=source,
         annotation_db=annotation_db,
+        name_map=name_map,
+        is_reversed=rvd,
     )
 
 
@@ -2354,6 +2503,9 @@ def _(
     info: dict = None,
     source: OptPathType = None,
     annotation_db: SupportsFeatures = None,
+    offset: dict[str, int] = None,
+    name_map: dict[str, str] = None,
+    is_reversed: OptBool = None,
 ) -> SequenceCollection:
     moltype = new_moltype.get_moltype(moltype)
     if not moltype.is_compatible_alphabet(data.alphabet):
@@ -2361,16 +2513,3924 @@ def _(
             f"Provided moltype: {moltype} is not compatible with SeqsData alphabet {data.alphabet}"
         )
 
-    info = info if isinstance(info, dict) else {}
-    if source:
-        info["source"] = str(source)
-    else:
-        info["source"] = str(info.get("source", "unknown"))
+    # we cannot set offset when creating from an SeqsData
+    if offset:
+        raise ValueError(f"Setting offset is not supported for {data=}")
 
-    return SequenceCollection(
+    info = info if isinstance(info, dict) else {}
+    info["source"] = str(source) if source else str(info.get("source", "unknown"))
+    seqs = SequenceCollection(
         seqs_data=data,
         moltype=moltype,
         info=info,
         annotation_db=annotation_db,
         source=source,
+        name_map=name_map,
+        is_reversed=is_reversed or False,
+    )
+    if label_to_name:
+        seqs = seqs.rename_seqs(label_to_name)
+    return seqs
+
+
+@singledispatch
+def decompose_gapped_seq(
+    seq: typing.union[StrORBytesORArray, new_sequence.Sequence],
+    *,
+    alphabet: new_alphabet.AlphabetABC,
+) -> tuple[numpy.ndarray, numpy.ndarray]:
+    """
+    Takes a sequence with (or without) gaps and returns an ungapped sequence
+    and a map of the position and length of gaps in the original parent sequence
+    """
+    raise NotImplementedError(
+        f"decompose_gapped_seq not implemented for type {type(seq)}"
     )
+
+
+@decompose_gapped_seq.register
+def _(
+    seq: numpy.ndarray,
+    *,
+    alphabet: new_alphabet.AlphabetABC,
+) -> tuple[numpy.ndarray, numpy.ndarray]:
+    return decompose_gapped_seq_array(seq.astype(alphabet.dtype), alphabet.gap_index)
+
+
+@decompose_gapped_seq.register
+def _(
+    seq: str, *, alphabet: new_alphabet.AlphabetABC
+) -> tuple[numpy.ndarray, numpy.ndarray]:
+    if not alphabet.is_valid(seq):
+        raise new_alphabet.AlphabetError(f"Sequence is invalid for alphabet {alphabet}")
+
+    return decompose_gapped_seq(alphabet.to_indices(seq), alphabet=alphabet)
+
+
+@decompose_gapped_seq.register
+def _(
+    seq: bytes, *, alphabet: new_alphabet.AlphabetABC
+) -> tuple[numpy.ndarray, numpy.ndarray]:
+    return decompose_gapped_seq(seq.decode("utf-8"), alphabet=alphabet)
+
+
+@decompose_gapped_seq.register
+def _(
+    seq: new_sequence.Sequence, *, alphabet: new_alphabet.AlphabetABC
+) -> tuple[numpy.ndarray, numpy.ndarray]:
+    return decompose_gapped_seq(numpy.array(seq), alphabet=alphabet)
+
+
+@numba.jit(cache=True)
+def decompose_gapped_seq_array(
+    seq: numpy.ndarray,
+    gap_index: int,
+    missing_index: int = -1,
+) -> tuple[numpy.ndarray, numpy.ndarray]:  # pragma: no cover
+    """
+    extracts the ungapped sequence and gap data from a gapped sequence
+
+    Parameters
+    ----------
+    seq
+        numpy array representing a gapped sequence
+    gap_index
+        from an alphabet
+    missing_index
+        from an alphabet, represents index for missing character
+
+    Returns
+    -------
+    ungapped, [[gap_pos in sequence coords, cumulative gap length]]
+
+    Notes
+    -----
+    being called by decompose_gapped_seq
+    A missing_index is an ambiguity code that includes the gap character.
+    Be careful in providing this value when dealing with sequences that
+    may have had a feature masking applied.
+    """
+    seqlen = len(seq)
+    working = numpy.empty((seqlen, numpy.int64(2)), dtype=numpy.int64)
+
+    in_gap = False
+    num_gaps = 0
+    start = 0
+    for i, base in enumerate(seq):
+        gapped = base == gap_index or base == missing_index
+        if gapped and not in_gap:
+            start = i
+            in_gap = True
+        elif not gapped and in_gap:
+            working[num_gaps][:] = start, i - start
+            num_gaps += 1
+            in_gap = False
+
+        if gapped and i == seqlen - 1:
+            # end of sequence
+            working[num_gaps][:] = start, i - start + 1
+            num_gaps += 1
+
+    if num_gaps == 0:
+        return seq, numpy.empty((0, 2), dtype=numpy.int64)
+
+    gap_coords = working[:num_gaps]
+    gap_coords.T[1] = gap_coords.T[1].cumsum()
+    # get gap start positions in sequence coords
+    for index, cum_length in enumerate(numpy.append(0, gap_coords.T[1][:-1])):
+        gap_coords[index][0] -= cum_length
+
+    ungapped = numpy.empty(seqlen - gap_coords.T[1][-1], dtype=seq.dtype)
+    seqpos = 0
+    for b in seq:
+        if b != gap_index:
+            ungapped[seqpos] = b
+            seqpos += 1
+
+    return ungapped, gap_coords
+
+
+@numba.jit(cache=True)
+def compose_gapped_seq(
+    ungapped_seq: numpy.ndarray, gaps: numpy.ndarray, gap_index: int
+) -> numpy.ndarray:  # pragma: no cover
+    """reconstruct a gapped sequence from an ungapped sequence and gap data"""
+    if not len(gaps):
+        return ungapped_seq
+
+    gapped_len = len(ungapped_seq) + gaps[-1, 1]
+
+    gapped_seq = numpy.empty(gapped_len, dtype=ungapped_seq.dtype)
+
+    pos = 0
+    ungapped_pos = 0
+    prev_gap_len = 0
+    for gap_pos, cum_gap_len in gaps:
+        gap_len = cum_gap_len - prev_gap_len
+        prev_gap_len = cum_gap_len
+
+        gapped_seq[pos : pos + gap_pos - ungapped_pos] = ungapped_seq[
+            ungapped_pos:gap_pos
+        ]
+        pos += gap_pos - ungapped_pos
+        ungapped_pos = gap_pos
+
+        gapped_seq[pos : pos + gap_len] = gap_index
+        pos += gap_len
+
+    gapped_seq[pos:] = ungapped_seq[ungapped_pos:]
+
+    return gapped_seq
+
+
+class Aligned:
+    """A single sequence in an alignment."""
+
+    __slots__ = ("_data", "_moltype", "_name", "_annotation_db")
+
+    def __init__(
+        self,
+        data: AlignedDataView,
+        moltype: new_moltype.MolType,
+        name: OptStr = None,
+        annotation_db: typing.Optional[SupportsFeatures] = None,
+    ):
+        self._data = data
+        self._moltype = moltype
+        self._name = name or data.seqid
+        self._annotation_db = annotation_db
+
+    def __len__(self) -> int:
+        return len(self.map)
+
+    @property
+    def data(self) -> AlignedDataView:
+        return self._data
+
+    @property
+    def map(self) -> IndelMap:
+        return self.data.map
+
+    @property
+    def seq(self) -> new_sequence.Sequence:
+        """Returns Sequence object, excluding gaps."""
+        # if the slice record has abs(step) > 1, we cannot retain a connection
+        # to the underlying aligned seq data container because the gaps are
+        # not going to be modulo the step.
+        if self.data.slice_record.plus_step == 1:
+            # to complement or not handled by seq view
+            seq = self.data.get_seq_view()
+        elif self.data.slice_record.step > 1:
+            # we have a step, but no complementing will be required
+            seq = self.moltype.degap(self.data.gapped_array_value)
+        else:
+            # gapped_array_value gives the reverse of the plus strand
+            # so we need to complement it. We do that here because with a
+            # step != 1, we cannot retain a connection to the underlying
+            # annotations
+            seq = self.moltype.degap(self.data.gapped_array_value)
+            seq = self.moltype.complement(seq)
+
+        mt_seq = self.moltype.make_seq(seq=seq, name=self.data.seqid)
+        ann_db = self.annotation_db if self.data.slice_record.plus_step == 1 else None
+        mt_seq.replace_annotation_db(ann_db)
+        return mt_seq
+
+    @property
+    def gapped_seq(self) -> new_sequence.Sequence:
+        """Returns Sequence object, including gaps."""
+        seq = self.data.gapped_array_value
+        if self.data.slice_record.step < 0:
+            seq = self.moltype.complement(seq)
+        return self.moltype.make_seq(seq=seq, name=self.data.seqid)
+
+    @property
+    def moltype(self) -> new_moltype.MolType:
+        return self._moltype
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @property
+    def annotation_db(self):
+        return self._annotation_db
+
+    @annotation_db.setter
+    def annotation_db(self, value: SupportsFeatures):
+        if value == self._annotation_db:
+            return
+
+        self._annotation_db = value
+
+    def gap_vector(self) -> list[bool]:
+        """Returns gap_vector of positions."""
+        return self.gapped_seq.gap_vector()
+
+    def make_feature(self, feature: FeatureDataType, alignment: "Alignment") -> Feature:
+        """returns a feature, not written into annotation_db"""
+        annot = self.seq.make_feature(feature)
+        inverted = self.map.to_feature_map().inverse()
+        # todo should indicate whether tidy or not
+        return annot.remapped_to(alignment, inverted)
+
+    def __str__(self) -> str:
+        return str(self.gapped_seq)
+
+    def __array__(self, dtype=None, copy=None) -> numpy.ndarray:
+        return numpy.array(self.gapped_seq, dtype=dtype)
+
+    def __bytes__(self) -> bytes:
+        return bytes(self.gapped_seq)
+
+    def __iter__(self):
+        """Iterates over sequence one motif (e.g. char) at a time, incl. gaps"""
+        yield from self.gapped_seq
+
+    @singledispatchmethod
+    def __getitem__(self, span: Union[int, slice]):
+        raise NotImplementedError(f"__getitem__ not implemented for {type(span)}")
+
+    @__getitem__.register
+    def _(self, span: int):
+        return self.__getitem__(slice(span, span + 1))
+
+    @__getitem__.register
+    def _(self, span: slice):
+        return self.__class__(data=self.data[span], moltype=self.moltype)
+
+    @__getitem__.register
+    def _(self, span: FeatureMap):
+        # we assume the feature map is in align coordinates
+        data, gaps = self.slice_with_map(span)
+        seqid = self.data.seqid
+        seqs_data = self.data.parent.from_seqs_and_gaps(
+            seqs={seqid: data},
+            gaps={seqid: gaps},
+            alphabet=self.moltype.most_degen_alphabet(),
+        )
+        view = seqs_data.get_view(seqid)
+
+        return Aligned(view, self.moltype)
+
+    def slice_with_map(self, span: FeatureMap) -> tuple[numpy.ndarray, numpy.ndarray]:
+        start, end = span.start, span.end
+        if span.useful and len(list(span.spans)) == 1:
+            im = self.map[start:end]
+            seq_start = self.map.get_seq_index(start)
+            seq_end = self.map.get_seq_index(end)
+            data = self.data.array_value[seq_start:seq_end]
+            # .array_value will return the data in the correct orientation
+            # which means we need to complement it if the data is reversed
+            data = self.moltype.complement(data) if self.data.is_reversed else data
+        elif not span.useful:
+            im = self.map[start:end]
+            data = self.data.array_value[:0]
+        else:
+            # multiple spans
+            align_coords = span.get_coordinates()
+            im = self.map.joined_segments(align_coords)
+            seq_map = self.map.make_seq_feature_map(span)
+            # self.seq will return the data in the correct orientation
+            # and will complement it if the data is reversed
+            data = numpy.array(self.seq.gapped_by_map(seq_map))
+
+        gaps = numpy.array([im.gap_pos, im.cum_gap_lengths]).T
+        return data, gaps
+
+    def parent_coordinates(self, seq_coords=False):
+        """returns seqid, start, stop, strand on the parent sequence
+
+        Parameters
+        ----------
+        seq_coords
+            if True, the coordinates for the unaligned sequence
+        """
+        strand = -1 if self.data.is_reversed else 1
+        seqid = self.data.seqid
+        if not seq_coords:
+            start = self.data.slice_record.parent_start
+            stop = self.data.slice_record.parent_stop
+        else:
+            # AlignedDataView.parent_seq_coords uses it's indelmap, etc..
+            # to return the necessary coordinates
+            seqid, start, stop, strand = self.data.parent_seq_coords()
+
+        return (
+            seqid,
+            start,
+            stop,
+            strand,
+        )
+
+    @extend_docstring_from(new_sequence.Sequence.annotate_matches_to)
+    def annotate_matches_to(
+        self, pattern: str, biotype: str, name: str, allow_multiple: bool = False
+    ):  # noqa
+        return self.seq.annotate_matches_to(
+            pattern=pattern,
+            biotype=biotype,
+            name=name,
+            allow_multiple=allow_multiple,
+        )
+
+    def __repr__(self) -> str:
+        # refactor: design
+        # avoid using map in the repr
+        return f"Aligned(map={self.data.map}, data={self.seq})"
+
+
+class AlignedSeqsDataABC(SeqsDataABC):
+    # all methods that are from SeqsDataABC should work in sequence coordinates,
+    # all methods unique to AlignedSeqsDataABC should work in aligned coordinates.
+    # all indices provided to AlignedSeqsDataABC should be on the plus strand.
+    __slots__ = ()
+
+    @classmethod
+    @abstractmethod
+    def from_seqs_and_gaps(
+        cls,
+        *,
+        seqs: dict[str, StrORBytesORArray],
+        gaps: dict[str, numpy.ndarray],
+        alphabet: new_alphabet.AlphabetABC,
+    ): ...
+
+    @classmethod
+    @abstractmethod
+    def from_names_and_array(
+        cls,
+        *,
+        names: list[str],
+        data: numpy.ndarray,
+        alphabet: new_alphabet.AlphabetABC,
+    ): ...
+
+    @property
+    @abstractmethod
+    def align_len(self) -> int: ...
+
+    @abstractmethod
+    def get_seq_array(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> numpy.ndarray: ...
+
+    @abstractmethod
+    def get_seq_str(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> str: ...
+
+    @abstractmethod
+    def get_seq_bytes(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> bytes: ...
+
+    @abstractmethod
+    def get_gapped_seq_array(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> numpy.ndarray: ...
+
+    @abstractmethod
+    def get_gapped_seq_str(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> str: ...
+
+    @abstractmethod
+    def get_gapped_seq_bytes(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> bytes: ...
+
+    @abstractmethod
+    def get_ungapped(
+        self,
+        name_map: dict[str, str],
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> tuple[dict, dict]:
+        """
+        Returns a dictionary of sequence data with no gaps or missing characters and
+        a dictionary with information to construct a new SequenceCollection via
+        make_unaligned_seqs.
+
+        Parameters
+        ----------
+        name_map
+            A dict of {aln_name: data_name, ...} indicating the mapping between
+            names in the encompassing Alignment (aln_name) and the names in self
+            (data_name).
+        start
+            The alignment starting position.
+        stop
+            The alignment stopping position.
+        step
+            The step size.
+
+        Returns
+        -------
+        tuple
+            A tuple containing the following:
+            - seqs (dict): A dictionary of {name: seq, ...} where the sequences have no gaps
+              or missing characters.
+            - kwargs (dict): A dictionary of keyword arguments for make_unaligned_seqs, e.g.,
+              {"offset": self.offset, "name_map": name_map}.
+        """
+        ...
+
+    @abstractmethod
+    def get_positions(
+        self,
+        names: PySeqStr,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> numpy.ndarray: ...
+
+
+def _gapped_seq_len(seq: numpy.ndarray, gap_map: numpy.ndarray) -> int:
+    """calculate the gapped sequence length from a ungapped sequence and gap map
+
+    Parameters
+    ----------
+    seq
+        numpy array of sequence indices
+    gap_map
+        numpy array of [gap index, cumulative gap length] pairs
+    """
+    try:
+        gap_len = gap_map[-1][1]
+    except IndexError:  # no gaps
+        return len(seq)
+
+    return len(seq) + gap_len
+
+
+class AlignedSeqsData(AlignedSeqsDataABC):
+    # refactor: docstring
+    # refactor: design
+
+    __slots__ = (
+        "_names",
+        "_name_to_index",
+        "_ungapped",
+        "_gapped",
+        "_gaps",
+        "_alphabet",
+        "_align_len",
+        "_offset",
+    )
+
+    def __init__(
+        self,
+        *,
+        gapped_seqs: numpy.ndarray,
+        names: tuple[str],
+        alphabet: new_alphabet.AlphabetABC,
+        ungapped_seqs: Optional[dict[str, numpy.ndarray]] = None,
+        gaps: Optional[dict[str, numpy.ndarray]] = None,
+        offset: Optional[DictStrInt] = None,
+        align_len: OptInt = None,
+        check: bool = True,
+    ):
+        """
+        Parameters
+        ----------
+        gapped_seqs
+            2D numpy.uint8 array of aligned sequences. axis 0 are sequences,
+            axis 1 are alignment positions
+        names
+            sequence names in order matching the axis 0 of gapped_seqs
+        alphabet
+            caharacter alphabet for the sequences
+        ungapped_seqs
+            a dictionary mapping names to 1D numpy.uint8 arrays of individual
+            sequences without gaps. If not provided, computed on demand.
+        gaps
+            a dictionary mapping names to 1D numpy.int32 arrays of gap data,
+            axis 0 is a gap axis 1 is [gap position in sequence coordinates,
+            cumulative gap length].  If not provided, computed on demand.
+        offset
+            a dictionary of annotation offsets
+        align_len
+            length of the alignment, which must equal the gapped_seqs.shape[1]
+        check
+            validate any keys in offset, ungapped_seqs, gaps are a subset of names
+        """
+        self._alphabet = alphabet
+        self._names = tuple(names)
+        self._name_to_index = {name: i for i, name in enumerate(names)}
+        self._gapped = gapped_seqs
+        self._ungapped = ungapped_seqs or {}
+        self._gaps = gaps or {}
+        align_len = align_len or gapped_seqs.shape[1]
+        if align_len:
+            assert align_len == gapped_seqs.shape[1], "mismatch in alignment length"
+
+        self._align_len = align_len
+        self._offset = offset or {}
+
+        if check:
+            if not set(names) >= set(self._gaps.keys()) or not set(names) >= set(
+                self._ungapped.keys()
+            ):
+                raise ValueError(
+                    "Keys in ungapped seqs and gaps must be subsets of names."
+                )
+            if not set(names) >= set(self._offset):
+                raise ValueError("Keys in offset must be a subset of names.")
+
+            if len(names) != gapped_seqs.shape[0]:
+                raise ValueError(f"{len(names)=} != {gapped_seqs.shape[0]=}")
+
+    def __eq__(self, other: AlignedSeqsDataABC) -> bool:
+        if not isinstance(other, self.__class__):
+            return False
+        attrs = (
+            "_names",
+            "_name_to_index",
+            "_alphabet",
+            "_align_len",
+            "_offset",
+        )
+        for attr_name in attrs:
+            self_attr = getattr(self, attr_name)
+            other_attr = getattr(other, attr_name)
+            if self_attr != other_attr:
+                return False
+
+        return numpy.all(self._gapped == other._gapped)
+
+    def __ne__(self, other: object) -> bool:
+        return not self == other
+
+    @classmethod
+    def from_seqs(
+        cls,
+        *,
+        data: dict[str, StrORArray],
+        alphabet: new_alphabet.AlphabetABC,
+        **kwargs,
+    ):
+        """Construct an AlignedSeqsData object from a dict of aligned sequences
+
+        Parameters
+        ----------
+        data
+            dict of gapped sequences {name: seq, ...}. sequences must all be
+            the same length
+        alphabet
+            alphabet object for the sequences
+        """
+        seq_lengths = {len(v) for v in data.values()}
+        if len(seq_lengths) != 1:
+            raise ValueError("All sequence lengths must be the same.")
+
+        align_len = seq_lengths.pop()
+        names = tuple(data.keys())
+        array_seqs = numpy.empty((len(names), align_len), dtype=alphabet.dtype)
+        for i, name in enumerate(names):
+            array_seqs[i] = alphabet.to_indices(data[name])
+            if not alphabet.is_valid(data[name]):
+                raise new_alphabet.AlphabetError(
+                    f"Sequence {name} contains invalid characters."
+                )
+
+        array_seqs.flags.writeable = False
+        return cls(
+            gapped_seqs=array_seqs,
+            alphabet=alphabet,
+            align_len=align_len,
+            check=False,
+            names=names,
+            **kwargs,
+        )
+
+    @classmethod
+    def from_seqs_and_gaps(
+        cls,
+        *,
+        seqs: dict[str, StrORBytesORArray],
+        gaps: dict[str, numpy.ndarray],
+        alphabet: new_alphabet.AlphabetABC,
+        **kwargs,
+    ):
+        """Construct an AlignedSeqsData object from a dict of ungapped sequences
+        and a corresponding dict of gap data.
+
+        Parameters
+        ----------
+        seqs
+            dict of ungapped sequences {name: seq, ...}
+        gaps
+            gap data {name: [[seq gap position, cumulative gap length], ...], ...}
+        alphabet
+            alphabet object for the sequences
+        """
+        names = tuple(kwargs.pop("names", seqs.keys()))
+        if not len(names):
+            raise ValueError("seqs cannot be empty")
+
+        align_len = kwargs.pop("align_len", None)
+        if align_len is None:
+            align_len = _gapped_seq_len(seqs[names[0]], gaps[names[0]])
+
+        gapped_seqs = numpy.empty((len(names), align_len), dtype=alphabet.dtype)
+        for i, name in enumerate(names):
+            seq = alphabet.to_indices(seqs[name])
+            seqs[name] = seq
+            if name not in gaps:
+                raise ValueError(f"Missing gap data for sequence {name!r}")
+            gapped_seqs[i] = compose_gapped_seq(seq, gaps[name], alphabet.gap_index)
+            assert len(gapped_seqs[i]) == align_len, "aligned lengths do not match"
+
+        gapped_seqs.flags.writeable = False
+        return cls(
+            ungapped_seqs=seqs,
+            gaps=gaps,
+            gapped_seqs=gapped_seqs,
+            alphabet=alphabet,
+            names=names,
+            align_len=align_len,
+            **kwargs,
+        )
+
+    @classmethod
+    def from_names_and_array(
+        cls,
+        *,
+        names: PySeq[str],
+        data: numpy.ndarray,
+        alphabet: new_alphabet.AlphabetABC,
+    ):
+        """Construct an AlignedSeqsData object from a list of names and a numpy
+        array of aligned sequence data.
+
+        Parameters
+        ----------
+        names
+            list of sequence names
+        data
+            numpy array of aligned sequence data
+        alphabet
+            alphabet object for the sequences
+        """
+        if len(names) != data.shape[0] or not len(names):
+            raise ValueError("Number of names must match number of rows in data.")
+
+        gapped_seqs = data.astype(alphabet.dtype)
+        gapped_seqs.flags.writeable = False
+        return cls(
+            gapped_seqs=gapped_seqs,
+            names=names,
+            alphabet=alphabet,
+        )
+
+    @property
+    def names(self) -> tuple[str]:
+        return self._names
+
+    @property
+    def alphabet(self) -> new_alphabet.CharAlphabet:
+        return self._alphabet
+
+    @property
+    def align_len(self) -> int:
+        return self._align_len
+
+    @property
+    def offset(self) -> dict[str, int]:
+        """returns the offset of each sequence in the Alignment"""
+        return {name: self._offset.get(name, 0) for name in self.names}
+
+    def __len__(self) -> int:
+        return self.align_len
+
+    @singledispatchmethod
+    def __getitem__(self, index: Union[str, int]) -> AlignedDataViewABC:
+        return self.get_view(index)
+
+    def get_seq_length(self, seqid: str) -> int:
+        """return length of the unaligned seq for seqid"""
+        return len(self._get_ungapped(seqid))
+
+    @singledispatchmethod
+    def get_view(self, seqid: str):
+        return AlignedDataView(
+            parent=self,
+            seqid=seqid,
+            alphabet=self.alphabet,
+        )
+
+    @get_view.register
+    def _(self, seqid: int):
+        return self.get_view(self.names[seqid])
+
+    def _get_gaps(self, seqid: str) -> numpy.ndarray:
+        if seqid not in self._gaps:
+            self._make_gaps_and_ungapped(seqid)
+        return self._gaps[seqid]
+
+    def _get_ungapped(self, seqid: str) -> numpy.ndarray:
+        if seqid not in self._ungapped:
+            self._make_gaps_and_ungapped(seqid)
+        return self._ungapped[seqid]
+
+    def get_gaps(self, seqid: str) -> numpy.ndarray:
+        return self._get_gaps(seqid)
+
+    def _make_gaps_and_ungapped(self, seqid):
+        index = self._name_to_index[seqid]
+        ungapped, gaps = decompose_gapped_seq(
+            self._gapped[index], alphabet=self.alphabet
+        )
+        self._gaps[seqid] = gaps
+        self._ungapped[seqid] = ungapped
+
+    def get_seq_array(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> numpy.ndarray:
+        """Return ungapped sequence corresponding to seqid as an array of indices.
+        assumes start/stop are in sequence coordinates. Excludes gaps.
+        """
+        start = start or 0
+        stop = stop if stop is not None else self.get_seq_length(seqid)
+        step = step or 1
+
+        assert (
+            start >= 0 and stop >= 0 and step > 0
+        ), "start, stop, and step must be >= 0"
+
+        out_len = (stop - start + step - 1) // step
+
+        seq = numpy.empty(out_len, dtype=self.alphabet.dtype)
+        seq[:] = self._get_ungapped(seqid)[start:stop:step]
+
+        return seq
+
+    def get_gapped_seq_array(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> numpy.ndarray:
+        """Return sequence data corresponding to seqid as an array of indices.
+        start/stop are in alignment coordinates. Includes gaps.
+        """
+        start = start or 0
+        stop = stop if stop is not None else self.align_len
+        step = step or 1
+        assert (
+            start >= 0 and stop >= 0 and step > 0
+        ), "start, stop, and step must be >= 0"
+
+        index = self._name_to_index[seqid]
+        out_len = (stop - start + step - 1) // step
+        gapped = numpy.empty(out_len, dtype=self.alphabet.dtype)
+        gapped[:] = self._gapped[index][start:stop:step]
+
+        return gapped
+
+    def get_seq_str(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> str:
+        """Return ungapped sequence corresponding to seqid as a string.
+        start/stop are in sequence coordinates. Excludes gaps."""
+        return self.alphabet.from_indices(
+            self.get_seq_array(seqid=seqid, start=start, stop=stop, step=step)
+        )
+
+    def get_gapped_seq_str(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> str:
+        """Return sequence corresponding to seqid as a string.
+        start/stop are in alignment coordinates. Includes gaps."""
+        return self.alphabet.from_indices(
+            self.get_gapped_seq_array(seqid=seqid, start=start, stop=stop, step=step)
+        )
+
+    def get_seq_bytes(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> bytes:
+        """Return ungapped sequence corresponding to seqid as a bytes string.
+        start/stop are in sequence coordinates. Excludes gaps."""
+        return self.get_seq_str(seqid=seqid, start=start, stop=stop, step=step).encode(
+            "utf8"
+        )
+
+    def get_gapped_seq_bytes(
+        self,
+        *,
+        seqid: str,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> bytes:
+        """Return sequence corresponding to seqid as a bytes string.
+        start/stop are in alignment coordinates. Includes gaps."""
+        return self.get_gapped_seq_str(
+            seqid=seqid, start=start, stop=stop, step=step
+        ).encode("utf8")
+
+    @extend_docstring_from(AlignedSeqsDataABC.get_ungapped)
+    def get_ungapped(
+        self,
+        name_map: dict[str, str],
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> tuple[dict, dict]:
+        # redesign
+        # if gaps exist, don't go via gapped seq
+        # convert alignment coords into sequence coords using the location.align_to_seq_index function
+        # this means we will need to convert coordinates to a plus strand slice
+        seq_array = numpy.empty(
+            (len(name_map), self.align_len), dtype=self.alphabet.dtype
+        )
+        names = tuple(name_map.values())
+        for i, name in enumerate(names):
+            index = self._name_to_index[name]
+            seq_array[i] = self._gapped[index]
+        seq_array = seq_array[:, start:stop:step]
+        # now exclude gaps and missing
+        seqs = {}
+        for i, name in enumerate(names):
+            seq = seq_array[i]
+            indices = seq != self.alphabet.gap_index
+            if self.alphabet.missing_index is not None:
+                indices &= seq != self.alphabet.missing_index
+            seqs[name] = seq[indices]
+
+        offset = {n: v for n, v in self._offset.items() if n in names}
+        return seqs, {"offset": offset, "name_map": name_map}
+
+    def add_seqs(
+        self,
+        seqs: dict[str, StrORArray],
+        force_unique_keys: bool = True,
+        offset: dict[str, int] = None,
+    ) -> AlignedSeqsData:
+        """Returns a new AlignedSeqsData object with added sequences.
+
+        Parameters
+        ----------
+        seqs
+            dict of sequences to add {name: seq, ...}
+        force_unique_keys
+            if True, raises ValueError if any sequence names already exist in the collection
+        """
+        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_seq_lens = {len(seq) for seq in seqs.values()}
+        if len(new_seq_lens) != 1 or new_seq_lens.pop() != self.align_len:
+            raise ValueError(
+                "All sequences must be the same length as existing sequences"
+            )
+
+        new_seqs = dict(zip(self.names, self._gapped))
+        for name, seq in seqs.items():
+            seq = self.alphabet.to_indices(seq)
+            if not self.alphabet.is_valid(seq):
+                raise new_alphabet.AlphabetError(
+                    f"Sequence {name!r} contains invalid characters."
+                )
+            seq.flags.writeable = False
+            new_seqs[name] = seq
+
+        names = tuple(new_seqs.keys())
+        gapped = numpy.empty((len(names), self.align_len), dtype=self.alphabet.dtype)
+        for i, name in enumerate(names):
+            gapped[i] = new_seqs[name]
+
+        return self.__class__(
+            gapped_seqs=gapped,
+            names=names,
+            alphabet=self.alphabet,
+            offset={**self._offset, **(offset or {})},
+            align_len=self.align_len,
+        )
+
+    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__(
+                gapped_seqs=self._gapped,
+                alphabet=alphabet,
+                offset=self._offset,
+                align_len=self.align_len,
+                names=self.names,
+            )
+
+        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))
+        )
+        gapped = numpy.empty(
+            (len(self.names), self.align_len), dtype=self.alphabet.dtype
+        )
+
+        for i in range(len(self.names)):
+            as_new_alpha = convert_bytes_to_new(convert_old_to_bytes(self._gapped[i]))
+
+            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"
+                )
+            gapped[i] = as_new_alpha
+
+        return self.__class__(
+            gapped_seqs=gapped,
+            alphabet=alphabet,
+            offset=self._offset,
+            names=self.names,
+        )
+
+    def get_positions(
+        self,
+        names: PySeqStr,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+    ) -> numpy.ndarray:
+        """returns an array of the selected positions for names."""
+        indices = tuple(self._name_to_index[name] for name in names)
+
+        if abs((start - stop) // step) == self.align_len:
+            return self._gapped[indices, :] if step > 0 else self._gapped[indices, ::-1]
+
+        return self._gapped[indices, start:stop:step]
+
+
+class AlignedDataViewABC(new_sequence.SeqViewABC):
+    __slots__ = ()
+
+    @abstractmethod
+    def get_seq_view(self) -> new_sequence.SeqViewABC: ...
+
+    @property
+    @abstractmethod
+    def map(self) -> IndelMap: ...
+
+    @property
+    @abstractmethod
+    def slice_record(self) -> new_sequence.SliceRecordABC: ...
+
+    @property
+    @abstractmethod
+    def gapped_str_value(self) -> str: ...
+
+    @property
+    @abstractmethod
+    def gapped_array_value(self) -> numpy.ndarray: ...
+
+    @property
+    @abstractmethod
+    def gapped_bytes_value(self) -> bytes: ...
+
+
+class AlignedDataView(new_sequence.SeqViewABC):
+    # refactor: docstring
+
+    __slots__ = (
+        "parent",
+        "_seqid",
+        "alphabet",
+        "_offset",
+        "_parent_len",
+        "_slice_record",
+    )
+
+    def __init__(
+        self,
+        *,
+        parent: AlignedSeqsDataABC,
+        seqid: str,
+        alphabet: new_alphabet.AlphabetABC,
+        slice_record: OptSliceRecord = None,
+    ):
+        self.parent = parent
+        self._seqid = seqid
+        self.alphabet = alphabet
+        self._parent_len = parent.align_len
+        self._slice_record = (
+            slice_record
+            if slice_record is not None
+            else new_sequence.SliceRecord(parent_len=self._parent_len)
+        )
+
+    @property
+    def slice_record(self) -> new_sequence.SliceRecordABC:
+        return self._slice_record
+
+    @slice_record.setter
+    def slice_record(self, value: new_sequence.SliceRecordABC):
+        self._slice_record = value
+
+    @property
+    def seqid(self) -> str:
+        return self._seqid
+
+    @property
+    def parent_len(self) -> int:
+        return self._parent_len
+
+    @property
+    def map(self) -> IndelMap:
+        imap = self._parent_map()
+        start, stop, step = (
+            self.slice_record.start,
+            self.slice_record.stop,
+            self.slice_record.step,
+        )
+        return imap[start:stop:step]
+
+    def _parent_map(self) -> IndelMap:
+        gap_pos_gap_length = self.parent.get_gaps(self.seqid)
+        if gap_pos_gap_length.size > 0:
+            gap_pos = numpy.array(gap_pos_gap_length[:, 0], dtype=int)
+            cum_gap_lengths = numpy.array(gap_pos_gap_length[:, 1], dtype=int)
+        else:
+            gap_pos, cum_gap_lengths = (
+                numpy.array([], dtype=int),
+                numpy.array([], dtype=int),
+            )
+        return IndelMap(
+            gap_pos=gap_pos,
+            cum_gap_lengths=cum_gap_lengths,
+            parent_length=self.parent.get_seq_length(self.seqid),
+        )
+
+    @property
+    def str_value(self) -> str:
+        return self.alphabet.from_indices(self.array_value)
+
+    @property
+    def gapped_str_value(self) -> str:
+        return self.alphabet.from_indices(self.gapped_array_value)
+
+    @property
+    def array_value(self) -> numpy.ndarray:
+        value = self.parent.get_seq_array(
+            seqid=self.seqid,
+            start=self.map.get_seq_index(self.slice_record.plus_start),
+            stop=self.map.get_seq_index(self.slice_record.plus_stop),
+            step=self.map.get_seq_index(self.slice_record.plus_step),
+        )
+        return value[::-1] if self.slice_record.is_reversed else value
+
+    @property
+    def gapped_array_value(self) -> numpy.ndarray:
+        value = self.parent.get_gapped_seq_array(
+            seqid=self.seqid,
+            start=self.slice_record.plus_start,
+            stop=self.slice_record.plus_stop,
+            step=self.slice_record.plus_step,
+        )
+        return value[::-1] if self.slice_record.is_reversed else value
+
+    @property
+    def bytes_value(self) -> bytes:
+        return self.str_value.encode("utf8")
+
+    @property
+    def gapped_bytes_value(self) -> bytes:
+        return self.gapped_str_value.encode("utf8")
+
+    def __str__(self) -> str:
+        return self.str_value
+
+    def __array__(self, dtype=None, copy=None) -> numpy.ndarray:
+        arr = self.array_value
+        if dtype:
+            arr = arr.astype(dtype)
+        return arr
+
+    def __bytes__(self) -> bytes:
+        return self.bytes_value
+
+    def __getitem__(self, segment) -> AlignedDataViewABC:
+        return self.__class__(
+            parent=self.parent,
+            seqid=self.seqid,
+            alphabet=self.alphabet,
+            slice_record=self.slice_record[segment],
+        )
+
+    def __repr__(self) -> str:
+        # refactor: design
+        # todo: once design is settled, need to be tested
+        seq_preview = (
+            f"{self.parent.get_seq_array(seqid=self.seqid, start=0, stop=10)}..."
+            f"{self.parent.get_seq_array(seqid=self.seqid, start=self.parent_len-5)}"
+            if self.parent_len > 15
+            else self.parent.get_seq_array(seqid=self.seqid)
+        )
+        seq_preview = self.alphabet.from_indices(seq_preview)
+        return (
+            f"{self.__class__.__name__}(seqid={self.seqid!r}, map={self.map!r}, parent={seq_preview!r}, "
+            f"slice_record={self.slice_record.__repr__()})"
+        )
+
+    def parent_seq_coords(self) -> tuple[str, int, int, int]:
+        """returns seqid, start, stop, strand on the parent sequence"""
+        # we want the coordinates on the parent, which means we need to use the
+        # parent's IndelMap for findings the correct indices.
+        parent_map = self._parent_map()
+        start = parent_map.get_seq_index(self.slice_record.parent_start)
+        stop = parent_map.get_seq_index(self.slice_record.parent_stop)
+        strand = -1 if self.slice_record.step < 0 else 1
+
+        return self.seqid, start, stop, strand
+
+    def copy(self, sliced: bool = False):
+        return self
+
+    def _get_init_kwargs(self) -> dict:
+        return {
+            "parent": self.parent,
+            "seqid": self.seqid,
+            "alphabet": self.alphabet,
+            "slice_record": self.slice_record,
+        }
+
+    def get_seq_view(self) -> new_sequence.SeqViewABC:
+        # we want the parent coordinates in sequence coordinates
+        # parent_seq_coords does not account for the stride
+        seqid, start, stop, _ = self.parent_seq_coords()
+        parent_len = self.parent.get_seq_length(seqid)
+        offset = self.parent.offset.get(seqid)
+        sr = new_sequence.SliceRecord(
+            start=start,
+            stop=stop,
+            parent_len=parent_len,
+            offset=offset,
+        )[:: self.slice_record.step]
+
+        return SeqDataView(
+            parent=self.parent,
+            seqid=seqid,
+            alphabet=self.alphabet,
+            parent_len=parent_len,
+            slice_record=sr,
+        )
+
+
+def make_gap_filter(template, gap_fraction, gap_run):
+    """Returns f(seq) -> True if no gap runs and acceptable gap fraction.
+
+    Calculations relative to template.
+    gap_run = number of consecutive gaps allowed in either the template or seq
+    gap_fraction = fraction of positions that either have a gap in the template
+        but not in the seq or in the seq but not in the template
+    NOTE: template and seq must both be ArraySequence objects.
+    """
+    template_gaps = numpy.array(template.gap_vector())
+
+    def result(seq):
+        """Returns True if seq adhers to the gap threshold and gap fraction."""
+        seq_gaps = numpy.array(seq.gap_vector())
+        # check if gap amount bad
+        if sum(seq_gaps != template_gaps) / float(len(seq)) > gap_fraction:
+            return False
+        # check if gap runs bad
+        if (
+            b"\x01" * gap_run
+            in numpy.logical_and(seq_gaps, numpy.logical_not(template_gaps))
+            .astype(numpy.uint8)
+            .tobytes()
+        ):
+            return False
+        # check if insertion runs bad
+        elif (
+            b"\x01" * gap_run
+            in numpy.logical_and(template_gaps, numpy.logical_not(seq_gaps))
+            .astype(numpy.uint8)
+            .tobytes()
+        ):
+            return False
+        return True
+
+    return result
+
+
+class _IndexableSeqs:
+    """container that is created by SequenceCollection and Alignment instances"""
+
+    def __init__(
+        self,
+        parent: Union[SequenceCollection, Alignment],
+        make_seq: typing.Callable[[str], typing.Union[new_sequence.Sequence, Aligned]],
+    ):
+        """
+        Parameters
+        ----------
+        parent
+            either a SequenceCollection or Alignment instance
+        make_seq
+            method on the parent that creates the correct object type when given a seqid
+        """
+        self.parent = parent
+        self._make_seq = make_seq
+
+    @singledispatchmethod
+    def __getitem__(
+        self, key: Union[str, int, slice]
+    ) -> Union[new_sequence.Sequence, Aligned]:
+        raise TypeError(f"indexing not supported for {type(key)}, try .take_seqs()")
+
+    @__getitem__.register
+    def _(self, key: int) -> Union[new_sequence.Sequence, Aligned]:
+        return self[self.parent.names[key]]
+
+    @__getitem__.register
+    def _(self, key: str) -> Union[new_sequence.Sequence, Aligned]:
+        return self._make_seq(key)
+
+    def __repr__(self) -> str:
+        one_seq = self[self.parent.names[0]]
+        return f"({one_seq!r}, + {self.parent.num_seqs-1} seqs)"
+
+    def __len__(self) -> int:
+        return self.parent.num_seqs
+
+    def __iter__(self):
+        for name in self.parent.names:
+            yield self._make_seq(name)
+
+
+class Alignment(SequenceCollection):
+    # refactor: docstring
+    def __init__(
+        self,
+        seqs_data: AlignedSeqsDataABC,  # seqs_data
+        slice_record: OptSliceRecord = None,
+        **kwargs,
+    ):
+        super().__init__(seqs_data=seqs_data, **kwargs)
+        self._slice_record = (
+            slice_record
+            if slice_record is not None
+            else new_sequence.SliceRecord(parent_len=self._seqs_data.align_len)
+        )
+        self._array_seqs = None
+
+    def _post_init(self):
+        self._seqs = _IndexableSeqs(self, make_seq=self._make_aligned)
+
+    def __eq__(self, other: "Alignment") -> bool:
+        return super().__eq__(other) and self._slice_record == other._slice_record
+
+    def __ne__(self, other: "Alignment") -> bool:
+        return not self == other
+
+    def _get_init_kwargs(self) -> dict:
+        """returns the kwargs needed to re-instantiate the object"""
+        return {
+            "seqs_data": self._seqs_data,
+            "moltype": self.moltype,
+            "name_map": self._name_map.copy(),
+            "info": self.info.copy(),
+            "annotation_db": self.annotation_db,
+            "slice_record": self._slice_record,
+        }
+
+    @singledispatchmethod
+    def __getitem__(self, index):
+        raise NotImplementedError(f"__getitem__ not implemented for {type(index)}")
+
+    @__getitem__.register
+    def _(self, index: str):
+        return self._make_aligned(seqid=index)
+
+    @__getitem__.register
+    def _(self, index: int):
+        new_slice = self._slice_record[index]
+        return self.__class__(
+            seqs_data=self._seqs_data,
+            slice_record=new_slice,
+            moltype=self.moltype,
+            info=self.info,
+        )
+
+    @__getitem__.register
+    def _(self, index: slice):
+        new_slice = self._slice_record[index]
+        new = self.__class__(
+            seqs_data=self._seqs_data,
+            slice_record=new_slice,
+            moltype=self.moltype,
+            info=self.info,
+        )
+        if abs(new_slice.step or 1) > 1:
+            return new
+
+        # for simple slices, we retain the annotation database
+        if self.annotation_db is not None:
+            new.annotation_db = self.annotation_db
+        return new
+
+    @__getitem__.register
+    def _(self, index: FeatureMap):
+        return self._mapped(index)
+
+    @__getitem__.register
+    def _(self, index: Feature):
+        if index.parent is not self:
+            raise ValueError("This feature applied to the wrong sequence / alignment")
+        return index.get_slice()
+
+    def __repr__(self):
+        seqs = []
+        limit = 10
+        delimiter = ""
+        for count, name in enumerate(self.names):
+            if count == 3:
+                seqs.append("...")
+                break
+            elts = list(str(self.seqs[name])[: limit + 1])
+            if len(elts) > limit:
+                elts[-1] = "..."
+            seqs.append(f"{name}[{delimiter.join(elts)}]")
+        seqs = ", ".join(seqs)
+
+        return f"{len(self.names)} x {len(self)} {self.moltype.label} alignment: {seqs}"
+
+    def __len__(self):
+        return len(self._slice_record)
+
+    def __array__(self):
+        return self.array_seqs
+
+    def _make_aligned(self, seqid: str) -> Aligned:
+        adv = self._seqs_data.get_view(self._name_map.get(seqid, seqid))
+        adv.slice_record = self._slice_record
+        aligned = Aligned(data=adv, moltype=self.moltype, name=seqid)
+        aligned.annotation_db = self.annotation_db
+        return aligned
+
+    @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
+        corresponding to names"""
+        if self._array_seqs is None:
+            # create the dest array dim
+            arr_seqs = self._seqs_data.get_positions(
+                names=list(self._name_map.values()),
+                start=self._slice_record.start,
+                stop=self._slice_record.stop,
+                step=self._slice_record.step,
+            )
+            # if we are reversed and a nucleic acid moltype we will complement the array
+            if self.moltype.is_nucleic and self._slice_record.is_reversed:
+                arr_seqs = arr_seqs.copy()
+                arr_seqs.flags.writeable = True
+                make_complement = self.moltype.complement
+                for i in range(arr_seqs.shape[0]):
+                    arr_seqs[i] = make_complement(arr_seqs[i])
+
+            arr_seqs.flags.writeable = False  # make sure data is immutable
+            self._array_seqs = arr_seqs
+        return self._array_seqs
+
+    @property
+    def array_positions(self) -> numpy.ndarray:
+        """Returns a numpy array of positions, axis 0 is alignment positions
+        columns in order corresponding to names."""
+        return self.array_seqs.T
+
+    def get_seq(
+        self, seqname: str, copy_annotations: bool = False
+    ) -> new_sequence.Sequence:
+        """Return a Sequence object for the specified seqname.
+
+        Parameters
+        ----------
+        seqname
+            name of the sequence to return
+        copy_annotations
+            if True, only the annotations for the specified sequence are copied
+            to the annotation database of the Sequence object. If False, all
+            annotations are copied.
+        """
+        seq = self.seqs[seqname].seq
+        if copy_annotations:
+            seq.annotation_db = type(self.annotation_db)()
+            seq.annotation_db.update(annot_db=self.annotation_db, seqids=seqname)
+        else:
+            seq.annotation_db = self.annotation_db
+
+        return seq
+
+    @c3warn.deprecated_args(
+        version="2025.6", reason="naming consistency", old_new=[("seq_name", "seqname")]
+    )
+    def get_gapped_seq(self, seqname: str) -> new_sequence.Sequence:
+        """Return a gapped Sequence object for the specified seqname.
+
+        Notes
+        -----
+        This method breaks the connection to the annotation database.
+        """
+        return self.seqs[seqname].gapped_seq
+
+    def rc(self):
+        """Returns the reverse complement of all sequences in the alignment.
+        A synonym for reverse_complement.
+        """
+        init_kwargs = self._get_init_kwargs()
+        init_kwargs["slice_record"] = self._slice_record[::-1]
+        return self.__class__(**init_kwargs)
+
+    def alignment_quality(self, app_name: str = "ic_score", **kwargs):
+        """
+        Computes the alignment quality using the indicated app
+
+        Parameters
+        ----------
+        app_name
+            name of an alignment score calculating app, e.g. 'ic_score',
+            'cogent3_score', 'sp_score'
+
+        kwargs
+            keyword arguments to be passed to the app. Use
+            ``cogent3.app_help(app_name)`` to see the available options.
+
+        Returns
+        -------
+        float or a NotCompleted instance if the score could not be computed
+        """
+        app = get_app(app_name, **kwargs)
+        return app(self)
+
+    def rename_seqs(self, renamer: Callable[[str], str]):
+        """Returns new alignment with renamed sequences."""
+        new_name_map = {
+            renamer(name): old_name for name, old_name in self._name_map.items()
+        }
+        if len(new_name_map) != len(self._name_map):
+            raise ValueError(f"non-unique names produced by {renamer=}")
+
+        init_kwargs = self._get_init_kwargs()
+        init_kwargs["name_map"] = new_name_map
+        new = self.__class__(**init_kwargs)
+
+        if self._array_seqs is not None:
+            new._array_seqs = self._array_seqs
+
+        return new
+
+    def iter_positions(
+        self, pos_order: list = None
+    ) -> typing.Iterator[list, list, list]:
+        """Iterates over positions in the alignment, in order.
+
+        Parameters
+        ----------
+        pos_order
+            list of indices specifying the column order. If None, the
+            positions are iterated in order.
+
+        Returns
+        -------
+        yields lists of elemenets for each position (column) in the alignment
+        """
+        # refactor: array
+        # this could also iter columns of indices as a numpy array - could be an optional arg
+        # refactor: add motif_length argument
+        pos_order = pos_order or range(len(self))
+        for pos in pos_order:
+            yield [str(self[seq][pos]) for seq in self.names]
+
+    def get_position_indices(
+        self, f: Callable, native: bool = False, negate: bool = False
+    ) -> list[int]:
+        """Returns list of column indices for which f(col) is True.
+
+        f
+          function that returns true/false given an alignment position
+        native
+          if True, f is provided with slice of array, otherwise the string is used
+        negate
+          if True, not f() is used
+        """
+        # refactor:
+        # type hint for f
+        # implement native
+        new_f = negate_condition(f) if negate else f
+
+        # refactor: design
+        # use array_positions here
+        return [i for i, col in enumerate(self.positions) if new_f(col)]
+
+    def take_positions(self, cols: list, negate: bool = False):
+        """Returns new Alignment containing only specified positions.
+
+        Parameters
+        ----------
+        cols
+            list of column indices to keep
+        negate
+            if True, all columns except those in cols are kept
+        """
+        # refactor: array - use array operations throughout method
+        if negate:
+            col_lookup = dict.fromkeys(cols)
+            cols = [i for i in range(len(self)) if i not in col_lookup]
+
+        new_data = {
+            aligned.data.seqid: numpy.array(aligned.gapped_seq)[cols]
+            for aligned in self.seqs
+        }
+        seqs_data = self._seqs_data.from_seqs(
+            data=new_data, alphabet=self.moltype.most_degen_alphabet()
+        )
+        return self.__class__(
+            seqs_data=seqs_data,
+            name_map=self._name_map,
+            info=self.info,
+            moltype=self.moltype,
+        )
+
+    def take_positions_if(self, f, negate=False):
+        """Returns new Alignment containing cols where f(col) is True."""
+        return self.take_positions(self.get_position_indices(f, negate=negate))
+
+    def get_gap_array(self, include_ambiguity: bool = True) -> numpy.ndarray:
+        """returns bool array with gap state True, False otherwise
+
+        Parameters
+        ----------
+        include_ambiguity
+            if True, ambiguity characters that include the gap state are
+            included
+        """
+        result = numpy.full((self.num_seqs, len(self)), False, dtype=bool)
+        for i, aligned in enumerate(self.seqs):
+            if include_ambiguity:
+                seq_array = numpy.array(aligned)
+                result[i][seq_array >= self.moltype.most_degen_alphabet().gap_index] = (
+                    True
+                )
+            else:
+                # refactor: design
+                # this should be on IndelMap
+                gaps = self._seqs_data.get_gaps(aligned.data.seqid)
+                for gap_pos, cum_gap_length in gaps:
+                    result[i][list(range(gap_pos, gap_pos + cum_gap_length))] = True
+        return result
+
+    def iupac_consensus(self, allow_gap: bool = True) -> str:
+        """Returns string containing IUPAC consensus sequence of the alignment."""
+        exclude = set() if allow_gap else set(self.moltype.gaps)
+        consensus = []
+        degen = self.moltype.degenerate_from_seq
+        for col in self.iter_positions():
+            col = set(col) - exclude
+            consensus.append(degen("".join(col)))
+        return "".join(consensus)
+
+    def majority_consensus(self) -> new_sequence.Sequence:
+        """Returns consensus sequence containing most frequent item at each
+        position."""
+        states = []
+        data = zip(*map(str, self.seqs))
+        for pos in data:
+            pos = CategoryCounter(pos)
+            states.append(pos.mode)
+
+        return self.moltype.make_seq(seq="".join(states))
+
+    def counts_per_pos(
+        self,
+        motif_length: int = 1,
+        include_ambiguity: bool = False,
+        allow_gap: bool = False,
+        warn: bool = False,
+    ) -> DictArray:
+        """return DictArray of counts per position
+
+        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.
+        warn
+            warns if motif_length > 1 and alignment trimmed to produce
+            motif columns
+        """
+        # refactor: performance
+        # use self.variable_positions and a numba decorated
+        # function for counting k-mers the latter should allow returning the
+        # first allowed state for when position is not variable
+
+        align_len = len(self._slice_record)
+        length = (align_len // motif_length) * motif_length
+        if warn and align_len != length:
+            warnings.warn(f"trimmed {align_len - length}", UserWarning)
+
+        data = list(self.to_dict().values())
+        alpha = self.moltype.alphabet.get_kmer_alphabet(motif_length)
+        all_motifs = set()
+        exclude_chars = set()
+        if not allow_gap:
+            exclude_chars.update(self.moltype.gap)
+
+        if not include_ambiguity and self.moltype.degen_alphabet:
+            ambigs = [c for c, v in self.moltype.ambiguities.items() if len(v) > 1]
+            exclude_chars.update(ambigs)
+
+        result = []
+        for i in range(0, align_len - motif_length + 1, motif_length):
+            counts = CategoryCounter([s[i : i + motif_length] for s in data])
+            all_motifs.update(list(counts))
+            result.append(counts)
+
+        if all_motifs:
+            alpha += tuple(sorted(set(alpha) ^ all_motifs))
+
+        if exclude_chars:
+            # this additional clause is required for the bytes moltype
+            # That moltype includes '-' as a character
+            alpha = [m for m in alpha if not (set(m) & exclude_chars)]
+
+        for i, counts in enumerate(result):
+            result[i] = counts.tolist(alpha)
+
+        result = MotifCountsArray(result, alpha)
+        return result
+
+    def probs_per_pos(
+        self,
+        motif_length: int = 1,
+        include_ambiguity: bool = False,
+        allow_gap: bool = False,
+        warn: bool = False,
+    ) -> MotifFreqsArray:
+        """returns MotifFreqsArray per position"""
+        counts = self.counts_per_pos(
+            motif_length=motif_length,
+            include_ambiguity=include_ambiguity,
+            allow_gap=allow_gap,
+            warn=warn,
+        )
+        return counts.to_freq_array()
+
+    def entropy_per_pos(
+        self,
+        motif_length: int = 1,
+        include_ambiguity: bool = False,
+        allow_gap: bool = False,
+        warn: bool = False,
+    ) -> numpy.ndarray:
+        """returns shannon entropy per position"""
+        probs = self.probs_per_pos(
+            motif_length=motif_length,
+            include_ambiguity=include_ambiguity,
+            allow_gap=allow_gap,
+            warn=warn,
+        )
+        return probs.entropy()
+
+    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:
+        """counts of non-overlapping motifs per sequence
+
+        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 False, all canonical states included
+        warn
+            warns if motif_length > 1 and alignment trimmed to produce
+            motif columns
+        """
+        length = (len(self) // motif_length) * motif_length
+        if warn and len(self) != length:
+            warnings.warn(f"trimmed {len(self) - length}", UserWarning)
+
+        counts = []
+        motifs = set()
+        for name in self.names:
+            seq = self.get_gapped_seq(name)
+            c = seq.counts(
+                motif_length=motif_length,
+                include_ambiguity=include_ambiguity,
+                allow_gap=allow_gap,
+                exclude_unobserved=exclude_unobserved,
+            )
+            motifs.update(c.keys())
+            counts.append(c)
+
+        if not exclude_unobserved:
+            motifs.update(self.moltype.alphabet.get_kmer_alphabet(motif_length))
+
+        motifs = list(sorted(motifs))
+        if not motifs:
+            return None
+
+        for i, c in enumerate(counts):
+            counts[i] = c.tolist(motifs)
+        return MotifCountsArray(counts, motifs, row_indices=self.names)
+
+    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
+
+        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.
+        exclude_unobserved
+            if True, unobserved motif combinations are excluded.
+        warn
+            warns if motif_length > 1 and alignment trimmed to produce
+            motif columns
+        """
+
+        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
+            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.
+        exclude_unobserved
+            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 count_gaps_per_pos(self, include_ambiguity: bool = True) -> DictArray:
+        """return counts of gaps per position as a DictArray
+
+        Parameters
+        ----------
+        include_ambiguity
+            if True, ambiguity characters that include the gap state are
+            included
+        """
+        gap_array = self.get_gap_array(include_ambiguity=include_ambiguity)
+        darr = DictArrayTemplate(range(len(self)))
+
+        result = gap_array.sum(axis=0)
+        result = darr.wrap(result)
+        return result
+
+    def count_gaps_per_seq(
+        self,
+        induced_by: bool = False,
+        unique: bool = False,
+        include_ambiguity: bool = True,
+        drawable: bool = False,
+    ):
+        """return counts of gaps per sequence as a DictArray
+
+        Parameters
+        ----------
+        induced_by
+            a gapped column is considered to be induced by a seq if the seq
+            has a non-gap character in that column.
+        unique
+            count is limited to gaps uniquely induced by each sequence
+        include_ambiguity
+            if True, ambiguity characters that include the gap state are
+            included
+        drawable
+            if True, resulting object is capable of plotting data via specified
+            plot type 'bar', 'box' or 'violin'
+        """
+        from cogent3.draw.drawable import Drawable
+
+        gap_array = self.get_gap_array(include_ambiguity=include_ambiguity)
+        darr = DictArrayTemplate(self.names)
+
+        if unique:
+            # we identify cols with a single non-gap character
+            gap_cols = gap_array.sum(axis=0) == self.num_seqs - 1
+            gap_array = gap_array[:, gap_cols] == False
+        elif induced_by:
+            # identify all columns with gap opposite
+            gap_cols = gap_array.sum(axis=0) > 0
+            gap_array = gap_array[:, gap_cols] == False
+        else:
+            gap_cols = gap_array.sum(axis=0) > 0
+            gap_array = gap_array[:, gap_cols]
+
+        result = gap_array.sum(axis=1)
+        result = darr.wrap(result)
+        if drawable:
+            drawable = drawable.lower()
+            trace_name = (
+                os.path.basename(self.info.source) if self.info.source else None
+            )
+            draw = Drawable("Gaps Per Sequence", showlegend=False)
+            draw.layout |= dict(yaxis=dict(title="Gap counts"))
+            if drawable == "bar":
+                trace = UnionDict(type="bar", y=result.array, x=self.names)
+            else:
+                trace = UnionDict(
+                    type=drawable, y=result.array, text=self.names, name=trace_name
+                )
+
+            draw.add_trace(trace)
+            result = draw.bound_to(result)
+
+        return result
+
+    def variable_positions(
+        self,
+        include_gap_motif: bool = True,
+        include_ambiguity: bool = False,
+        motif_length: int = 1,
+    ) -> tuple[int]:
+        """Return a list of variable position indexes.
+
+        Parameters
+        ----------
+        include_gap_motif
+            if False, sequences with a gap motif in a column are ignored.
+        include_ambiguity
+            if True, all states are considered.
+        motif_length
+            if any position within a motif is variable, the entire motif is
+            considered variable.
+
+        Returns
+        -------
+        tuple of integers, if motif_length > 1, the returned positions are
+        motif_length long sequential indices.
+
+        Notes
+        -----
+        Truncates alignment to be modulo motif_length.
+        """
+        align_len = len(self) // motif_length * motif_length
+        array_seqs = self.array_seqs[:, :align_len]
+        # both gap and missing data are treated as gaps
+        alpha = self.moltype.most_degen_alphabet()
+        gap_index = alpha.gap_index
+        missing_index = alpha.missing_index
+        # at the individual position level
+        if include_gap_motif and include_ambiguity:
+            indices = _var_pos_allow_all(array_seqs)
+        elif include_gap_motif:
+            indices = _var_pos_canonical_or_gap(array_seqs, gap_index, missing_index)
+        elif include_ambiguity:
+            indices = _var_pos_not_gap(array_seqs, gap_index)
+        else:
+            indices = _var_pos_canonical(array_seqs, gap_index)
+
+        if motif_length > 1:
+            indices = indices.reshape(-1, motif_length).any(axis=1).repeat(motif_length)
+
+        return tuple(numpy.where(indices)[0].tolist())
+
+    def omit_bad_seqs(self, quantile: OptFloat = None):
+        """Returns new alignment without sequences with a number of uniquely
+        introduced gaps exceeding quantile
+
+        Uses count_gaps_per_seq(unique=True) to obtain the counts of gaps
+        uniquely introduced by a sequence. The cutoff is the quantile of
+        this distribution.
+
+        Parameters
+        ----------
+        quantile
+            sequences whose unique gap count is in a quantile larger than this
+            cutoff are excluded. The default quantile is (num_seqs - 1) / num_seqs
+        """
+        gap_counts = self.count_gaps_per_seq(unique=True)
+        quantile = quantile or (self.num_seqs - 1) / self.num_seqs
+        cutoff = numpy.quantile(gap_counts.array, quantile)
+        names = [name for name, count in gap_counts.items() if count <= cutoff]
+        return self.take_seqs(names)
+
+    def degap(self) -> SequenceCollection:
+        """Returns new SequenceCollection in which sequences have no gaps or
+        missing characters.
+
+        Notes
+        -----
+        The returned collection will not retain an annotation_db if present.
+        """
+        # refactor: design
+        # add optional argument to retain annotation_db
+
+        # because SequenceCollection does not track slice operations, we need
+        # to apply any slice record to the underlying data
+        start, stop, step = (
+            self._slice_record.start,
+            self._slice_record.stop,
+            self._slice_record.step,
+        )
+        data, kwargs = self._seqs_data.get_ungapped(
+            name_map=self._name_map, start=start, stop=stop, step=step
+        )
+        # the SeqsData classes will return the data corresponding to the slice,
+        # however, will not complement the data if the step is negative. We do
+        # this here.
+        data = (
+            {name: self.moltype.complement(seq) for name, seq in data.items()}
+            if step < 0
+            else data
+        )
+        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: str, gap_fraction: float, gap_run: int):
+        """Returns new alignment with seqs well aligned with a reference.
+
+        Parameters
+        ----------
+        ref_name
+            name of the sequence to use as the reference
+        gap_fraction
+            fraction of positions that either have a gap in the
+            template but not in the seq or in the seq but not in the template
+        gap_run
+            number of consecutive gaps tolerated in query relative to
+            sequence or sequence relative to query
+        """
+        template = self.seqs[ref_name]
+        gap_filter = make_gap_filter(template, gap_fraction, gap_run)
+        return self.take_seqs_if(gap_filter)
+
+    def sliding_windows(
+        self, window: int, step: int, start: OptInt = None, end: OptInt = None
+    ):
+        """Generator yielding new alignments of given length and interval.
+
+        Parameters
+        ----------
+        window
+            The length of each returned alignment.
+        step
+            The interval between the start of the successive windows.
+        start
+            first window start position
+        end
+            last window start position
+        """
+        start = start or 0
+        end = [end, len(self) - window + 1][end is None]
+        end = min(len(self) - window + 1, end)
+        if start < end and len(self) - end >= window - 1:
+            for pos in range(start, end, step):
+                yield self[pos : pos + window]
+
+    def gapped_by_map(self, keep: FeatureMap, **kwargs):
+        # refactor: docstring
+        # todo: kath, not explicitly tested
+        seqs = {}
+        for seq in self.seqs:
+            selected = seq[keep]
+            seqs[seq.name] = numpy.array(selected.gapped_seq)
+
+        seqs_data = self._seqs_data.from_seqs(
+            data=seqs, alphabet=self.moltype.most_degen_alphabet()
+        )
+        return self.__class__(seqs_data=seqs_data, moltype=self.moltype, **kwargs)
+
+    def filtered(
+        self, predicate, motif_length: int = 1, drop_remainder: bool = True, **kwargs
+    ):
+        """The alignment positions where predicate(column) is true.
+
+        Parameters
+        ----------
+        predicate
+            a callback function that takes an tuple of motifs and returns
+            True/False
+        motif_length
+            length of the motifs the sequences should be split  into, eg. 3 for
+            filtering aligned codons.
+        drop_remainder
+            If length is not modulo motif_length, allow dropping the terminal
+            remaining columns
+        """
+        # refactor: type hint for predicate
+        # refactor: array
+        # add argument array_type: bool=True which allows the user to specify what
+        # type their callable can handle and what type the method should return
+        length = len(self)
+        if length % motif_length != 0 and not drop_remainder:
+            raise ValueError(
+                f"aligned length not divisible by motif_length={motif_length}"
+            )
+        gv = []
+        kept = False
+        seqs = [
+            self.get_gapped_seq(n).get_in_motif_size(motif_length, **kwargs)
+            for n in self.names
+        ]
+
+        positions = list(zip(*seqs))
+        for position, column in enumerate(positions):
+            keep = predicate(column)
+            if kept != keep:
+                gv.append(position * motif_length)
+                kept = keep
+
+        if kept:
+            gv.append(len(positions) * motif_length)
+
+        if not gv:
+            return None
+
+        locations = [(gv[i], gv[i + 1]) for i in range(0, len(gv), 2)]
+        # these are alignment coordinate locations
+        keep = FeatureMap.from_locations(locations=locations, parent_length=len(self))
+
+        return self.gapped_by_map(keep, info=self.info)
+
+    def no_degenerates(self, motif_length: int = 1, allow_gap: bool = False):
+        """returns new alignment without degenerate characters
+
+        Parameters
+        ----------
+        motif_length
+            sequences are segmented into units of this size and the segments are
+            excluded if they contain degenerate characters.
+        allow_gap
+            whether gaps are allowed or whether they are treated as a degenerate
+            character (latter is default, as most evolutionary modelling treats
+            gaps as N).
+        """
+        if self.moltype.degen_alphabet is None:
+            raise new_moltype.MolTypeError(
+                f"Invalid MolType={self.moltype.label} (no degenerate characters), "
+                "create the alignment using DNA, RNA or PROTEIN"
+            )
+
+        chars = len(self.moltype)
+
+        array_pos = self.array_positions
+        # by design, char alphabets are organised such that the canonical
+        # characters always occur first, followed by gap, then ambiguity
+        # characters. so we can define a cutoff as follows:
+        cutoff = chars + 1 if allow_gap else chars
+        indices = (array_pos < cutoff).all(axis=1)
+
+        if motif_length > 1:
+            num_motif = len(self) // motif_length
+
+            if remainder := len(self) % motif_length:
+                indices = indices[:-remainder]
+                array_pos = array_pos[:-remainder]
+
+            motif_valid = indices.reshape(num_motif, motif_length).all(axis=1).flatten()
+            indices = numpy.repeat(motif_valid, motif_length)
+        selected = array_pos[indices].T
+
+        aligned_seqs_data = self._seqs_data.from_names_and_array(
+            names=self.names, data=selected, alphabet=self.moltype.most_degen_alphabet()
+        )
+        return self.__class__(
+            seqs_data=aligned_seqs_data, info=self.info, moltype=self.moltype
+        )
+
+    def _omit_gap_pos_single(
+        self, gap_index: int, missing_index: OptInt, allowed_num: int
+    ) -> numpy.ndarray[bool]:
+        # for motif_length == 1
+        indices = numpy.empty(len(self), dtype=bool)
+        for i, col in enumerate(self.array_seqs.T):
+            indices[i] = _gap_ok_vector_single(
+                col, gap_index, missing_index, allowed_num
+            )
+        return indices
+
+    def _omit_gap_pos_multi(
+        self, gap_index: int, missing_index: OptInt, allowed_num: int, motif_length: int
+    ) -> numpy.ndarray[bool]:
+        # for motif_length > 1
+        num_motifs = len(self) // motif_length
+        indices = numpy.empty(num_motifs * motif_length, dtype=bool)
+        for i in range(0, len(self), motif_length):
+            col = self.array_seqs.T[i : i + motif_length].T
+            ok = _gap_ok_vector_multi(
+                col, gap_index, missing_index, motif_length, allowed_num
+            )
+            indices[i : i + motif_length] = ok
+        return indices
+
+    def omit_gap_pos(self, allowed_gap_frac: float = 1 - EPS, motif_length: int = 1):
+        """Returns new alignment where all cols (motifs) have <= allowed_gap_frac gaps.
+
+        Parameters
+        ----------
+        allowed_gap_frac
+            specifies proportion of gaps is allowed in each column. Set to 0 to
+            exclude columns with any gaps, 1 to include all columns.
+        motif_length
+            set's the "column" width, e.g. setting to 3 corresponds to codons.
+            A motif that includes a gap at any position is included in the
+            counting.
+        """
+        alpha = self.moltype.most_degen_alphabet()
+        gap_index = alpha.gap_index
+        missing_index = alpha.missing_index
+        if not gap_index and not missing_index:
+            return self
+
+        allowed_num = int(numpy.floor(self.num_seqs * allowed_gap_frac))
+
+        # we are assuming gap and missing data have same value on other strand
+        positions = self.array_positions
+        if motif_length == 1:
+            indices = self._omit_gap_pos_single(gap_index, missing_index, allowed_num)
+        else:
+            positions = positions[: motif_length * (len(positions) // motif_length)]
+            indices = self._omit_gap_pos_multi(
+                gap_index, missing_index, allowed_num, motif_length
+            )
+        selected = positions[indices, :].T
+
+        aligned_seqs_data = self._seqs_data.from_names_and_array(
+            names=self.names, data=selected, alphabet=alpha
+        )
+        kwargs = self._get_init_kwargs()
+        kwargs["seqs_data"] = aligned_seqs_data
+        # slice record now needs to be reset since we likely have disjoint positions
+        kwargs.pop("slice_record", None)
+        return self.__class__(**kwargs)
+
+    def has_terminal_stop(self, gc: Any = None, strict: bool = False) -> bool:
+        """Returns True if any sequence has a terminal stop codon.
+
+        Parameters
+        ----------
+        gc
+            valid input to cogent3.get_code(), a genetic code object, number
+            or name
+        strict
+            If True, raises an exception if a seq length not divisible by 3
+        """
+        for seq_name in self.names:
+            seq = self.seqs[seq_name].seq
+            if seq.has_terminal_stop(gc=gc, strict=strict):
+                return True
+        return False
+
+    def sample(
+        self,
+        *,
+        n: int = None,
+        with_replacement: bool = False,
+        motif_length: int = 1,
+        randint=numpy.random.randint,
+        permutation=numpy.random.permutation,
+    ):
+        """Returns random sample of positions from self, e.g. to bootstrap.
+
+        Parameters
+        ----------
+        n
+            number of positions to sample. If None, all positions are sampled.
+        with_replacement
+            if True, samples with replacement.
+        motif_length
+            number of positions to sample as a single motif.
+        randint
+            random number generator, default is numpy.randint
+        permutation
+            function to generate a random permutation of positions, default is
+            numpy.permutation
+
+        Notes
+        -----
+        By default (resampling all positions without replacement), generates
+        a permutation of the positions of the alignment.
+
+        Setting with_replacement to True and otherwise leaving parameters as
+        defaults generates a standard bootstrap resampling of the alignment.
+        """
+        # refactor: type hint for randint, permutation
+        # refactor: array
+        #  Given array_pos property, it will be efficient to generate random
+        # indices and use numpy.take() using that. In the case of motif_length
+        # != 1, the total number of positions is just len(self) // motif_length.
+        # Having produced that, those indices can be scaled back up, or the
+        # numpy array reshaped.
+
+        population_size = len(self) // motif_length
+        if not n:
+            n = population_size
+        if with_replacement:
+            locations = randint(0, population_size, n)
+        else:
+            assert n <= population_size, (n, population_size, motif_length)
+            locations = permutation(population_size)[:n]
+
+        positions = numpy.empty(n * motif_length, dtype=int)
+        for i, loc in enumerate(locations):
+            positions[i * motif_length : (i + 1) * motif_length] = range(
+                loc * motif_length, (loc + 1) * motif_length
+            )
+
+        new_seqs = {}
+        for aligned in self.seqs:
+            sampled = numpy.array(aligned)[positions]
+            new_seqs[aligned.data.seqid] = sampled
+
+        new_seqs_data = self._seqs_data.from_seqs(
+            data=new_seqs, alphabet=self.moltype.most_degen_alphabet()
+        )
+        return self.__class__(
+            seqs_data=new_seqs_data, info=self.info, moltype=self.moltype
+        )
+
+    def distance_matrix(
+        self,
+        calc: str = "pdist",
+        show_progress: bool = False,
+        drop_invalid: bool = False,
+    ):
+        """Returns pairwise distances between sequences.
+
+        Parameters
+        ----------
+        calc
+            a pairwise distance calculator or name of one. For options see
+            cogent3.evolve.fast_distance.available_distances
+        show_progress
+            controls progress display for distance calculation
+        drop_invalid
+            If True, sequences for which a pairwise distance could not be
+            calculated are excluded. If False, an ArithmeticError is raised if
+            a distance could not be computed on observed data.
+        """
+        from cogent3.evolve.fast_distance import get_distance_calculator
+
+        try:
+            calculator = get_distance_calculator(
+                calc,
+                moltype=self.moltype,
+                alignment=self,
+                invalid_raises=not drop_invalid,
+            )
+            calculator.run(show_progress=show_progress)
+        except ArithmeticError as e:
+            msg = "not all pairwise distances could be computed, try drop_invalid=True"
+            raise ArithmeticError(msg) from e
+        result = calculator.get_pairwise_distances()
+        if drop_invalid:
+            result = result.drop_invalid()
+        return result
+
+    @UI.display_wrap
+    def quick_tree(
+        self,
+        calc: str = "pdist",
+        bootstrap: OptInt = None,
+        drop_invalid: bool = False,
+        show_progress: bool = False,
+        ui=None,  # refactor: type hint
+    ):
+        """Returns a phylogenetic tree.
+
+        Parameters
+        ----------
+        calc
+            a pairwise distance calculator or name of one. For options see
+            cogent3.evolve.fast_distance.available_distances
+        bootstrap
+            Number of non-parametric bootstrap replicates. Resamples alignment
+            columns with replacement and builds a phylogeny for each such
+            resampling.
+        drop_invalid
+            If True, sequences for which a pairwise distance could not be
+            calculated are excluded. If False, an ArithmeticError is raised if
+            a distance could not be computed on observed data.
+        show_progress
+            controls progress display for distance calculation
+
+        Returns
+        -------
+        a phylogenetic tree. If bootstrap specified, returns the weighted
+        majority consensus. Support for each node is stored as
+        edge.params['params'].
+
+        Notes
+        -----
+        Sequences in the observed alignment for which distances could not be
+        computed are omitted. Bootstrap replicates are required to have
+        distances for all seqs present in the observed data distance matrix.
+        """
+        from cogent3.phylo.consensus import weighted_majority_rule
+        from cogent3.phylo.nj import gnj
+
+        dm = self.distance_matrix(
+            calc=calc, show_progress=show_progress, drop_invalid=drop_invalid
+        )
+        results = gnj(dm, keep=1, show_progress=show_progress)
+        kept_names = set(dm.template.names[0])
+        if bootstrap:
+            subaln = (
+                self.take_seqs(kept_names) if set(self.names) != kept_names else self
+            )
+            for _ in ui.series(range(bootstrap), count=bootstrap, noun="bootstrap"):
+                b = subaln.sample(with_replacement=True)
+                try:
+                    bdist = b.distance_matrix(
+                        calc=calc, show_progress=show_progress, drop_invalid=False
+                    )
+                except ArithmeticError:
+                    # all sequences must have a pairwise distance to allow
+                    # constructing a consensus tree
+                    continue
+                bresult = gnj(bdist, keep=1, show_progress=show_progress)
+                results.extend(bresult)
+
+            consense = weighted_majority_rule(results)
+            assert len(consense) == 1
+            results = consense
+
+        tree = results[0]
+        if not bootstrap:
+            tree = tree[1]
+        return tree
+
+    def trim_stop_codons(self, gc: Any = None, strict: bool = False, **kwargs):
+        # refactor: array
+        if not self.has_terminal_stop(gc=gc, strict=strict):
+            return self
+
+        # define a regex for finding stop codons followed by terminal gaps
+        gc = new_genetic_code.get_code(gc)
+        gaps = "".join(self.moltype.gaps)
+        pattern = f"({'|'.join(gc['*'])})[{gaps}]*$"
+        terminal_stop = re.compile(pattern)
+
+        data = self.to_dict()
+        for name, seq in data.items():
+            if match := terminal_stop.search(seq):
+                diff = len(seq) - match.start()
+                seq = terminal_stop.sub("-" * diff, seq)
+            data[name] = seq
+
+        seqs_data = self._seqs_data.from_seqs(
+            data=data, alphabet=self.moltype.most_degen_alphabet()
+        )
+
+        result = self.__class__(
+            seqs_data=seqs_data, info=self.info, moltype=self.moltype, **kwargs
+        )
+
+        if hasattr(self, "annotation_db"):
+            result.annotation_db = self.annotation_db
+
+        return result
+
+    @extend_docstring_from(SequenceCollection.get_translation)
+    def get_translation(
+        self,
+        gc: int = None,
+        incomplete_ok: bool = False,
+        include_stop: bool = False,
+        trim_stop: bool = True,
+        **kwargs,
+    ):
+        if not self.moltype.is_nucleic:
+            raise new_moltype.MolTypeError(
+                f"moltype must be a DNA/RNA, not {self.moltype.name!r}"
+            )
+
+        translated = {}
+        if not trim_stop or include_stop:
+            seqs = self
+        else:
+            seqs = self.trim_stop_codons(gc=gc, strict=not incomplete_ok)
+        # do the translation
+        for seqname in seqs.names:
+            seq = seqs.get_gapped_seq(seqname)
+            pep = seq.get_translation(
+                gc,
+                incomplete_ok=incomplete_ok,
+                include_stop=include_stop,
+                trim_stop=trim_stop,
+            )
+            translated[seqname] = numpy.array(pep)
+
+        pep_moltype = new_moltype.get_moltype(
+            "protein_with_stop" if include_stop else "protein"
+        )
+        seqs_data = self._seqs_data.from_seqs(
+            data=translated,
+            alphabet=pep_moltype.most_degen_alphabet(),
+            offset=None,
+        )
+        kwargs["moltype"] = pep_moltype
+        return self.__class__(seqs_data=seqs_data, info=self.info, **kwargs)
+
+    def make_feature(
+        self,
+        *,
+        feature: FeatureDataType,
+        on_alignment: OptBool = None,
+    ) -> Feature:
+        """
+        create a feature on named sequence, or on the alignment itself
+
+        Parameters
+        ----------
+        feature
+            a dict with all the necessary data rto construct a feature
+        on_alignment
+            the feature is in alignment coordinates, incompatible with setting
+            'seqid'. Set to True if 'seqid' not provided.
+
+        Returns
+        -------
+        Feature
+
+        Raises
+        ------
+        ValueError if define a 'seqid' not on alignment or use 'seqid' and
+        on_alignment.
+
+        Notes
+        -----
+        To get a feature AND add it to annotation_db, use add_feature().
+        """
+        if on_alignment is None:
+            on_alignment = feature.pop("on_alignment", None)
+
+        if not on_alignment and feature["seqid"]:
+            return self.seqs[feature["seqid"]].make_feature(feature, self)
+
+        feature["seqid"] = feature.get("seqid", None)
+        # there's no sequence to bind to, the feature is directly on self
+        revd = feature.pop("strand", None) == "-"
+        feature["strand"] = "-" if revd else "+"
+        fmap = FeatureMap.from_locations(
+            locations=feature.pop("spans"), parent_length=len(self)
+        )
+        if revd:
+            fmap = fmap.nucleic_reversed()
+        return Feature(parent=self, map=fmap, **feature)
+
+    def add_feature(
+        self,
+        *,
+        biotype: str,
+        name: str,
+        spans: List[Tuple[int, int]],
+        seqid: OptStr = None,
+        parent_id: OptStr = None,
+        strand: str = "+",
+        on_alignment: OptBool = None,
+    ) -> Feature:
+        """
+        add feature on named sequence, or on the alignment itself
+
+        Parameters
+        ----------
+        seqid
+            sequence name, incompatible with on_alignment
+        parent_id
+            name of the parent feature
+        biotype
+            biological type, e.g. CDS
+        name
+            name of the feature
+        spans
+            plus strand coordinates of feature
+        strand
+            '+' (default) or '-'
+        on_alignment
+            the feature is in alignment coordinates, incompatible with setting
+            seqid. Set to True if seqid not provided.
+
+        Returns
+        -------
+        Feature
+
+        Raises
+        ------
+        ValueError if define a seqid not on alignment or use seqid and
+        on_alignment.
+        """
+        if seqid and on_alignment is None:
+            on_alignment = False
+        elif not on_alignment:
+            on_alignment = on_alignment is None
+
+        if seqid and on_alignment:
+            raise ValueError("seqid and on_alignment are incomatible")
+
+        if seqid and seqid not in self.names:
+            raise ValueError(f"unknown {seqid=}")
+
+        if not self.annotation_db:
+            self.annotation_db = DEFAULT_ANNOTATION_DB()
+
+        feature = {k: v for k, v in locals().items() if k != "self"}
+
+        self.annotation_db.add_feature(**feature)
+        for discard in ("on_alignment", "parent_id"):
+            feature.pop(discard, None)
+        return self.make_feature(feature=feature, on_alignment=on_alignment)
+
+    def _get_seq_features(
+        self,
+        *,
+        seqid: Optional[str] = None,
+        biotype: Optional[str] = None,
+        name: Optional[str] = None,
+        allow_partial: bool = False,
+    ) -> Iterator[Feature]:
+        """yields Feature instances
+
+        Parameters
+        ----------
+        seqid
+            limit search to features on this named sequence, defaults to search all
+        biotype
+            biotype of the feature, e.g. CDS, gene
+        name
+            name of the feature
+        allow_partial
+            allow features partially overlaping self
+
+        Notes
+        -----
+        When dealing with a nucleic acid moltype, the returned features will
+        yield a sequence segment that is consistently oriented irrespective
+        of strand of the current instance.
+        """
+        if self.annotation_db is None:
+            return None
+
+        seqid_to_seqname = {seq.parent_coordinates()[0]: seq.name for seq in self.seqs}
+
+        seqids = [seqid] if isinstance(seqid, str) else seqid
+        if seqids is None:
+            seqids = tuple(seqid_to_seqname)
+        elif set(seqids) & set(self.names):
+            # we've been given seq names, convert to parent names
+            seqids = [self.seqs[seqid].parent_coordinates()[0] for seqid in seqids]
+        elif seqids and set(seqids) <= seqid_to_seqname.keys():
+            # already correct
+            pass
+        else:
+            raise ValueError(f"unknown {seqid=}")
+
+        for seqid in seqids:
+            seqname = seqid_to_seqname[seqid]
+            seq = self.seqs[seqname]
+            # we use parent seqid
+            parent_id, start, stop, _ = seq.parent_coordinates()
+            offset = seq.data.offset
+
+            for feature in self.annotation_db.get_features_matching(
+                seqid=parent_id,
+                biotype=biotype,
+                name=name,
+                on_alignment=False,
+                allow_partial=allow_partial,
+                start=start,
+                stop=stop,
+            ):
+                if offset:
+                    feature["spans"] = (numpy.array(feature["spans"]) - offset).tolist()
+                # passing self only used when self is an Alignment
+                yield seq.make_feature(feature, self)
+
+    def get_features(
+        self,
+        *,
+        seqid: Optional[str] = None,
+        biotype: Optional[str] = None,
+        name: Optional[str] = None,
+        on_alignment: Optional[bool] = None,
+        allow_partial: bool = False,
+    ) -> Iterator[Feature]:
+        """yields Feature instances
+
+        Parameters
+        ----------
+        seqid
+            limit search to features on this named sequence, defaults to search all
+        biotype
+            biotype of the feature, e.g. CDS, gene
+        name
+            name of the feature
+        on_alignment
+            limit query to features on Alignment, ignores sequences. Ignored on
+            SequenceCollection instances.
+        allow_partial
+            allow features partially overlaping self
+
+        Notes
+        -----
+        When dealing with a nucleic acid moltype, the returned features will
+        yield a sequence segment that is consistently oriented irrespective
+        of strand of the current instance.
+        """
+        # we only do on-alignment in here
+        if not on_alignment:
+            kwargs = {
+                k: v for k, v in locals().items() if k not in ("self", "__class__")
+            }
+            kwargs.pop("on_alignment")
+            yield from self._get_seq_features(**kwargs)
+
+        if on_alignment == False:
+            return
+
+        if self.annotation_db is None:
+            anno_db = merged_db_collection(self._seqs_data)
+            self.annotation_db = anno_db
+
+        if self.annotation_db is None:
+            return None
+
+        seq_map = None
+        for feature in self.annotation_db.get_features_matching(
+            biotype=biotype,
+            name=name,
+            on_alignment=on_alignment,
+            allow_partial=allow_partial,
+        ):
+            if feature["seqid"]:
+                continue
+            on_al = feature.pop("on_alignment", on_alignment)
+            if feature["seqid"]:
+                raise RuntimeError(f"{on_alignment=} {feature=}")
+            if seq_map is None:
+                seq_map = self.seqs[0].map.to_feature_map()
+                *_, strand = self.seqs[0].seq.parent_coordinates()
+
+            spans = numpy.array(feature["spans"])
+            spans = seq_map.relative_position(spans)
+            feature["spans"] = spans.tolist()
+            # and if i've been reversed...?
+            feature["strand"] = "-" if strand == -1 else "+"
+            yield self.make_feature(feature=feature, on_alignment=on_al)
+
+    def get_projected_feature(self, *, seqid: str, feature: Feature) -> Feature:
+        """returns an alignment feature projected onto the seqid sequence
+
+        Parameters
+        ----------
+        seqid
+            name of the sequence to project the feature onto
+        feature
+            a Feature, bound to self, that will be projected
+
+        Returns
+        -------
+        a new Feature bound to seqid
+
+        Notes
+        -----
+        The alignment coordinates of feature are converted into the seqid
+        sequence coordinates and the object is bound to that sequence.
+
+        The feature is added to the annotation_db.
+        """
+        target_aligned = self.seqs[seqid]
+        if feature.parent is not self:
+            raise ValueError("Feature does not belong to this alignment")
+        result = feature.remapped_to(target_aligned.seq, target_aligned.map)
+
+        if not self.annotation_db:
+            # todo gah improve bound db initialisation
+            self.annotation_db = DEFAULT_ANNOTATION_DB()
+
+        self.annotation_db.add_feature(**feature.to_dict())
+        return result
+
+    def get_projected_features(self, *, seqid: str, **kwargs) -> list[Feature]:
+        """projects all features from other sequences onto seqid"""
+        annots = []
+        for name in self.names:
+            if name == seqid:
+                continue
+            annots.extend(list(self.get_features(seqid=name, **kwargs)))
+        return [self.get_projected_feature(seqid=seqid, feature=a) for a in annots]
+
+    def get_drawables(
+        self, *, biotype: Optional[str, typing.Iterable[str]] = None
+    ) -> dict:
+        """returns a dict of drawables, keyed by type
+
+        Parameters
+        ----------
+        biotype
+            passed to get_features(biotype). Can be a single biotype or
+            series. Only features matching this will be included.
+        """
+        result = defaultdict(list)
+        for f in self.get_features(biotype=biotype, allow_partial=True):
+            result[f.biotype].append(f.get_drawable())
+        return result
+
+    def get_drawable(
+        self,
+        *,
+        biotype: Optional[str, typing.Iterable[str]] = None,
+        width: int = 600,
+        vertical: int = False,
+        title: OptStr = None,
+    ):
+        """make a figure from sequence features
+
+        Parameters
+        ----------
+        biotype
+            passed to get_features(biotype). Can be a single biotype or
+            series. Only features matching this will be included.
+        width
+            width in pixels
+        vertical
+            rotates the drawable
+        title
+            title for the plot
+
+        Returns
+        -------
+        a Drawable instance
+        """
+        # todo gah I think this needs to be modified to make row-blocks
+        # for each sequence in the alignment, or are we displaying the
+        # sequence name in the feature label?
+        from cogent3.draw.drawable import Drawable
+
+        drawables = self.get_drawables(biotype=biotype)
+        if not drawables:
+            return None
+        # we order by tracks
+        top = 0
+        space = 0.25
+        annotes = []
+        for feature_type in drawables:
+            new_bottom = top + space
+            for i, annott in enumerate(drawables[feature_type]):
+                annott.shift(y=new_bottom - annott.bottom)
+                if i > 0:
+                    # refactor: design
+                    # modify the api on annott, we should not be using
+                    # a private attribute!
+                    annott._showlegend = False
+                annotes.append(annott)
+
+            top = annott.top
+
+        top += space
+        height = max((top / len(self)) * width, 300)
+        xaxis = dict(range=[0, len(self)], zeroline=False, showline=True)
+        yaxis = dict(range=[0, top], visible=False, zeroline=True, showline=True)
+
+        if vertical:
+            all_traces = [t.T.as_trace() for t in annotes]
+            width, height = height, width
+            xaxis, yaxis = yaxis, xaxis
+        else:
+            all_traces = [t.as_trace() for t in annotes]
+
+        drawer = Drawable(title=title, traces=all_traces, width=width, height=height)
+        drawer.layout.update(xaxis=xaxis, yaxis=yaxis)
+        return drawer
+
+    def seqlogo(
+        self,
+        width: float = 700,
+        height: float = 100,
+        wrap: OptInt = None,
+        vspace: float = 0.005,
+        colours: dict = None,
+    ):
+        """returns Drawable sequence logo using mutual information
+
+        Parameters
+        ----------
+        width, height
+            plot dimensions in pixels
+        wrap
+            number of alignment columns per row
+        vspace
+            vertical separation between rows, as a proportion of total plot
+        colours
+            mapping of characters to colours. If note provided, defaults to
+            custom for everything ecept protein, which uses protein moltype
+            colours.
+
+        Notes
+        -----
+        Computes MI based on log2 and includes the gap state, so the maximum
+        possible value is -log2(1/num_states)
+        """
+        assert 0 <= vspace <= 1, "vertical space must be in range 0, 1"
+        freqs = self.counts_per_pos(allow_gap=True).to_freq_array()
+        if colours is None and "protein" in self.moltype.label:
+            colours = self.moltype._colors
+
+        return freqs.logo(
+            width=width, height=height, wrap=wrap, vspace=vspace, colours=colours
+        )
+
+    @c3warn.deprecated_args(
+        "2024.12", reason="consistency", old_new=[("method", "stat")]
+    )
+    def coevolution(
+        self,
+        stat: str = "nmi",
+        segments: list[tuple[int, int]] = None,
+        drawable: OptStr = None,
+        show_progress: bool = False,
+        parallel: bool = False,
+        par_kw: OptDict = None,
+    ):
+        """performs pairwise coevolution measurement
+
+        Parameters
+        ----------
+        stat
+            coevolution metric, defaults to 'nmi' (Normalized Mutual
+            Information). Valid choices are 'rmi' (Resampled Mutual Information)
+            and 'mi', mutual information.
+        segments
+            coordinates of the form [(start, end), ...] where all possible
+            pairs of alignment positions within and between segments are
+            examined.
+        drawable
+            Result object is capable of plotting data specified type. str value
+            must be one of plot type 'box', 'heatmap', 'violin'.
+        show_progress
+            shows a progress bar.
+        parallel
+            run in parallel, according to arguments in par_kwargs.
+        par_kw
+            dict of values for configuring parallel execution.
+
+        Returns
+        -------
+        DictArray of results with lower-triangular values. Upper triangular
+        elements and estimates that could not be computed for numerical reasons
+        are set as nan
+        """
+        from cogent3.draw.drawable import AnnotatedDrawable, Drawable
+        from cogent3.evolve import coevolution as coevo
+        from cogent3.util.union_dict import UnionDict
+
+        # refactor: design
+        # These graphical representations of matrices should be separate functions
+        # in the drawing submodule somewhere
+        stat = stat.lower()
+        if segments:
+            segments = [range(*segment) for segment in segments]
+
+        result = coevo.coevolution_matrix(
+            alignment=self,
+            stat=stat,
+            positions=segments,
+            show_progress=show_progress,
+            parallel=parallel,
+            par_kw=par_kw,
+        )
+        if drawable is None:
+            return result
+
+        trace_name = os.path.basename(self.info.source) if self.info.source else None
+        if drawable in ("box", "violin"):
+            trace = UnionDict(
+                type=drawable, y=result.array.flatten(), showlegend=False, name=""
+            )
+            draw = Drawable(
+                width=500, height=500, title=trace_name, ytitle=stat.upper()
+            )
+            draw.add_trace(trace)
+            result = draw.bound_to(result)
+        elif drawable:
+            axis_title = "Alignment Position"
+            axis_args = dict(
+                showticklabels=True,
+                mirror=True,
+                showgrid=False,
+                showline=True,
+                zeroline=False,
+            )
+            height = 500
+            width = height
+            draw = Drawable(
+                width=width,
+                height=height,
+                xtitle=axis_title,
+                ytitle=axis_title,
+                title=trace_name,
+            )
+
+            trace = UnionDict(
+                type="heatmap",
+                z=result.array,
+                colorbar=dict(title=dict(text=stat.upper(), font=dict(size=16))),
+            )
+            draw.add_trace(trace)
+            draw.layout.xaxis.update(axis_args)
+            draw.layout.yaxis.update(axis_args)
+
+            try:
+                bottom = self.get_drawable()
+                left = self.get_drawable(vertical=True)
+            except AttributeError:
+                bottom = False
+
+            if bottom and drawable != "box":
+                xlim = 1.2
+                draw.layout.width = height * xlim
+                layout = dict(legend=dict(x=xlim, y=1))
+                draw = AnnotatedDrawable(
+                    draw,
+                    left_track=left,
+                    bottom_track=bottom,
+                    xtitle=axis_title,
+                    ytitle=axis_title,
+                    xrange=[0, len(self)],
+                    yrange=[0, len(self)],
+                    layout=layout,
+                )
+
+            result = draw.bound_to(result)
+
+        return result
+
+    def information_plot(
+        self,
+        width: OptInt = None,
+        height: OptInt = None,
+        window: OptInt = None,
+        stat: str = "median",
+        include_gap: bool = True,
+    ):
+        """plot information per position
+
+        Parameters
+        ----------
+        width
+            figure width in pixels
+        height
+            figure height in pixels
+        window
+            used for smoothing line, defaults to sqrt(length)
+        stat
+            'mean' or 'median, used as the summary statistic for each window
+        include_gap
+            whether to include gap counts, shown on right y-axis
+        """
+        from cogent3.draw.drawable import AnnotatedDrawable, Drawable
+
+        # refactor: design
+        # These graphical representations of matrices should be separate functions
+        # in the drawing submodule somewhere
+        window = window or numpy.sqrt(len(self))
+        window = int(window)
+        y = self.entropy_per_pos()
+        nan_indices = numpy.isnan(y)
+        if nan_indices.sum() == y.shape[0]:  # assuming 1D array
+            y.fill(0.0)
+        max_entropy = y[nan_indices == False].max()
+        y = max_entropy - y  # convert to information
+        # now make all nan's 0
+        y[nan_indices] = 0
+        stats = {"mean": numpy.mean, "median": numpy.median}
+        if stat not in stats:
+            raise ValueError('stat must be either "mean" or "median"')
+        calc_stat = stats[stat]
+        num = len(y) - window
+        v = [calc_stat(y[i : i + window]) for i in range(num)]
+        x = numpy.arange(num)
+        x += window // 2  # shift x-coordinates to middle of window
+        trace_line = UnionDict(
+            type="scatter",
+            x=x,
+            y=v,
+            mode="lines",
+            name=f"smoothed {stat}",
+            line=dict(shape="spline", smoothing=1.3),
+        )
+        trace_marks = UnionDict(
+            type="scatter",
+            x=numpy.arange(y.shape[0]),
+            y=y,
+            mode="markers",
+            opacity=0.5,
+            name="per position",
+        )
+        layout = UnionDict(
+            title="Information per position",
+            width=width,
+            height=height,
+            showlegend=True,
+            yaxis=dict(range=[0, max(y) * 1.2], showgrid=False),
+            xaxis=dict(
+                showgrid=False, range=[0, len(self)], mirror=True, showline=True
+            ),
+        )
+
+        traces = [trace_marks, trace_line]
+        if include_gap:
+            gap_counts = self.count_gaps_per_pos()
+            y = [calc_stat(gap_counts[i : i + window]) for i in range(num)]
+            trace_g = UnionDict(
+                type="scatter",
+                x=x,
+                y=y,
+                yaxis="y2",
+                name="Gaps",
+                mode="lines",
+                line=dict(shape="spline", smoothing=1.3),
+            )
+            traces += [trace_g]
+            layout.yaxis2 = dict(
+                title="Count",
+                side="right",
+                overlaying="y",
+                range=[0, max(gap_counts) * 1.2],
+                showgrid=False,
+                showline=True,
+            )
+
+        draw = Drawable(
+            title="Information per position",
+            xtitle="Position",
+            ytitle=f"Information (window={window})",
+        )
+        draw.traces.extend(traces)
+        draw.layout |= layout
+        draw.layout.legend = dict(x=1.1, y=1)
+
+        try:
+            drawable = self.get_drawable()
+        except AttributeError:
+            drawable = False
+
+        if drawable:
+            draw = AnnotatedDrawable(
+                draw,
+                bottom_track=drawable,
+                xtitle="position",
+                ytitle=f"Information (window={window})",
+                layout=layout,
+            )
+
+        return draw
+
+    def to_pretty(self, name_order=None, wrap=None):
+        """returns a string representation of the alignment in pretty print format
+
+        Parameters
+        ----------
+        name_order
+            order of names for display.
+        wrap
+            maximum number of printed bases
+        """
+        names, output = self._get_raw_pretty(name_order=name_order)
+        label_width = max(list(map(len, names)))
+        name_template = "{:>%d}" % label_width
+        display_names = dict([(n, name_template.format(n)) for n in names])
+
+        def make_line(label, seq):
+            return f"{label}    {seq}"
+
+        if wrap is None:
+            result = [make_line(display_names[n], "".join(output[n])) for n in names]
+            return "\n".join(result)
+
+        align_length = len(self)
+        result = []
+        for start in range(0, align_length, wrap):
+            for n in names:
+                result.append(
+                    make_line(
+                        display_names[n],
+                        "".join(output[n][start : start + wrap]),
+                    )
+                )
+
+            result.append("")
+
+        if not result[-1]:
+            del result[-1]
+
+        return "\n".join(result)
+
+    def to_html(
+        self,
+        name_order: Optional[typing.Sequence[str]] = None,
+        wrap: int = 60,
+        limit: Optional[int] = None,
+        ref_name: str = "longest",
+        colors: Optional[Mapping[str, str]] = None,
+        font_size: int = 12,
+        font_family: str = "Lucida Console",
+    ) -> str:
+        """returns html with embedded styles for sequence colouring
+
+        Parameters
+        ----------
+        name_order
+            order of names for display.
+        wrap
+            number of alignment columns per row
+        limit
+            truncate alignment to this length
+        ref_name
+            Name of an existing sequence or 'longest'. If the latter, the
+            longest sequence (excluding gaps and ambiguities) is selected as the
+            reference.
+        colors
+            {character
+            moltype.
+        font_size
+            in points. Affects labels and sequence and line spacing
+            (proportional to value)
+        font_family
+            string denoting font family
+
+        Examples
+        ---------
+
+        In a jupyter notebook, this code is used to provide the representation.
+
+        .. code-block:: python
+
+            aln  # is rendered by jupyter
+
+        You can directly use the result for display in a notebook as
+
+        .. code-block:: python
+
+            from IPython.core.display import HTML
+
+            HTML(aln.to_html())
+        """
+        css, styles = self.moltype.get_css_style(
+            colors=colors, font_size=font_size, font_family=font_family
+        )
+        if name_order:
+            selected = self.take_seqs(name_order)
+            name_order = list(name_order)
+        else:
+            name_order = list(self.names)
+            ref_name = ref_name or "longest"
+            selected = self
+
+        if ref_name == "longest":
+            lengths = selected.get_lengths(include_ambiguity=False, allow_gap=False)
+
+            length_names = defaultdict(list)
+            for n, l in lengths.items():
+                length_names[l].append(n)
+
+            longest = max(length_names)
+            ref = sorted(length_names[longest])[0]
+
+        elif ref_name:
+            if ref_name not in selected.names:
+                raise ValueError(f"Unknown sequence name {ref_name}")
+            ref = ref_name
+
+        name_order.remove(ref)
+        name_order.insert(0, ref)
+
+        if limit is None:
+            names, output = selected._get_raw_pretty(name_order)
+        else:
+            names, output = selected[:limit]._get_raw_pretty(name_order)
+
+        gaps = "".join(selected.moltype.gaps)
+        refname = names[0]
+        refseq = output[refname]
+        seqlen = len(refseq)
+        start_gap = re.search(f"^[{gaps}]+", "".join(refseq))
+        end_gap = re.search(f"[{gaps}]+$", "".join(refseq))
+        start = 0 if start_gap is None else start_gap.end()
+        end = len(refseq) if end_gap is None else end_gap.start()
+        seq_style = []
+        template = '<span class="%s">%%s</span>'
+        styled_seqs = defaultdict(list)
+        for i in range(seqlen):
+            char = refseq[i]
+            if i < start or i >= end:
+                style = f"terminal_ambig_{selected.moltype.label}"
+            else:
+                style = styles[char]
+
+            seq_style.append(template % style)
+            styled_seqs[refname].append(seq_style[-1] % char)
+
+        for name in names:
+            if name == refname:
+                continue
+
+            seq = []
+            for i, c in enumerate(output[name]):
+                if c == ".":
+                    s = seq_style[i] % c
+                else:
+                    s = template % (styles[c])
+                    s = s % c
+                seq.append(s)
+
+            styled_seqs[name] = seq
+
+        # make a html table
+        seqs = numpy.array([styled_seqs[n] for n in names], dtype="O")
+        table = ["<table>"]
+        seq_ = "<td>%s</td>"
+        label_ = '<td class="label">%s</td>'
+        num_row_ = '<tr class="num_row"><td></td><td><b>{:,d}</b></td></tr>'
+        for i in range(0, seqlen, wrap):
+            table.append(num_row_.format(i))
+            seqblock = seqs[:, i : i + wrap].tolist()
+            for n, s in zip(names, seqblock):
+                s = "".join(s)
+                row = "".join([label_ % n, seq_ % s])
+                table.append(f"<tr>{row}</tr>")
+        table.append("</table>")
+        if (
+            limit
+            and limit < len(selected)
+            or name_order
+            and len(name_order) < len(selected.names)
+        ):
+            summary = (
+                f"{self.num_seqs} x {len(self)} (truncated to "
+                f"{len(name_order) if name_order else len(selected.names)} x "
+                f"{limit or len(selected)}) {selected.moltype.label} alignment"
+            )
+        else:
+            summary = (
+                f"{self.num_seqs} x {len(self)} {selected.moltype.label} alignment"
+            )
+
+        text = [
+            "<style>",
+            ".c3align table {margin: 10px 0;}",
+            ".c3align td { border: none !important; text-align: left !important; }",
+            ".c3align tr:not(.num_row) td span {margin: 0 2px;}",
+            ".c3align tr:nth-child(even) {background: #f7f7f7;}",
+            ".c3align .num_row {background-color:rgba(161, 195, 209, 0.5) !important; border-top: solid 1px black; }",
+            ".c3align .label { font-size: %dpt ; text-align: right !important; "
+            "color: black !important; padding: 0 4px; display: table-cell !important; "
+            "font-weight: normal !important; }" % font_size,
+            "\n".join([f".c3align {style}" for style in css]),
+            "</style>",
+            '<div class="c3align">',
+            "\n".join(table),
+            f"<p><i>{summary}</i></p>",
+            "</div>",
+        ]
+        return "\n".join(text)
+
+    def _get_raw_pretty(self, name_order):
+        """returns dict {name: seq, ...} for pretty print"""
+        if name_order is not None:
+            assert set(name_order) <= set(self.names), "names don't match"
+
+        output = defaultdict(list)
+        names = name_order or self.names
+        num_seqs = len(names)
+
+        seqs = [str(self.seqs[name]) for name in names]
+        positions = list(zip(*seqs))
+
+        for position in positions:
+            ref = position[0]
+            output[names[0]].append(ref)
+            for seq_num in range(1, num_seqs):
+                val = "." if position[seq_num] == ref else position[seq_num]
+                output[names[seq_num]].append(val)
+
+        return names, output
+
+    def _repr_html_(self) -> str:
+        settings = self._repr_policy.copy()
+        env_vals = get_setting_from_environ(
+            "COGENT3_ALIGNMENT_REPR_POLICY",
+            dict(num_seqs=int, num_pos=int, wrap=int, ref_name=str),
+        )
+        settings.update(env_vals)
+        return self.to_html(
+            name_order=self.names[: settings["num_seqs"]],
+            ref_name=settings["ref_name"],
+            limit=settings["num_pos"],
+            wrap=settings["wrap"],
+        )
+
+    def _mapped(self, slicemap):
+        seqs = {}
+        maps = {}
+        for aligned in self.seqs:
+            seq, im = aligned.slice_with_map(slicemap)
+            name = aligned.data.seqid
+            seqs[name] = seq
+            maps[name] = im
+
+        data = self._seqs_data.from_seqs_and_gaps(
+            seqs=seqs, gaps=maps, alphabet=self.moltype.most_degen_alphabet()
+        )
+
+        return self.__class__(
+            seqs_data=data,
+            moltype=self.moltype,
+            name_map=self._name_map,
+            info=self.info,
+        )
+
+    def apply_scaled_gaps(
+        self, other: SequenceCollection, aa_to_codon: bool | None = None
+    ):
+        """applies gaps in self to unagpped sequences"""
+        assert set(other.names) == set(self.names), "Names must match"
+        if aa_to_codon and not all(
+            (not self.moltype.is_nucleic, other.moltype.is_nucleic)
+        ):
+            raise ValueError(
+                "aa_to_codon True requires nucleic moltype destination not {self.moltype.name!r}"
+            )
+        elif not aa_to_codon and not all(
+            (self.moltype.is_nucleic, not other.moltype.is_nucleic)
+        ):
+            raise ValueError(
+                f"aa_to_codon False requires protein moltype destination not {other.moltype.name!r}"
+            )
+
+        assert aa_to_codon is not None
+        gaps = {}
+        seqs = {}
+        scale = 3 if aa_to_codon else 1 / 3
+
+        for seq in self.seqs:
+            parent_name = self._name_map[seq.name]
+            gaps[parent_name] = seq.map.scaled(scale).array
+            ungapped = numpy.array(other.seqs[seq.name])
+            seqs[parent_name] = ungapped
+            seq_len = self._seqs_data.get_seq_length(parent_name)
+            if len(ungapped) != int(seq_len * scale):
+                raise ValueError(
+                    f"Destination sequence for {seq.name!r} != {scale:.2f} x {seq_len} sequence"
+                )
+
+        seq_data = self._seqs_data.from_seqs_and_gaps(
+            seqs=seqs, gaps=gaps, alphabet=other.moltype.most_degen_alphabet()
+        )
+        init_args = self._get_init_kwargs()
+        init_args.pop("slice_record")  # slice is realised
+        init_args["moltype"] = other.moltype
+        init_args["seqs_data"] = seq_data
+        init_args["annotation_db"] = other.annotation_db
+        return self.__class__(**init_args)
+
+    def copy(self):
+        """creates new instance, only mutable attributes are copied"""
+        kwargs = self._get_init_kwargs()
+        return self.__class__(**kwargs)
+
+    def deepcopy(self, **kwargs):
+        """returns deep copy of self
+
+        Notes
+        -----
+        Reduced to sliced sequences in self, kwargs are ignored.
+        Annotation db is not copied if the alignment has been sliced.
+        """
+        import copy
+
+        kwargs = self._get_init_kwargs()
+        kwargs.pop("seqs_data")
+        kwargs["annotation_db"] = (
+            None
+            if len(self) != self._seqs_data.align_len
+            else copy.deepcopy(self.annotation_db)
+        )
+        new_seqs_data = {
+            n: self._seqs_data.get_gapped_seq_array(
+                seqid=n,
+                start=self._slice_record.plus_start,
+                stop=self._slice_record.plus_stop,
+                step=self._slice_record.plus_step,
+            )
+            for n in self._name_map.values()
+        }
+        new_seqs_data = self._seqs_data.from_seqs(
+            data=new_seqs_data,
+            alphabet=self.moltype.most_degen_alphabet(),
+        )
+        kwargs["seqs_data"] = new_seqs_data
+        kwargs["slice_record"] = None
+        return self.__class__(
+            **kwargs,
+        )
+
+    def with_masked_annotations(
+        self, biotypes: PySeqStr, mask_char: str = "?", shadow: bool = False
+    ):
+        """returns an alignment with regions replaced by mask_char
+
+        Parameters
+        ----------
+        biotypes
+            annotation type(s)
+        mask_char
+            must be a character valid for the moltype. The default value is
+            the most ambiguous character, eg. '?' for DNA
+        shadow
+            If True, masks everything but the biotypes
+        """
+        # by doing numpy.array(seq), this method applies the slice_record
+        # and modifies the underlying sequence data. We therefore split
+        # gapped sequences into their gaps and seq components and discard
+        # the current slice_record.
+        gaps = {}
+        ungapped = {}
+        for seq in self.seqs:
+            parent_name = self._name_map[seq.name]
+            gaps[parent_name] = seq.map.array
+            ungapped[parent_name] = numpy.array(
+                seq.seq.with_masked_annotations(biotypes, mask_char, shadow)
+            )
+
+        seq_data = self._seqs_data.from_seqs_and_gaps(
+            seqs=ungapped, gaps=gaps, alphabet=self.moltype.most_degen_alphabet()
+        )
+        init = self._get_init_kwargs()
+        init["seqs_data"] = seq_data
+        # we have to drop the slice record since we have changed the data
+        init["slice_record"] = None
+        return self.__class__(**init)
+
+    def to_rich_dict(self) -> dict[str, str | dict[str, str]]:
+        """returns a json serialisable dict"""
+        kwargs = self._get_init_kwargs()
+        kwargs.pop("slice_record")  # slice is realised
+        kwargs["moltype"] = self.moltype.label
+        kwargs.pop("annotation_db", None)  # we dont serialise the annotation db
+        kwargs.pop(
+            "offset", None
+        )  # no need for offset since annotation db is not serialised
+        kwargs.pop("seqs_data")
+
+        seqs = {self._name_map[s.name]: str(s) for s in self.seqs}
+        return {
+            "init_args": kwargs,
+            "type": get_object_provenance(self),
+            "version": __version__,
+            "seqs": seqs,
+        }
+
+    @classmethod
+    def from_rich_dict(cls, data: dict[str, str | dict[str, str]]):
+        data["init_args"].pop("annotation_db", None)
+        return make_aligned_seqs(data["seqs"], **data["init_args"])
+
+
+@register_deserialiser(get_object_provenance(Alignment))
+def deserialise_alignment(data: dict[str, str | dict[str, str]]) -> Alignment:
+    return Alignment.from_rich_dict(data)
+
+
+@singledispatch
+def make_aligned_seqs(
+    data: Union[dict[str, StrORBytesORArray], list, AlignedSeqsDataABC],
+    *,
+    moltype: typing.Union[str, new_moltype.MolType],
+    label_to_name: OptRenamerCallable = None,
+    info: OptDict = None,
+    source: OptPathType = None,
+    annotation_db: typing.Optional[SupportsFeatures] = None,
+    offset: typing.Optional[DictStrInt] = None,
+    name_map: typing.Optional[DictStrStr] = None,
+    is_reversed: OptBool = None,
+) -> Alignment:
+    if len(data) == 0:
+        raise ValueError("data must be at least one sequence.")
+
+    moltype = new_moltype.get_moltype(moltype)
+    alphabet = moltype.most_degen_alphabet()
+    annotation_db = annotation_db or merged_db_collection(data)
+
+    # if we have Sequences, we need to construct the name map before we construct
+    # the SeqsData object - however, if a name_map is provided, we assume that it
+    # corrects for any naming differences in data and skip this step
+    assign_names = _SeqNamer(name_func=label_to_name)
+    seqs_data, offs, rvd, nm = prep_for_seqs_data(data, moltype, assign_names)
+    # rvd is the count of sequences that have been reversed. It should be equal 0
+    # or the number of sequences. If inbetween, the sequences are a mix of the two
+    # which for now we do not support. However, if the user has provided a value
+    # for is_reversed, we will use that.
+    if is_reversed is not None:
+        rvd = is_reversed
+    elif rvd in {0, len(seqs_data)}:
+        rvd = bool(rvd)
+    else:
+        rvd = False
+        if annotation_db and len(annotation_db) > 0:
+            warnings.warn(
+                "Sequence strand is inconsistent, not applying the annotation db.",
+                UserWarning,
+            )
+            annotation_db = None
+
+    offset = offset or offs
+    name_map = name_map or nm
+    seqs_data = AlignedSeqsData.from_seqs(
+        data=seqs_data,
+        alphabet=alphabet,
+        offset=offset,
+    )
+    # we do not pass on offset/label_to_name as they are handled in this function
+    return make_aligned_seqs(
+        seqs_data,
+        moltype=moltype,
+        info=info,
+        annotation_db=annotation_db,
+        source=source,
+        name_map=name_map,
+        is_reversed=rvd,
+    )
+
+
+# I'm explicitly encoding the alternate variants of the following as numba
+# can then cache the byte-compiiled functions.
+@make_aligned_seqs.register
+def _(
+    data: AlignedSeqsDataABC,
+    *,
+    moltype: typing.Union[str, new_moltype.MolType],
+    label_to_name: OptRenamerCallable = None,
+    info: OptDict = None,
+    source: OptPathType = None,
+    annotation_db: typing.Optional[SupportsFeatures] = None,
+    offset: typing.Optional[DictStrInt] = None,
+    name_map: typing.Optional[DictStrStr] = None,
+    is_reversed: OptBool = None,
+) -> Alignment:
+    moltype = new_moltype.get_moltype(moltype)
+    if not moltype.is_compatible_alphabet(data.alphabet):
+        raise ValueError(
+            f"Provided moltype: {moltype.label} is not compatible with AlignedSeqsData",
+            f" alphabet: {data.alphabet}",
+        )
+
+    # we cannot set offset when creating from an AlignedSeqsData
+    if offset:
+        raise ValueError(f"Setting offset is not supported for {data=}")
+
+    info = info if isinstance(info, dict) else {}
+    info["source"] = str(source) if source else str(info.get("source", "unknown"))
+
+    sr = (
+        new_sequence.SliceRecord(parent_len=data.align_len, step=-1)
+        if is_reversed
+        else None
+    )
+    aln = Alignment(
+        seqs_data=data,
+        moltype=moltype,
+        info=info,
+        source=source,
+        annotation_db=annotation_db,
+        name_map=name_map,
+        slice_record=sr,
+    )
+    if label_to_name:
+        aln = aln.rename_seqs(label_to_name)
+    return aln
+
+
+@numba.jit(cache=True)
+def _var_pos_canonical_or_gap(
+    arr: numpy.ndarray, gap_index: int, missing_index: int
+) -> numpy.ndarray:  # pragma: no cover
+    """return boolean array indicating columns with more than one value below threshold
+
+    Parameters
+    ----------
+    arr
+        a 2D array with rows being sequences and columns positions
+    gap_index
+        value of gap state
+    missing_index
+        value of missing state
+
+    Returns
+    ------
+    a boolean array
+    """
+    # relying on consistent ordering of gap as num canonical + 1
+    m, n = arr.shape
+    result = numpy.zeros(n, dtype=numpy.bool_)
+    for pos in numpy.arange(n):
+        last = -1
+        for seq in numpy.arange(m):
+            state = arr[seq, pos]
+            if state <= gap_index or state == missing_index:
+                if last == -1:
+                    last = state
+                elif state != last:
+                    result[pos] = True
+                    break
+
+    return result
+
+
+@numba.jit(cache=True)
+def _var_pos_canonical(
+    arr: numpy.ndarray, gap_index: int
+) -> numpy.ndarray:  # pragma: no cover
+    """return boolean array indicating columns with more than one value below threshold
+
+    Parameters
+    ----------
+    arr
+        a 2D array with rows being sequences and columns positions
+    gap_index
+        value of gap state
+
+    Returns
+    ------
+    a boolean array
+    """
+    # relying on consistent ordering of gap as num canonical + 1
+    m, n = arr.shape
+    result = numpy.zeros(n, dtype=numpy.bool_)
+    for pos in numpy.arange(n):
+        last = -1
+        for seq in numpy.arange(m):
+            state = arr[seq, pos]
+            if state < gap_index:
+                if last == -1:
+                    last = state
+                elif state != last:
+                    result[pos] = True
+                    break
+
+    return result
+
+
+@numba.jit(cache=True)
+def _var_pos_allow_all(
+    arr: numpy.ndarray,
+) -> numpy.ndarray:  # pragma: no cover
+    """return boolean array indicating columns with more than one value below threshold
+
+    Parameters
+    ----------
+    arr
+        a 2D array with rows being sequences and columns positions
+
+    Returns
+    ------
+    a boolean array
+    """
+    m, n = arr.shape
+    result = numpy.zeros(n, dtype=numpy.bool_)
+    for pos in numpy.arange(n):
+        last = -1
+        for seq in numpy.arange(m):
+            state = arr[seq, pos]
+            if last == -1:
+                last = state
+            elif state != last:
+                result[pos] = True
+                break
+    return result
+
+
+@numba.jit(cache=True)
+def _var_pos_not_gap(
+    arr: numpy.ndarray,
+    gap_index: int,
+) -> numpy.ndarray:  # pragma: no cover
+    """return boolean array indicating columns with more than one value below threshold
+
+    Parameters
+    ----------
+    arr
+        a 2D array with rows being sequences and columns positions
+    gap_index
+        value of gap state
+
+    Returns
+    ------
+    a boolean array
+    """
+    m, n = arr.shape
+    result = numpy.zeros(n, dtype=numpy.bool_)
+    for pos in numpy.arange(n):
+        last = -1
+        for seq in numpy.arange(m):
+            state = arr[seq, pos]
+            if state != gap_index:
+                if last == -1:
+                    last = state
+                elif state != last:
+                    result[pos] = True
+                    break
+
+    return result
diff --git a/src/cogent3/core/new_alphabet.py b/src/cogent3/core/new_alphabet.py
index d2a66bf10..a29b19f8d 100644
--- a/src/cogent3/core/new_alphabet.py
+++ b/src/cogent3/core/new_alphabet.py
@@ -12,16 +12,13 @@
 from cogent3.util.misc import get_object_provenance
 
 StrORBytes = typing.Union[str, bytes]
+StrORArray = typing.Union[str, numpy.ndarray]
 StrORBytesORArray = typing.Union[str, bytes, numpy.ndarray]
 OptInt = typing.Optional[int]
 OptStr = typing.Optional[str]
 OptBytes = typing.Optional[bytes]
 
 
-def _element_type(val: typing.Sequence) -> typing.Any:
-    return type(val[0])
-
-
 @functools.singledispatch
 def _coerce_to_type(orig: StrORBytes, text: str) -> StrORBytes:
     raise TypeError(f"{type(orig)} is invalid")
@@ -110,7 +107,7 @@ def is_valid(self, seq) -> bool: ...
     def with_gap_motif(self): ...
 
     @abstractmethod
-    def to_indices(self, seq: str) -> numpy.ndarray: ...
+    def to_indices(self, seq: StrORBytesORArray) -> numpy.ndarray: ...
 
     @abstractmethod
     def from_indices(self, seq: numpy.ndarray) -> str: ...
@@ -210,7 +207,7 @@ def __init__(self, chars: bytes, dtype, delete: OptBytes = None):
         )
         self.dtype = dtype
 
-    def __call__(self, seq: bytes) -> numpy.ndarray:
+    def __call__(self, seq: bytes) -> numpy.ndarray[int]:
         b = self._converter(seq)
         return numpy.array(memoryview(b), dtype=self.dtype)
 
@@ -263,17 +260,17 @@ def __init__(
         gap: OptStr = None,
         missing: OptStr = None,
     ):
+        self.dtype = get_array_type(len(self))
         self._gap_char = gap
-        self._gap_index = self.index(gap) if gap else None
+        self._gap_index = self.dtype(self.index(gap)) if gap else None
         self._missing_char = missing
-        self._missing_index = self.index(missing) if missing else None
+        self._missing_index = self.dtype(self.index(missing)) if missing else None
 
         # the number of canonical states are non-gap, non-missing states
         adj = (1 if gap else 0) + (1 if missing else 0)
 
         self._num_canonical = len(self) - adj
 
-        self.dtype = get_array_type(len(self))
         self._chars = set(self)  # for quick lookup
         byte_chars = self.as_bytes()
         self._bytes2arr = bytes_to_array(byte_chars, dtype=self.dtype)
@@ -314,27 +311,35 @@ def get_motif_len(self) -> int:
         return self.motif_len
 
     @functools.singledispatchmethod
-    def to_indices(self, seq: StrORBytesORArray) -> numpy.ndarray:
+    def to_indices(self, seq: StrORBytesORArray) -> numpy.ndarray[int]:
         raise TypeError(f"{type(seq)} is invalid")
 
     @to_indices.register
-    def _(self, seq: bytes) -> numpy.ndarray:
+    def _(self, seq: bytes) -> numpy.ndarray[int]:
         # any non-canonical characters should lie outside the range
         # we replace these with a single value
         return self._bytes2arr(seq)
 
     @to_indices.register
-    def _(self, seq: str) -> numpy.ndarray:
+    def _(self, seq: str) -> numpy.ndarray[int]:
         return self.to_indices(seq.encode("utf8"))
 
     @to_indices.register
-    def _(self, seq: numpy.ndarray) -> numpy.ndarray:
-        return seq
+    def _(self, seq: numpy.ndarray) -> numpy.ndarray[int]:
+        return seq.astype(self.dtype)
 
     @functools.singledispatchmethod
-    def from_indices(self, seq: numpy.ndarray) -> str:
+    def from_indices(self, seq: StrORBytesORArray) -> str:
         raise TypeError(f"{type(seq)} is invalid")
 
+    @from_indices.register
+    def _(self, seq: str) -> str:
+        return seq
+
+    @from_indices.register
+    def _(self, seq: bytes) -> str:
+        return seq.decode("utf8")
+
     @from_indices.register
     def _(self, seq: numpy.ndarray) -> str:
         return self._arr2bytes(seq).decode("utf8")
@@ -753,7 +758,7 @@ def with_gap_motif(self, include_missing: bool = False):
         return monomers.get_kmer_alphabet(self.k, include_gap=True)
 
     @functools.singledispatchmethod
-    def to_index(self, seq) -> numpy.ndarray:
+    def to_index(self, seq) -> int:
         """encodes a k-mer as a single integer
 
         Parameters
@@ -774,17 +779,17 @@ def to_index(self, seq) -> numpy.ndarray:
         raise TypeError(f"{type(seq)} not supported")
 
     @to_index.register
-    def _(self, seq: str) -> numpy.ndarray:
+    def _(self, seq: str) -> int:
         seq = self.monomers.to_indices(seq)
         return self.to_index(seq)
 
     @to_index.register
-    def _(self, seq: bytes) -> numpy.ndarray:
+    def _(self, seq: bytes) -> int:
         seq = self.monomers.to_indices(seq)
         return self.to_index(seq)
 
     @to_index.register
-    def _(self, seq: numpy.ndarray) -> numpy.ndarray:
+    def _(self, seq: numpy.ndarray) -> int:
         assert len(seq) == self.k
         if (self.monomers.num_canonical > seq).all():
             return coord_to_index(seq, self._coeffs)
@@ -862,7 +867,7 @@ def deserialise_kmer_alphabet(data: dict) -> KmerAlphabet:
     return KmerAlphabet.from_rich_dict(data)
 
 
-class CodonAlphabet(tuple, AlphabetABC):
+class SenseCodonAlphabet(tuple, AlphabetABC):
     """represents the sense-codons of a GeneticCode"""
 
     def __new__(
@@ -908,12 +913,12 @@ def __init__(
             _alphabet_moltype_map[self] = monomers.moltype
 
     @property
-    def gap_char(self):
+    def gap_char(self) -> str:
         return self._gap_char
 
     @property
-    def gap_index(self):
-        return self._to_indices(self.gap_char) if self._gap_char else None
+    def gap_index(self) -> OptInt:
+        return self._to_indices[self.gap_char] if self._gap_char else None
 
     @property
     def missing_char(self):
@@ -936,7 +941,15 @@ def _(self, seq: str) -> numpy.ndarray:
 
     @to_indices.register
     def _(self, seq: list) -> numpy.ndarray:
-        return [self.to_index(c) for c in seq]
+        return numpy.array([self.to_index(c) for c in seq], dtype=self.dtype)
+
+    @to_indices.register
+    def _(self, seq: numpy.ndarray) -> numpy.ndarray:
+        # we assume that this is a dna sequence encoded as a numpy array
+        size = len(seq) // 3
+        return self.to_indices(
+            [self.monomers.from_indices(c) for c in seq.reshape(size, 3)]
+        )
 
     def to_index(self, codon: str) -> int:
         if len(codon) != 3:
@@ -944,7 +957,7 @@ def to_index(self, codon: str) -> int:
         try:
             return self._to_indices[codon]
         except KeyError as e:
-            raise ValueError(f"{codon=!r} not in alphabet") from e
+            raise AlphabetError(f"{codon=!r} not in alphabet") from e
 
     def from_index(self, index: int) -> str:
         if index > len(self) or index < 0:
@@ -972,7 +985,7 @@ def _(self, seq: str) -> bool:
         try:
             _ = self.to_indices(seq)
             return True
-        except ValueError:
+        except (ValueError, AlphabetError):
             return False
 
     @is_valid.register
@@ -1008,9 +1021,9 @@ def from_rich_dict(cls, data):
         return cls(**data)
 
 
-@register_deserialiser(get_object_provenance(CodonAlphabet))
-def deserialise_codon_alphabet(data: dict) -> CodonAlphabet:
-    return CodonAlphabet.from_rich_dict(data)
+@register_deserialiser(get_object_provenance(SenseCodonAlphabet))
+def deserialise_codon_alphabet(data: dict) -> SenseCodonAlphabet:
+    return SenseCodonAlphabet.from_rich_dict(data)
 
 
 _alphabet_moltype_map = {}
diff --git a/src/cogent3/core/new_genetic_code.py b/src/cogent3/core/new_genetic_code.py
index 67992a18b..a385dc288 100644
--- a/src/cogent3/core/new_genetic_code.py
+++ b/src/cogent3/core/new_genetic_code.py
@@ -12,6 +12,7 @@
 import collections
 import contextlib
 import dataclasses
+import functools
 import itertools
 import typing
 
@@ -193,14 +194,18 @@ def __getitem__(self, item):
             return self._aa_to_codon.get(item, set())
 
         if len(item) != 3:
-            raise InvalidCodonError(f"Codon or aa {item} has wrong length")
+            raise InvalidCodonError(f"Codon or aa {item!r} has wrong length")
 
         key = item.upper()
         key = key.replace("U", "T")
         return self._codon_to_aa.get(key, "X")
 
     def translate(
-        self, dna: StrORBytesORArray, start: int = 0, rc: bool = False
+        self,
+        dna: StrORBytesORArray,
+        start: int = 0,
+        rc: bool = False,
+        incomplete_ok: bool = True,
     ) -> str:
         """Translates DNA to protein.
 
@@ -212,17 +217,22 @@ def translate(
             position to begin translation (used to implement frames)
         rc
             if True, returns the translation of the reverse complement sequence
+        incomplete_ok
+            if True, translates codons that are a mix of gaps and bases
+            as a gap. If False, raises an AlphabetError on those incomplete
+            cases.
 
         Notes
         -----
         Sequences are truncated to be a multiple of 3.
         Codons containing ambiguous nucleotides are translated as 'X',
-        codons containing a gap character are translated as '-'. Codons with
-        a mix of ambiguous nucleotides and gaps are translated as 'X'.
+        codons containing a gap character are translated as '-' unless
+        incomplete_ok is False. Codons with a mix of ambiguous nucleotides
+        are translated as 'X'.
 
         Returns
         -------
-        String containing amino acid sequence.
+        The amino acid sequence as a string.
         """
         # refactor: design
         # previous implementation converted mix of ambigs and nt to '?', potentially
@@ -234,7 +244,11 @@ def translate(
             dna = dna[:-diff]
 
         # convert to indices and then bytes
-        seq = self.codons.to_indices(dna)
+        if incomplete_ok:
+            seq = self.codons.to_indices(dna)
+        else:
+            codons = self.get_alphabet(include_stop=True, include_gap=True)
+            seq = codons.to_indices(dna)
 
         if rc:
             return self._translate_minus(seq.tobytes()).decode("utf8")[::-1]
@@ -256,9 +270,10 @@ def is_stop(self, codon):
         """Returns True if codon is a stop codon, False otherwise."""
         return self[codon] == "*"
 
+    @functools.cache
     def get_alphabet(
         self, include_gap: bool = False, include_stop: bool = False
-    ) -> new_alphabet.CodonAlphabet:
+    ) -> new_alphabet.SenseCodonAlphabet:
         """returns a codon alphabet
 
         Parameters
@@ -276,8 +291,8 @@ def get_alphabet(
         if include_gap:
             codons += (gap,)
 
-        return new_alphabet.CodonAlphabet(
-            words=codons, monomers=self.moltype.alphabet, gap=gap
+        return new_alphabet.SenseCodonAlphabet(
+            words=codons, monomers=self.moltype.degen_gapped_alphabet, gap=gap
         )
 
     def to_regex(self, seq: typing.Union[str, "Sequence"]) -> str:
@@ -494,14 +509,13 @@ def get_code(code_id: StrORInt = 1) -> GeneticCode:
     # refactor: simplify
     # added for compatibility with previous API, should be removed when
     # support for old style is dropped
-    if code_id is None:
-        code_id = 1
+    code_id = code_id or 1
 
     with contextlib.suppress((ValueError, TypeError)):
         code_id = int(code_id)
 
     if code_id not in _CODES:
-        raise GeneticCodeError(f"Unknown genetic code {code_id}")
+        raise GeneticCodeError(f"Unknown genetic code {code_id!r}")
     return _CODES[code_id]
 
 
diff --git a/src/cogent3/core/new_moltype.py b/src/cogent3/core/new_moltype.py
index 2c2005698..a16e85887 100644
--- a/src/cogent3/core/new_moltype.py
+++ b/src/cogent3/core/new_moltype.py
@@ -8,21 +8,16 @@
 import numpy
 
 from cogent3.core import new_alphabet, new_sequence
-from cogent3.data.molecular_weight import (
-    DnaMW,
-    ProteinMW,
-    RnaMW,
-    WeightCalculator,
-)
+from cogent3.data.molecular_weight import DnaMW, ProteinMW, RnaMW, WeightCalculator
 
 OptStr = typing.Optional[str]
 OptFloat = typing.Optional[float]
 OptCallable = typing.Optional[typing.Callable]
 SeqStrType = typing.Union[list[str], tuple[str, ...]]
 StrORBytes = typing.Union[str, bytes]
+StrORArray = typing.Union[str, numpy.ndarray]
 StrORBytesORArray = typing.Union[str, bytes, numpy.ndarray]
 SeqStrBytesType = typing.Union[list[StrORBytes], tuple[StrORBytes, ...]]
-StrORArray = typing.Union[str, numpy.ndarray]
 
 IUPAC_gap = "-"
 
@@ -145,6 +140,9 @@
 }
 
 
+class MolTypeError(TypeError): ...
+
+
 def make_pairs(
     *,
     pairs: dict[tuple[str, str], bool] = None,
@@ -546,7 +544,12 @@ def is_compatible_alphabet(
         return any(alpha == alphabet for alpha in self.iter_alphabets())
 
     def make_seq(
-        self, *, seq: str, name: OptStr = None, check_seq=True, **kwargs
+        self,
+        *,
+        seq: typing.Union[str, "SeqViewABC"],
+        name: OptStr = None,
+        check_seq: bool = True,
+        **kwargs,
     ) -> new_sequence.Sequence:
         """creates a Sequence object corresponding to the molecular type of
         this instance.
@@ -572,7 +575,8 @@ def make_seq(
             assert self.is_valid(
                 seq
             ), f"{seq[:4]!r} not valid for moltype {self.name!r}"
-        return self._make_seq(moltype=self, seq=seq or "", name=name, **kwargs)
+        seq = "" if seq is None else seq
+        return self._make_seq(moltype=self, seq=seq, name=name, **kwargs)
 
     @functools.singledispatchmethod
     def complement(self, seq: StrORBytesORArray, validate: bool = True) -> str:
@@ -606,7 +610,7 @@ def _(self, seq: bytes, validate: bool = True) -> str:
         return self._complement(seq)
 
     @complement.register
-    def _(self, seq: numpy.ndarray, validate: bool = True) -> str:
+    def _(self, seq: numpy.ndarray, validate: bool = True) -> numpy.ndarray[int]:
         if validate and not self.is_valid(seq):
             raise new_alphabet.AlphabetError(
                 f"{seq[:4]!r} not valid for moltype {self.name!r}"
@@ -618,6 +622,17 @@ def _(self, seq: numpy.ndarray, validate: bool = True) -> str:
             )
         )
 
+    @property
+    def is_nucleic(self) -> bool:
+        """is a nucleic acid moltype
+
+        Notes
+        -----
+        nucleic moltypes can be used for complementing and translating
+        into amino acids.
+        """
+        return callable(self._complement)
+
     def rc(self, seq: str, validate: bool = True) -> str:
         """reverse reverse complement of a sequence"""
         if validate and not self.is_valid(seq):
@@ -639,7 +654,7 @@ def _(self, seq: bytes, validate: bool = True) -> bool:  # refactor: docstring
                 f"{seq[:4]!r} not valid for moltype {self.name!r}"
             )
         return self.is_degenerate(
-            self.degen_gapped_alphabet.to_indices(seq), validate=False
+            self.most_degen_alphabet().to_indices(seq), validate=False
         )
 
     @is_degenerate.register
@@ -649,23 +664,22 @@ def _(self, seq: str, validate: bool = True) -> bool:
                 f"{seq[:4]!r} not valid for moltype {self.name!r}"
             )
         return self.is_degenerate(
-            self.degen_gapped_alphabet.to_indices(seq), validate=False
+            self.most_degen_alphabet().to_indices(seq), validate=False
         )
 
     @is_degenerate.register
     def _(self, seq: numpy.ndarray, validate: bool = True) -> bool:
-        # what index is the first degenerate character
+        if self.degen_alphabet is None:
+            return False
+
         if validate and not self.is_valid(seq):
             raise new_alphabet.AlphabetError(
                 f"{seq[:4]!r} not valid for moltype {self.name!r}"
             )
 
-        for index, val in enumerate(self.degen_gapped_alphabet):
-            if val in self.ambiguities:
-                break
-        else:
-            return False
-        return (seq >= index).any()
+        first_degen = self.most_degen_alphabet().gap_index + 1
+
+        return (seq >= first_degen).any()
 
     @functools.singledispatchmethod
     def is_gapped(self, seq, validate: bool = True) -> bool:
@@ -714,11 +728,12 @@ def get_degenerate_positions(
                 f"{seq[:4]!r} not valid for moltype {self.name!r}"
             )
 
+        if self.degen_alphabet is None:
+            return []
+
         alpha = self.most_degen_alphabet()
         seq = alpha.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
+        index = len(self) if include_gap else len(self) + 1
         degens = seq >= index
         return numpy.where(degens)[0].tolist()
 
diff --git a/src/cogent3/core/new_sequence.py b/src/cogent3/core/new_sequence.py
index 5cde13ae2..85bcc20be 100644
--- a/src/cogent3/core/new_sequence.py
+++ b/src/cogent3/core/new_sequence.py
@@ -24,7 +24,7 @@
 from numpy import array, floating, integer, issubdtype
 
 from cogent3._version import __version__
-from cogent3.core import new_alphabet
+from cogent3.core import new_alphabet, new_moltype
 from cogent3.core.annotation import Feature
 from cogent3.core.annotation_db import (
     BasicAnnotationDb,
@@ -34,7 +34,13 @@
     load_annotations,
 )
 from cogent3.core.info import Info as InfoClass
-from cogent3.core.location import FeatureMap, IndelMap, LostSpan
+from cogent3.core.location import (
+    FeatureMap,
+    IndelMap,
+    LostSpan,
+    _input_vals_neg_step,
+    _input_vals_pos_step,
+)
 from cogent3.format.fasta import seqs_to_fasta
 from cogent3.maths.stats.contingency import CategoryCounts
 from cogent3.maths.stats.number import CategoryCounter
@@ -78,20 +84,12 @@ def _moltype_seq_from_rich_dict(data):
     data.pop("type")
     data.pop("version")
     data.pop("annotation_db", None)
-    offset = data.pop("annotation_offset", 0)
+    data.pop("annotation_offset", 0)
 
     moltype = data.pop("moltype")
     moltype = new_moltype.get_moltype(moltype)
 
-    seqview_data = data.pop("seq")
-    seq = _coerce_to_seqview(
-        seqview_data["init_args"]["seq"],
-        data["name"],
-        moltype.most_degen_alphabet(),
-        offset,
-    )
-    seq = seq[:: seqview_data["init_args"]["step"]]
-
+    seq = data.pop("seq")
     return moltype, seq
 
 
@@ -130,14 +128,24 @@ class Sequence:
     Sequences should be constructed by a MolType instance.
     """
 
+    __slots__ = (
+        "moltype",
+        "name",
+        "_seq",
+        "info",
+        "_repr_policy",
+        "_annotation_db",
+    )
+
     def __init__(
         self,
         moltype: "MolType",
-        seq: str,
+        seq: typing.Union[StrORBytesORArray, SeqViewABC],
         *,
         name: OptStr = None,
         info: typing.Optional[typing.Union[dict, InfoClass]] = None,
         annotation_offset: int = 0,
+        annotation_db: typing.Optional[SupportsFeatures] = None,
     ):
         """Initialize a sequence.
 
@@ -153,6 +161,13 @@ def __init__(
             Info object or dict
         annotation_offset
             integer indicating start position relative to annotations
+        annotation_db
+            optional annotation database
+
+        Notes
+        -----
+        If a user attempts to add a feature and the annotation_db is None,
+        a default BasicAnnotationDb instance will be created and used.
         """
         self.moltype = moltype
         self.name = name
@@ -163,7 +178,7 @@ def __init__(
         info = info or {}
         self.info = InfoClass(**info)
         self._repr_policy = dict(num_pos=60)
-        self._annotation_db = DEFAULT_ANNOTATION_DB()
+        self._annotation_db = annotation_db
 
     def __str__(self):
         result = str(self._seq)
@@ -179,13 +194,37 @@ def __bytes__(self):
                 result = self.moltype.complement(result)
         return result
 
-    def __array__(self):
-        result = array(self._seq)
+    def __array__(self, dtype=None, copy=None) -> numpy.ndarray[int]:
+        result = array(self._seq, dtype=dtype)
         if self._seq.is_reversed:
             with contextlib.suppress(TypeError):
                 result = self.moltype.complement(result)
         return result
 
+    def to_array(self, apply_transforms: bool = True) -> numpy.ndarray[int]:
+        """returns the numpy array
+
+        Parameters
+        ----------
+        apply_transforms
+            if True, applies any reverse complement operation
+
+        Notes
+        -----
+        Use this method with apply_transforms=False if you are
+        creating data for storage in a SeqData instance.
+        """
+        if apply_transforms:
+            return numpy.array(self)
+
+        arr = self._seq.array_value
+        if self._seq.is_reversed:
+            # the reversal will have been applied in the SeqView
+            # array_value method, so we undo that here.
+            arr = arr[::-1]
+
+        return arr
+
     def to_fasta(self, make_seqlabel=None, block_size=60) -> str:
         """Return string of self in FASTA format, no trailing newline
 
@@ -229,17 +268,16 @@ def to_rich_dict(
             info.pop("Refs")
 
         info = info or None
-        seq = self._seq.to_rich_dict() if hasattr(self, "_seq") else str(self)
         data = dict(
             name=self.name,
-            seq=seq,
+            seq=str(self),
             moltype=self.moltype.label,
             info=info,
             type=get_object_provenance(self),
             version=__version__,
         )
         if hasattr(self, "annotation_offset"):
-            offset = int(self._seq.parent_start)
+            offset = int(self._seq.slice_record.parent_start)
             data |= dict(annotation_offset=offset)
 
         if (
@@ -856,7 +894,7 @@ def annotation_offset(self):
             int: The offset between annotation coordinates and sequence coordinates.
         """
 
-        return self._seq.parent_start
+        return self._seq.slice_record.parent_start
 
     @property
     def annotation_db(self):
@@ -893,9 +931,8 @@ def replace_annotation_db(
         if value == self._annotation_db:
             return
 
-        if check and value:
-            if not isinstance(value, SupportsFeatures):
-                raise TypeError(f"{type(value)} does not satisfy SupportsFeatures")
+        if check and value and not isinstance(value, SupportsFeatures):
+            raise TypeError(f"{type(value)} does not satisfy SupportsFeatures")
 
         self._annotation_db = value
 
@@ -949,10 +986,14 @@ def get_features(
             *_,
             strand,
         ) = self.parent_coordinates()
-        query_start = self._seq.absolute_position(start, include_boundary=False)
+        query_start = self._seq.slice_record.absolute_position(
+            start, include_boundary=False
+        )
         # we set include_boundary=True because stop is exclusive indexing,
         # i,e., the stop can be equal to the length of the view
-        query_stop = self._seq.absolute_position(stop, include_boundary=True)
+        query_stop = self._seq.slice_record.absolute_position(
+            stop, include_boundary=True
+        )
 
         rev_strand = strand == -1
         if rev_strand:
@@ -989,7 +1030,7 @@ def get_features(
             # current view
             spans = array(feature.pop("spans"), dtype=int)
             for i, v in enumerate(spans.ravel()):
-                rel_pos = self._seq.relative_position(v)
+                rel_pos = self._seq.slice_record.relative_position(v)
                 spans.ravel()[i] = rel_pos
 
             if rev_strand:
@@ -1017,6 +1058,11 @@ def _relative_spans(self, spans):
 
         return r_spans
 
+    def _init_annotation_db(self) -> None:
+        """initialise a default type annotation db if not already set"""
+        if self._annotation_db is None:
+            self._annotation_db = DEFAULT_ANNOTATION_DB()
+
     def make_feature(self, feature: FeatureDataType, *args) -> Feature:
         """
         return an Feature instance from feature data
@@ -1131,13 +1177,15 @@ def add_feature(
             seqid=self.name,
             **{n: v for n, v in locals().items() if n not in ("self", "seqid")},
         )
+        if self._annotation_db is None:
+            self._init_annotation_db()
 
         self.annotation_db.add_feature(**feature_data)
         for discard in ("on_alignment", "parent_id"):
             feature_data.pop(discard)
         return self.make_feature(feature_data)
 
-    def to_moltype(self, moltype: str):
+    def to_moltype(self, moltype: typing.Union[str, new_moltype.MolType]) -> "Sequence":
         """returns copy of self with moltype seq
 
         Parameters
@@ -1153,7 +1201,6 @@ def to_moltype(self, moltype: str):
         When applied to a sequence in a SequenceCollection, the resulting
         sequence will no longer be part of the collection.
         """
-        from cogent3.core import new_moltype
 
         if not moltype:
             raise ValueError(f"unknown moltype '{moltype}'")
@@ -1180,7 +1227,9 @@ def to_moltype(self, moltype: str):
                 f"Changing from old moltype={self.moltype.label!r} to new "
                 f"moltype={moltype.label!r} is not valid for this data"
             )
-        sv = SeqView(seq=seq, alphabet=moltype.most_degen_alphabet())
+        sv = SeqView(
+            parent=seq, parent_len=len(seq), alphabet=moltype.most_degen_alphabet()
+        )
         new = self.__class__(moltype=moltype, seq=sv, name=self.name, info=self.info)
         new.annotation_db = self.annotation_db
         return new
@@ -1307,8 +1356,8 @@ def with_masked_annotations(
         return new
 
     def gapped_by_map_segment_iter(
-        self, segment_map, allow_gaps=True, recode_gaps=False
-    ) -> str:
+        self, segment_map: IndelMap, allow_gaps: bool = True, recode_gaps: bool = False
+    ) -> typing.Iterator[str, str, str]:
         if not allow_gaps and not segment_map.complete:
             raise ValueError(f"gap(s) in map {segment_map}")
 
@@ -1321,12 +1370,14 @@ def gapped_by_map_segment_iter(
 
             yield seg
 
-    def gapped_by_map_motif_iter(self, map):
-        for segment in self.gapped_by_map_segment_iter(map):
+    def gapped_by_map_motif_iter(
+        self, segment_map: IndelMap
+    ) -> typing.Iterator[str, str, str]:
+        for segment in self.gapped_by_map_segment_iter(segment_map):
             yield from segment
 
-    def gapped_by_map(self, map, recode_gaps=False):
-        segments = self.gapped_by_map_segment_iter(map, True, recode_gaps)
+    def gapped_by_map(self, segment_map: IndelMap, recode_gaps: bool = False):
+        segments = self.gapped_by_map_segment_iter(segment_map, True, recode_gaps)
         return self.__class__(
             moltype=self.moltype,
             seq="".join(segments),
@@ -1334,22 +1385,19 @@ def gapped_by_map(self, map, recode_gaps=False):
             info=self.info,
         )
 
-    def _mapped(self, map):
+    def _mapped(self, segment_map: IndelMap):
         # Called by generic __getitem__
-        if map.num_spans == 1:
-            seq = self._seq[map.start : map.end]
-            offset = map.start
+        if segment_map.num_spans == 1:
+            seq = self._seq[segment_map.start : segment_map.end]
         else:
-            segments = self.gapped_by_map_segment_iter(map, allow_gaps=False)
+            segments = self.gapped_by_map_segment_iter(segment_map, allow_gaps=False)
             seq = "".join(segments)
-            offset = 0
 
         return self.__class__(
             moltype=self.moltype,
             seq=seq,
             name=self.name,
             info=self.info,
-            annotation_offset=offset,
         )
 
     def __repr__(self):
@@ -1518,10 +1566,23 @@ def parse_out_gaps(self):
         seq.annotation_db = self.annotation_db
         return indel_map, seq
 
-    def is_annotated(self):
-        """returns True if sequence has any annotations"""
-        num = self.annotation_db.num_matches() if self.annotation_db else 0
-        return num != 0
+    def is_annotated(
+        self, biotype: typing.Optional[typing.Union[str, tuple[str]]] = None
+    ) -> bool:
+        """returns True if sequence parent name has any annotations
+
+        Parameters
+        ----------
+        biotype
+            amend condition to return True only if the sequence is
+            annotated with one of provided biotypes.
+        """
+        with contextlib.suppress(AttributeError):
+            return (
+                self.annotation_db.num_matches(seqid=self._seq.seqid, biotype=biotype)
+                != 0
+            )
+        return False
 
     def annotate_matches_to(
         self, pattern: str, biotype: str, name: str, allow_multiple: bool = False
@@ -1564,7 +1625,7 @@ def annotate_matches_to(
         ]
 
     def get_drawables(
-        self, *, biotype: typing.Optional[str, typing.Iterable[str]] = None
+        self, *, biotype: typing.Optional[StrORIterableStr] = None
     ) -> dict:
         """returns a dict of drawables, keyed by type
 
@@ -1664,7 +1725,12 @@ def parent_coordinates(self) -> tuple[str, int, int, int]:
         -1 or 1.
         """
         strand = -1 if self._seq.is_reversed else 1
-        return self._seq.seqid, self._seq.parent_start, self._seq.parent_stop, strand
+        return (
+            self._seq.seqid,
+            self._seq.slice_record.parent_start,
+            self._seq.slice_record.parent_stop,
+            strand,
+        )
 
 
 @register_deserialiser(get_object_provenance(Sequence))
@@ -1903,9 +1969,13 @@ def get_translation(
         ------
         AlphabetError if include_stop is False and a stop codon occurs
         """
-
         from cogent3.core import new_genetic_code, new_moltype
 
+        if not self.moltype.is_nucleic:
+            raise new_moltype.MolTypeError(
+                f"moltype must be a DNA/RNA, not {self.moltype.name!r}"
+            )
+
         protein = new_moltype.get_moltype(
             "protein_with_stop" if include_stop else "protein"
         )
@@ -1918,12 +1988,12 @@ def get_translation(
 
         # since we are realising the view, reverse complementing will be
         # dealt with, so rc=False
-        pep = gc.translate(array(seq), rc=False)
+        pep = gc.translate(array(seq), rc=False, incomplete_ok=incomplete_ok)
 
         if not include_stop and "*" in pep:
             raise new_alphabet.AlphabetError("stop codon in translation")
 
-        if not incomplete_ok and ("-" in pep or "X" in pep):
+        if not incomplete_ok and "X" in pep:
             raise new_alphabet.AlphabetError(
                 "Incomplete codon in translation, set incomplete_ok=True to "
                 "allow translation"
@@ -1981,53 +2051,6 @@ def deserialise_rna_sequence(data) -> RnaSequence:
     return RnaSequence.from_rich_dict(data)
 
 
-def _input_vals_pos_step(seqlen, start, stop, step):
-    start = 0 if start is None else start
-    if start > 0 and start >= seqlen:
-        # start beyond seq is an empty slice
-        return 0, 0, 1
-
-    stop = seqlen if stop is None else stop
-    if stop < 0 and abs(stop) >= seqlen:
-        # finished slice before we started seq!
-        return 0, 0, 1
-
-    start = max(seqlen + start, 0) if start < 0 else start
-
-    if stop > 0:
-        stop = min(seqlen, stop)
-    elif stop < 0:
-        stop += seqlen
-
-    if start >= stop:
-        start = stop = 0
-        step = 1
-
-    return start, stop, step
-
-
-def _input_vals_neg_step(seqlen, start, stop, step):
-    # Note how Python reverse slicing works
-    # we need to make sure the start and stop are both
-    # negative, for example "abcd"[-1:-5:-1] returns "dcba"
-    if start is None or start >= seqlen:  # set default
-        start = -1  # Done
-    elif start >= 0:  # convert to -ve index
-        start = start - seqlen
-    elif start < -seqlen:  # start is bounded by len(seq)
-        return 0, 0, 1
-
-    if stop is None:  # set default
-        stop = -seqlen - 1
-    elif stop >= 0:
-        stop -= seqlen
-
-    stop = max(stop, -seqlen - 1)  # stop should always be <= len(seq)
-
-    # checking for zero-length slice
-    return (0, 0, 1) if start < stop else (start, stop, step)
-
-
 class SliceRecordABC(ABC):
     """Abstract base class for recording the history of operations to be applied
     to some underlying data. Provides slicing functionality for the underlying data.
@@ -2043,19 +2066,20 @@ class SliceRecordABC(ABC):
     offset
         can be set with any additional offset that exists before the start of
         the underlying data
-    seq_len
+    parent_len
         length of the underlying data (not including offset)
-
-    Notes
-    -----
-    seq_len refers to a Python typing.Sequence object, e.g. array, str, list.
     """
 
     __slots__ = ("start", "stop", "step", "_offset")
 
+    @abstractmethod
+    def __eq__(self, other): ...
+
+    @abstractmethod
+    def __neq__(self, other): ...
     @property
     @abstractmethod
-    def seq_len(self) -> int: ...
+    def parent_len(self) -> int: ...
 
     @abstractmethod
     def _get_init_kwargs(self) -> dict:
@@ -2081,6 +2105,47 @@ def offset(self, value: int):
         value = value or 0
         self._offset = int(value)
 
+    @property
+    def plus_start(self) -> int:
+        """start on plus strand"""
+        if self.is_reversed:
+            # all indices are negative, so the stop becomes the start. To convert
+            # to positive indices, we add the length of the parent sequence to
+            # the stop. Note that when abs(self.step) > 1, we instead calculate
+            # the "true stop", i.e., the index that immediately follows (in the
+            # direction of the step) the last selected index in the slice. We then
+            # add the abs(self.step) to account for the fact that the stop uses
+            # exclusive indexing, and the start is inclusive.
+
+            assert self.stop < 0, "expected stop on reverse strand SeqView < 0"
+            stop = self.stop if self.step == -1 else self.start + len(self) * self.step
+            start = self.parent_len + stop + abs(self.step)
+        else:
+            start = self.start
+        return start
+
+    @property
+    def plus_stop(self) -> int:
+        """stop on plus strand"""
+        if self.is_reversed:
+            # self.start becomes the stop, self.start will be negative
+            assert self.start < 0, "expected start on reverse strand SeqView < 0"
+            stop = self.start + self.parent_len + 1
+        else:
+            # we ensure that the plus_stop is the index that immediately follows
+            # the last selected index in the slice.
+            stop = (
+                self.stop
+                if self.step == 1
+                else (self.start + len(self) * self.step - self.step) + 1
+            )
+        return stop
+
+    @property
+    def plus_step(self) -> int:
+        """step on plus strand"""
+        return abs(self.step)
+
     @property
     def parent_start(self) -> int:
         """returns the start on the parent plus strand
@@ -2089,15 +2154,12 @@ def parent_start(self) -> int:
         -------
         offset + start, taking into account whether reversed. Result
         is positive.
-        """
-        if self.is_reversed:
-            # self.stop becomes the start, self.stop will be negative
-            assert self.stop < 0, "expected stop on reverse strand SeqView < 0"
-            start = self.stop + self.seq_len + 1
-        else:
-            start = self.start
 
-        return self.offset + start
+        Notes
+        -----
+        This should NOT be used for slicing on the parent object.
+        """
+        return self.offset + self.plus_start
 
     @property
     def is_reversed(self):
@@ -2111,14 +2173,12 @@ def parent_stop(self) -> int:
         -------
         offset + stop, taking into account whether reversed. Result
         is positive.
+
+        Notes
+        -----
+        This should NOT be used for slicing on the parent object.
         """
-        if self.is_reversed:
-            # self.start becomes the stop, self.start will be negative
-            assert self.start < 0, "expected start on reverse strand SeqView < 0"
-            stop = self.start + self.seq_len + 1
-        else:
-            stop = self.stop
-        return self.offset + stop
+        return self.offset + self.plus_stop
 
     def absolute_position(self, rel_index: int, include_boundary: bool = False):
         """Converts an index relative to the current view to be with respect
@@ -2145,7 +2205,7 @@ def absolute_position(self, rel_index: int, include_boundary: bool = False):
 
         offset = self.offset
         return (
-            offset + self.seq_len + seq_index + 1
+            offset + self.parent_len + seq_index + 1
             if self.is_reversed
             else offset + seq_index
         )
@@ -2169,7 +2229,7 @@ def relative_position(self, abs_index: int, stop: bool = False):
             offset = self.offset
 
             if (
-                tmp := (self.seq_len - abs_index + offset + self.start + 1)
+                tmp := (self.parent_len - abs_index + offset + self.start + 1)
             ) % self.step == 0 or stop:
                 rel_pos = tmp // abs(self.step)
             else:
@@ -2198,7 +2258,7 @@ def __getitem__(self, segment: typing.Union[int, slice]):
                 stop=stop,
                 step=step,
                 offset=self.offset,
-                seq_len=self.seq_len,
+                parent_len=self.parent_len,
                 **kwargs,
             )
 
@@ -2292,7 +2352,7 @@ def _get_forward_slice_from_forward_seqview_(
         # checking for zero-length slice
         if stop < start:
             return self._zero_slice
-        if start > self.seq_len:
+        if start > self.parent_len:
             return self._zero_slice
 
         return self.__class__(
@@ -2300,7 +2360,7 @@ def _get_forward_slice_from_forward_seqview_(
             stop=min(self.stop, stop),
             step=self.step * step,
             offset=self.offset,
-            seq_len=self.seq_len,
+            parent_len=self.parent_len,
             **kwargs,
         )
 
@@ -2328,7 +2388,7 @@ def _get_forward_slice_from_reverse_seqview_(
             stop=max(self.stop, stop),
             step=self.step * step,
             offset=self.offset,
-            seq_len=self.seq_len,
+            parent_len=self.parent_len,
             **kwargs,
         )
 
@@ -2352,28 +2412,28 @@ def _get_reverse_slice_from_forward_seqview_(
         # max possible start is "true stop" - step, because stop is not inclusive
         # "true stop" - step is converted to -ve index via subtracting len(self)
         if slice_start >= len(self):
-            start = (self.start + len(self) * self.step - self.step) - self.seq_len
+            start = (self.start + len(self) * self.step - self.step) - self.parent_len
         elif slice_start >= 0:
-            start = (self.start + slice_start * self.step) - self.seq_len
+            start = (self.start + slice_start * self.step) - self.parent_len
         else:
             start = (
                 self.start
                 + len(self) * self.step
                 + slice_start * self.step
-                - self.seq_len
+                - self.parent_len
             )
 
-        if slice_stop >= self.seq_len:
+        if slice_stop >= self.parent_len:
             return self._zero_slice
 
         if slice_stop >= 0:
-            stop = self.start + (slice_stop * self.step) - self.seq_len
+            stop = self.start + (slice_stop * self.step) - self.parent_len
         else:
             stop = (
                 self.start
                 + (len(self) * self.step)
                 + (slice_stop * self.step)
-                - self.seq_len
+                - self.parent_len
             )
 
         if start >= 0 or stop >= 0:
@@ -2381,41 +2441,41 @@ def _get_reverse_slice_from_forward_seqview_(
 
         return self.__class__(
             start=start,
-            stop=max(stop, self.start - self.seq_len - 1),
+            stop=max(stop, self.start - self.parent_len - 1),
             step=self.step * step,
             offset=self.offset,
-            seq_len=self.seq_len,
+            parent_len=self.parent_len,
             **kwargs,
         )
 
     def _get_reverse_slice_from_reverse_seqview_(
         self, slice_start: int, slice_stop: int, step: int, **kwargs
     ):
-        # max start is "true stop" + abs(step), because stop is not inclusive
-        # "true stop" adjust for if abs(stop-start) % step != 0
+        # refactor: simplify
+        # are there other places in slice_record where we can use plus_start/stop/step
         if slice_start >= len(self):
-            start = self.seq_len + self.start + len(self) * self.step + abs(self.step)
+            start = self.plus_start
         elif slice_start >= 0:
-            start = self.seq_len + (self.start + slice_start * self.step)
+            start = self.parent_len + (self.start + slice_start * self.step)
         else:
-            start = self.seq_len + (
+            start = self.parent_len + (
                 self.start + len(self) * self.step + slice_start * self.step
             )
 
         if slice_stop >= 0:
-            stop = self.seq_len + (self.start + slice_stop * self.step)
-            if stop <= self.seq_len + self.stop:
+            stop = self.parent_len + (self.start + slice_stop * self.step)
+            if stop <= self.parent_len + self.stop:
                 return self._zero_slice
         else:
-            stop = self.seq_len + (
+            stop = self.parent_len + (
                 self.start + len(self) * self.step + slice_stop * self.step
             )
-            if stop > self.seq_len + self.start:
-                stop = self.seq_len + self.start + 1
+            if stop > self.parent_len + self.start:
+                stop = self.parent_len + self.start + 1
 
         # if -ve, it's an invalid slice becomes zero
         # checking for zero-length slice
-        if stop < start or start > self.seq_len or min(start, stop) < 0:
+        if stop < start or start > self.parent_len or min(start, stop) < 0:
             return self._zero_slice
 
         return self.__class__(
@@ -2423,11 +2483,79 @@ def _get_reverse_slice_from_reverse_seqview_(
             stop=stop,
             step=self.step * step,
             offset=self.offset,
-            seq_len=self.seq_len,
+            parent_len=self.parent_len,
             **kwargs,
         )
 
 
+class SliceRecord(SliceRecordABC):
+    __slots__ = "_parent_len"
+
+    def __init__(
+        self,
+        *,
+        parent_len: int,
+        start: OptInt = None,
+        stop: OptInt = None,
+        step: OptInt = None,
+        offset: int = 0,
+    ):
+        if step == 0:
+            raise ValueError("step cannot be 0")
+        step = step if step is not None else 1
+        func = _input_vals_pos_step if step > 0 else _input_vals_neg_step
+        start, stop, step = func(parent_len, start, stop, step)
+        self.start = start
+        self.stop = stop
+        self.step = step
+        self._parent_len = parent_len
+        self._offset = offset or 0
+
+    def __eq__(self, other: SliceRecordABC) -> bool:
+        return (
+            self.start == other.start
+            and self.stop == other.stop
+            and self.step == other.step
+            and self._parent_len == other.parent_len
+            and self._offset == other.offset
+        )
+
+    def __neq__(self, other: SliceRecordABC) -> bool:
+        return self != other
+
+    @property
+    def parent_len(self) -> int:
+        return self._parent_len
+
+    def _get_init_kwargs(self) -> dict:
+        return {}
+
+    def copy(self, sliced: bool = False):
+        # todo: kath
+        return self
+
+    def __repr__(self):
+        return (
+            f"{self.__class__.__name__}(start={self.start}, stop={self.stop}, step={self.step}, "
+            f"parent_len={self.parent_len}, offset={self.offset})"
+        )
+
+    @property
+    def _zero_slice(self):
+        return self.__class__(start=0, stop=0, step=1, parent_len=self.parent_len)
+
+    def to_rich_dict(self) -> dict:
+        data = {"type": get_object_provenance(self), "version": __version__}
+        data["init_args"] = {
+            "parent_len": self.parent_len,
+            "start": self.start,
+            "stop": self.stop,
+            "step": self.step,
+            "offset": self.offset,
+        }
+        return data
+
+
 class SeqViewABC(ABC):
     """
     An abstract base class for providing a view of a sequence.
@@ -2444,6 +2572,22 @@ class SeqViewABC(ABC):
     @abstractmethod
     def seqid(self) -> OptStr: ...
 
+    @property
+    @abstractmethod
+    def parent_len(self) -> int: ...
+
+    @property
+    @abstractmethod
+    def slice_record(self) -> SliceRecordABC: ...
+
+    @property
+    def offset(self) -> int:
+        return self.slice_record.offset
+
+    @property
+    def is_reversed(self) -> bool:
+        return self.slice_record.is_reversed
+
     @property
     @abstractmethod
     def str_value(self) -> str: ...
@@ -2460,22 +2604,32 @@ def bytes_value(self) -> bytes: ...
     def copy(self, sliced: bool = False): ...
 
     @abstractmethod
-    def to_rich_dict(self) -> dict: ...
+    def __str__(self) -> str: ...
 
-    def __str__(self) -> str:
-        return self.str_value
+    @abstractmethod
+    def __array__(self, dtype=None, copy=None): ...
 
-    def __array__(self, dtype=None):
-        arr = self.array_value
-        if dtype is not None:
-            arr = arr.astype(dtype)
-        return arr
+    @abstractmethod
+    def __bytes__(self): ...
 
-    def __bytes__(self):
-        return self.bytes_value
+    @abstractmethod
+    def __getitem__(self, segment: typing.Union[int, slice]) -> SeqViewABC: ...
 
+    def __len__(self):
+        return len(self.slice_record)
+
+    def with_offset(self, offset: int):
+        if self._slice_record.offset:
+            raise ValueError(
+                f"cannot set {offset=} on a SeqView with an offset {self._slice_record.offset=}"
+            )
 
-class SeqView(SeqViewABC, SliceRecordABC):
+        init_kwargs = self._get_init_kwargs()
+        init_kwargs["offset"] = offset
+        return self.__class__(**init_kwargs)
+
+
+class SeqView(SeqViewABC):
     """
     Provides a view of a sequence with support for slicing operations.
 
@@ -2484,126 +2638,130 @@ class SeqView(SeqViewABC, SliceRecordABC):
 
     Parameters
     ----------
-    seq
+    parent
         the original sequence data
     alphabet
         the alphabet object defining valid characters for the sequence
-    start
-        the starting index of the slice. Defaults to the start of the sequence
-    stop
-        the stopping index of the slice. Defaults to the end of the sequence
-    step
-        the step size for the slice. Defaults to 1
-    offset
-        an offset used for adjusting the view's starting position. Defaults to 0
     seqid
         the name or identifier of the sequence
-    seq_len
+    parent_len
         the length of the sequence. Defaults to the length of the input sequence
 
     """
 
+    # todo: kath,
+    # update the docstring to reflect the new design
+
     __slots__ = (
-        "seq",
+        "parent",
         "alphabet",
-        "start",
-        "stop",
-        "step",
-        "_offset",
         "_seqid",
-        "_seq_len",
+        "_parent_len",
+        "_slice_record",
     )
 
     def __init__(
         self,
         *,
-        seq: str,
+        parent: StrORBytesORArray,
         alphabet: new_alphabet.AlphabetABC,
-        start: OptInt = None,
-        stop: OptInt = None,
-        step: OptInt = None,
-        offset: int = 0,
+        parent_len: int,
         seqid: OptStr = None,
-        seq_len: OptInt = None,
+        slice_record: SliceRecordABC = None,
+        offset: int = 0,
     ):
-        if step == 0:
-            raise ValueError("step cannot be 0")
-        step = 1 if step is None else step
         self.alphabet = alphabet
-        func = _input_vals_pos_step if step > 0 else _input_vals_neg_step
-        start, stop, step = func(len(seq), start, stop, step)
-        self.seq = seq
-        self.start = start
-        self.stop = stop
-        self.step = step
-        self._offset = offset
+        self.parent = parent
         self._seqid = seqid
-
-        if seq_len is not None and seq_len != len(seq):
-            raise AssertionError(f"{seq_len} != {len(self.seq)})")
-        self._seq_len = seq_len or len(self.seq)
-
-    @property
-    def _zero_slice(self):
-        return self.__class__(seq="", alphabet=self.alphabet)
+        assert parent_len is not None
+        self._parent_len = parent_len
+        self._slice_record = (
+            slice_record
+            if slice_record is not None
+            else SliceRecord(parent_len=self._parent_len)
+        )
+        if offset and self._slice_record.offset:
+            raise ValueError(
+                f"cannot set {offset=} on a SeqView with an offset {self._slice_record.offset=}"
+            )
+        elif offset:
+            self._slice_record.offset = offset
 
     @property
     def seqid(self) -> str:
         return self._seqid
 
     @property
-    def seq_len(self) -> int:
-        return self._seq_len
+    def slice_record(self) -> SliceRecordABC:
+        return self._slice_record
 
-    def _get_init_kwargs(self):
-        return {"seq": self.seq, "seqid": self.seqid, "alphabet": self.alphabet}
+    @property
+    def parent_len(self) -> int:
+        return self._parent_len
 
+    def _get_init_kwargs(self):
+        return {
+            "parent": self.parent,
+            "parent_len": self._parent_len,
+            "seqid": self.seqid,
+            "alphabet": self.alphabet,
+            "slice_record": self.slice_record,
+        }
+
+    # DESIGN NOTE
+    # alphabet.to_indices() and alphabet.from_indices() cope with most primitive
+    # types that parent may be
     @property
     def str_value(self):
-        return self.seq[self.start : self.stop : self.step]
+        return self.alphabet.from_indices(
+            self.parent[
+                self.slice_record.start : self.slice_record.stop : self.slice_record.step
+            ]
+        )
 
     @property
     def array_value(self):
-        return self.alphabet.to_indices(self.str_value)
+        return self.alphabet.to_indices(
+            self.parent[
+                self.slice_record.start : self.slice_record.stop : self.slice_record.step
+            ]
+        )
 
     @property
     def bytes_value(self):
         return self.str_value.encode("utf-8")
 
-    def __repr__(self) -> str:
-        seq = f"{self.seq[:10]}...{self.seq[-5:]}" if self.seq_len > 15 else self.seq
-        return (
-            f"{self.__class__.__name__}(seq={seq!r}, start={self.start}, "
-            f"stop={self.stop}, step={self.step}, offset={self.offset}, "
-            f"seqid={self.seqid!r}, seq_len={self.seq_len})"
-        )
-
-    def to_rich_dict(self) -> dict[str, str | dict[str, str]]:
-        """returns a json serialisable dict
+    def __str__(self) -> str:
+        return self.str_value
 
-        Notes
-        -----
-        This method will slice the underlying sequence to the start and stop values
+    def __array__(self, dtype=None, copy=None) -> numpy.ndarray:
+        arr = self.array_value
+        if dtype is not None:
+            arr = arr.astype(dtype)
+        return arr
 
-        Warning
-        -------
-        This method is not intended to provide serialisation of this object,
-        instead, it is intended for usage by an enclosing class.
-        """
-        # get the current state
-        data = {"type": get_object_provenance(self), "version": __version__}
-        data["init_args"] = self._get_init_kwargs()
+    def __bytes__(self) -> bytes:
+        return self.bytes_value
 
-        data["init_args"]["step"] = self.step
-        if self.is_reversed:
-            adj = self.seq_len + 1
-            start, stop = self.stop + adj, self.start + adj
-        else:
-            start, stop = self.start, self.stop
+    def __getitem__(self, segment: typing.Union[int, slice]) -> SeqViewABC:
+        return self.__class__(
+            parent=self.parent,
+            seqid=self.seqid,
+            alphabet=self.alphabet,
+            parent_len=self.parent_len,
+            slice_record=self.slice_record[segment],
+        )
 
-        data["init_args"]["seq"] = self.seq[start:stop]
-        data["init_args"]["alphabet"] = self.alphabet.to_rich_dict()
-        return data
+    def __repr__(self) -> str:
+        seq_preview = (
+            f"{self.parent[:10]}...{self.parent[-5:]}"
+            if self.parent_len > 15
+            else self.parent
+        )
+        return (
+            f"{self.__class__.__name__}(seqid={self.seqid!r}, parent={seq_preview!r}, "
+            f"slice_record={self.slice_record.__repr__()})"
+        )
 
     def copy(self, sliced: bool = False):
         """returns copy
@@ -2616,27 +2774,29 @@ def copy(self, sliced: bool = False):
         """
         if not sliced:
             return self.__class__(
-                seq=self.seq,
+                parent=self.parent,
                 seqid=self.seqid,
                 alphabet=self.alphabet,
-                start=self.start,
-                stop=self.stop,
-                step=self.step,
-                offset=self.offset,
-                seq_len=self.seq_len,
+                slice_record=self.slice_record.copy(),
+                parent_len=self.parent_len,
             )
-        data = self.to_rich_dict()
+        # the plus start and stop are applied, but the step is not applied
+        sr = SliceRecord(parent_len=len(self), step=self.slice_record.step)
         return self.__class__(
-            seq=data["init_args"]["seq"],
+            parent=self.parent[
+                self.slice_record.plus_start : self.slice_record.plus_stop
+            ],
+            parent_len=len(self),
             seqid=self.seqid,
             alphabet=self.alphabet,
-            step=self.step,
-            offset=self.offset,
+            slice_record=sr,
         )
 
 
 @singledispatch
-def _coerce_to_seqview(data, seqid, alphabet, offset) -> SeqViewABC:
+def _coerce_to_seqview(
+    data, seqid: str, alphabet: new_alphabet.AlphabetABC, offset: int
+) -> SeqViewABC:
     from cogent3.core.alignment import Aligned
     from cogent3.core.sequence import Sequence as old_Sequence
     from cogent3.core.sequence import SeqView as old_SeqView
@@ -2647,7 +2807,9 @@ def _coerce_to_seqview(data, seqid, alphabet, offset) -> SeqViewABC:
 
 
 @_coerce_to_seqview.register
-def _(data: SeqViewABC, seqid, alphabet, offset) -> SeqViewABC:
+def _(
+    data: SeqViewABC, seqid: str, alphabet: new_alphabet.AlphabetABC, offset: int
+) -> SeqViewABC:
     # we require the indexes of shared states in alphabets to be the same
     # SeqView has an alphabet but SeqViewABC does NOT because that is
     # more general and covers the case where the SeqsData collection has the
@@ -2664,31 +2826,58 @@ def _(data: SeqViewABC, seqid, alphabet, offset) -> SeqViewABC:
             f"cannot set {offset=} on a SeqView with an offset {data.offset=}"
         )
     elif offset:
-        data.offset = offset
+        return data.with_offset(offset)
     return data
 
 
 @_coerce_to_seqview.register
-def _(data: Sequence, seqid, alphabet, offset) -> SeqViewABC:
+def _(
+    data: Sequence, seqid: str, alphabet: new_alphabet.AlphabetABC, offset: int
+) -> SeqViewABC:
     return _coerce_to_seqview(data._seq, seqid, alphabet, offset)
 
 
 @_coerce_to_seqview.register
-def _(data: str, seqid, alphabet, offset) -> SeqViewABC:
-    return SeqView(seq=data, seqid=seqid, alphabet=alphabet, offset=offset)
+def _(
+    data: str, seqid: str, alphabet: new_alphabet.AlphabetABC, offset: int
+) -> SeqViewABC:
+    return SeqView(
+        parent=data, parent_len=len(data), seqid=seqid, alphabet=alphabet, offset=offset
+    )
 
 
 @_coerce_to_seqview.register
-def _(data: bytes, seqid, alphabet, offset) -> SeqViewABC:
+def _(
+    data: bytes, seqid: str, alphabet: new_alphabet.AlphabetABC, offset: int
+) -> SeqViewABC:
     data = data.decode("utf8")
-    return SeqView(seq=data, seqid=seqid, alphabet=alphabet, offset=offset)
+    return SeqView(
+        parent=data, parent_len=len(data), seqid=seqid, alphabet=alphabet, offset=offset
+    )
+
+
+@_coerce_to_seqview.register
+def _(
+    data: numpy.ndarray, seqid: str, alphabet: new_alphabet.AlphabetABC, offset: int
+) -> SeqViewABC:
+    return SeqView(
+        parent=data.astype(alphabet.dtype),
+        parent_len=len(data),
+        seqid=seqid,
+        alphabet=alphabet,
+        offset=offset,
+    )
 
 
 @_coerce_to_seqview.register
-def _(data: tuple, seqid, alphabet, offset) -> SeqViewABC:
+def _(
+    data: tuple, seqid: str, alphabet: new_alphabet.AlphabetABC, offset: int
+) -> SeqViewABC:
     return _coerce_to_seqview("".join(data), seqid, alphabet, offset)
 
 
 @_coerce_to_seqview.register
-def _(data: list, seqid, alphabet, offset) -> SeqViewABC:
+def _(
+    data: list, seqid: str, alphabet: new_alphabet.AlphabetABC, offset: int
+) -> SeqViewABC:
     return _coerce_to_seqview("".join(data), seqid, alphabet, offset)
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index 058502587..dcb0026f0 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -31,6 +31,7 @@
     array,
     compress,
     floating,
+    int32,
     integer,
     issubdtype,
     logical_not,
diff --git a/src/cogent3/evolve/fast_distance.py b/src/cogent3/evolve/fast_distance.py
index bb58b0d22..b49bb1e9e 100644
--- a/src/cogent3/evolve/fast_distance.py
+++ b/src/cogent3/evolve/fast_distance.py
@@ -7,6 +7,7 @@
 from numpy.linalg import det, inv
 
 from cogent3._version import __version__
+from cogent3.core import new_moltype
 from cogent3.core.moltype import DNA, RNA, get_moltype
 from cogent3.util.dict_array import DictArray
 from cogent3.util.misc import get_object_provenance
@@ -341,7 +342,7 @@ def __init__(self, moltype, invalid=-9, alignment=None, invalid_raises=False):
 
     def _convert_seqs_to_indices(self, alignment):
         assert isinstance(
-            alignment.moltype, type(self.moltype)
+            alignment.moltype, (type(self.moltype), new_moltype.MolType)
         ), "Alignment does not have correct MolType"
 
         self._dists = {}
diff --git a/src/cogent3/format/fasta.py b/src/cogent3/format/fasta.py
index eba3ccd54..c5e143a26 100644
--- a/src/cogent3/format/fasta.py
+++ b/src/cogent3/format/fasta.py
@@ -12,9 +12,6 @@ def _iter_in_block_size(series: str, block_size: int) -> Iterable[str]:
         yield series[i : i + block_size]
 
 
-@c3warn.deprecated_args(
-    "2024.9", reason="better name", old_new=[("alignment_dict", "seqs")]
-)
 def seqs_to_fasta(
     seqs: dict[str, str],
     block_size: int = 60,
@@ -50,64 +47,3 @@ def seqs_to_fasta(
     if result:
         result.append("")
     return "\n".join(result)
-
-
-@c3warn.deprecated_callable("2024.9", reason="better name", new="seqs_to_fasta")
-def alignment_to_fasta(
-    alignment_dict: dict[str, str],
-    block_size: int = 60,
-    order: Optional[list[str]] = None,
-) -> str:  # pragma: no cover
-    """use seqs_to_fasta() instead"""
-    return seqs_to_fasta(seqs=alignment_dict, block_size=block_size, order=order)
-
-
-@c3warn.deprecated_callable(
-    "2024.9", reason="inefficient", is_discontinued=True, new="seqs_to_fasta"
-)
-class FastaFormatter(_AlignmentFormatter):  # pragma: no cover
-    def format(
-        self,
-        alignment_dict: dict[str, str],
-        block_size: int = 60,
-        order: Optional[list[str]] = None,
-    ) -> str:
-        """Format the alignment to Fasta.
-
-        Parameters
-        ----------
-        alignment_dict
-            dict of seq_name
-        block_size
-            the sequence length to write to each line,
-            by default 60
-        order
-            optional list of sequence names, which order to print in.
-            Assumes complete and correct list of names,
-            by default None
-
-        Returns
-        -------
-        The alignment in the Fasta format.
-        """
-        # setup
-        if not order:
-            order = list(alignment_dict.keys())
-        self.set_align_info(alignment_dict, order)
-        self.set_block_size(block_size)
-
-        if len(alignment_dict) == 0:
-            return ""
-
-        return "".join(
-            [
-                ">%s\n%s"
-                % (
-                    seq,
-                    self.wrap_string_to_block_size(
-                        alignment_dict[seq], alt_block_size=block_size
-                    ),
-                )
-                for seq in self.align_order
-            ]
-        )
diff --git a/src/cogent3/maths/stats/distribution.py b/src/cogent3/maths/stats/distribution.py
index 9613e5d4d..63b5731a2 100644
--- a/src/cogent3/maths/stats/distribution.py
+++ b/src/cogent3/maths/stats/distribution.py
@@ -43,41 +43,6 @@ def tprob(x, df):
     return 2 * t.sf(abs(x), df)
 
 
-@c3warn.deprecated_callable(
-    version="2024.9",
-    reason="use scipy.stats.poisson.sf() instead",
-    is_discontinued=True,
-)
-def poisson_high(successes, mean):  # pragma: no cover
-    """being removed"""
-    return pdtrc(successes, mean)
-
-
-@c3warn.deprecated_callable(
-    version="2024.9",
-    reason="use scipy.stats.poisson.cdf() instead",
-    is_discontinued=True,
-)
-def poisson_low(successes, mean):  # pragma: no cover
-    """being removed"""
-    return pdtr(successes, mean)
-
-
-@c3warn.deprecated_callable(
-    version="2024.9",
-    reason="use scipy.stats.poisson.pmf() instead",
-    is_discontinued=True,
-)
-def poisson_exact(successes, mean):  # pragma: no cover
-    """being removed"""
-    if successes == 0:
-        return pdtr(0, mean)
-    elif successes < mean:  # use left tail
-        return pdtr(successes, mean) - pdtr(successes - 1, mean)
-    else:  # successes > mean: use right tail
-        return pdtrc(successes - 1, mean) - pdtrc(successes, mean)
-
-
 def binomial_exact(successes, trials, prob):
     """Returns binomial probability of exactly X successes.
 
diff --git a/src/cogent3/maths/stats/special.py b/src/cogent3/maths/stats/special.py
index 460141d4c..11f08c98b 100644
--- a/src/cogent3/maths/stats/special.py
+++ b/src/cogent3/maths/stats/special.py
@@ -59,122 +59,6 @@ def one_minus_exp(x):
         return 1 - exp(x)
 
 
-@c3warn.deprecated_callable(
-    version="2024.9", reason="use scipy.special.perm() instead", is_discontinued=True
-)
-def permutations(n, k):  # pragma: no cover
-    """Returns the number of ways of choosing k items from n, in order.
-
-    Defined as n!/(n-k)!.
-    """
-    # Validation: k must be be between 0 and n (inclusive), and n must be >=0.
-    if k > n:
-        raise IndexError(f"Can't choose {k} items from {n}")
-    elif k < 0:
-        raise IndexError("Can't choose negative number of items")
-    elif n < 0:
-        raise IndexError("Can't choose from negative number of items")
-    if min(n, k) < 20 and isinstance(n, int) and isinstance(k, int):
-        return permutations_exact(n, k)
-    else:
-        return exp(ln_permutations(n, k))
-
-
-@c3warn.deprecated_callable(
-    version="2024.9", reason="use scipy.special.perm() instead", is_discontinued=True
-)
-def permutations_exact(n, k):  # pragma: no cover
-    """Calculates permutations by integer division.
-
-    Preferred method for small permutations, but slow on larger ones.
-
-    Note: no error checking (expects to be called through permutations())
-    """
-    product = 1
-    for i in range(n - k + 1, n + 1):
-        product *= i
-    return product
-
-
-@c3warn.deprecated_callable(
-    version="2024.9", reason="use scipy.special.perm() instead", is_discontinued=True
-)
-def ln_permutations(n, k):  # pragma: no cover
-    """Calculates permutations by difference in log of gamma function.
-
-    Preferred method for large permutations, but slow on smaller ones.
-
-    Note: no error checking (expects to be called through permutations())
-    """
-    return lgam(n + 1) - lgam(n - k + 1)
-
-
-@c3warn.deprecated_callable(
-    version="2024.9", reason="Use scipy.special.comb() instead", is_discontinued=True
-)
-def combinations(n, k):  # pragma: no cover
-    """Returns the number of ways of choosing k items from n, in order.
-
-    Defined as n!/(k!(n-k)!).
-    """
-    # Validation: k must be be between 0 and n (inclusive), and n must be >=0.
-    if k > n:
-        raise IndexError(f"Can't choose {k} items from {n}")
-    elif k < 0:
-        raise IndexError("Can't choose negative number of items")
-    elif n < 0:
-        raise IndexError("Can't choose from negative number of items")
-    # if min(n, k) < 20:
-    if min(n, k) < 20 and isinstance(n, int) and isinstance(k, int):
-        return combinations_exact(n, k)
-    else:
-        return exp(ln_combinations(n, k))
-
-
-@c3warn.deprecated_callable(
-    version="2024.9", reason="Use scipy.special.comb() instead", is_discontinued=True
-)
-def combinations_exact(n, k):  # pragma: no cover
-    """Calculates combinations by integer division.
-
-    Preferred method for small combinations, but slow on larger ones.
-
-    Note: no error checking (expects to be called through combinations())
-    """
-    # permutations(n, k) = permutations(n, n-k), so reduce computation by
-    # figuring out which requires calculation of fewer terms.
-    if k > (n - k):
-        larger = k
-        smaller = n - k
-    else:
-        larger = n - k
-        smaller = k
-
-    product = 1
-    # compute n!/(n-larger)! by multiplying terms from n to (n-larger+1)
-    for i in range(larger + 1, n + 1):
-        product *= i
-
-    # divide by (smaller)! by multiplying terms from 2 to smaller
-    for i in range(2, smaller + 1):  # no need to divide by 1...
-        product /= i  # ok to use integer division: should always be factor
-
-    return product
-
-
-@c3warn.deprecated_callable(
-    version="2024.9", reason="Use scipy.special.comb() instead", is_discontinued=True
-)
-def ln_combinations(n, k):  # pragma: no cover
-    """Calculates combinations by difference in log of gamma function.
-
-    Preferred method for large combinations, but slow on smaller ones.
-
-    Note: no error checking (expects to be called through combinations())
-    """
-    return lgam(n + 1) - lgam(k + 1) - lgam(n - k + 1)
-
-
 def ln_binomial(successes, trials, prob):
     """Returns the natural log of the binomial distribution.
 
@@ -270,63 +154,6 @@ def polevl(x, coef):
 ]
 
 
-@c3warn.deprecated_callable(
-    version="2024.9",
-    reason="use scipy.special.erf(x, out=None) instead",
-    is_discontinued=True,
-)
-def erf(a):  # pragma: no cover
-    """being removed"""
-    if abs(a) > 1:
-        return 1 - erfc(a)
-    z = a * a
-    return a * polevl(z, ZT) / polevl(z, ZU)
-
-
-@c3warn.deprecated_callable(
-    version="2024.9",
-    reason="use scipy.special.erfc(z, out=None) instead",
-    is_discontinued=True,
-)
-def erfc(a):  # pragma: no cover
-    """being removed"""
-    if a < 0:
-        x = -a
-    else:
-        x = a
-
-    if x < 1:
-        return 1 - erf(a)
-
-    z = -a * a
-    if z < -MAXLOG:  # underflow
-        if a < 0:
-            return 2
-        else:
-            return 0
-    z = exp(z)
-
-    if x < 8:
-        p = polevl(x, ZP)
-        q = polevl(x, ZQ)
-    else:
-        p = polevl(x, ZR)
-        q = polevl(x, ZS)
-
-    y = z * p / q
-
-    if a < 0:
-        y = 2 - y
-
-    if y == 0:  # underflow
-        if a < 0:
-            return 2
-        else:
-            return 0
-    else:
-        return y
-
-
 # Coefficients for Gamma follow:
 GA = [
     8.11614167470508450300e-4,
diff --git a/src/cogent3/parse/gff.py b/src/cogent3/parse/gff.py
index 10deca524..5cf619998 100644
--- a/src/cogent3/parse/gff.py
+++ b/src/cogent3/parse/gff.py
@@ -327,7 +327,6 @@ def _gff_parser(
         )
 
 
-@c3warn.deprecated_args("2024.9", reason="not being used", discontinued="span")
 def parse_attributes_gff2(attributes: str) -> dict:
     """Returns a dict with name and info keys"""
     name = attributes[attributes.find('"') + 1 :]
@@ -336,7 +335,6 @@ def parse_attributes_gff2(attributes: str) -> dict:
     return {"ID": name, "Info": attributes}
 
 
-@c3warn.deprecated_args("2024.9", reason="not being used", discontinued="span")
 def parse_attributes_gff3(attributes: str) -> dict:
     """Returns a dictionary containing all the attributes"""
     attributes = attributes.strip(";")
diff --git a/src/cogent3/parse/sequence.py b/src/cogent3/parse/sequence.py
index b77541a0e..43a1ec1df 100644
--- a/src/cogent3/parse/sequence.py
+++ b/src/cogent3/parse/sequence.py
@@ -23,58 +23,6 @@
 _lc_to_wc = "".join([[chr(x), "?"]["A" <= chr(x) <= "Z"] for x in range(256)])
 
 
-@c3warn.deprecated_callable(
-    "2024.9",
-    reason="allow more customised parser implementations",
-    is_discontinued=True,
-)
-def FromFilenameParser(filename, format=None, **kw):  # pragma: no cover
-    """Arguments:
-    - filename: name of the sequence alignment file
-    - format: the multiple sequence file format
-    """
-    if format is None:
-        format, _ = get_format_suffixes(filename)
-
-    with open_(filename, newline=None, mode="rt") as f:
-        data = f.read()
-
-    return FromFileParser(data.splitlines(), format, **kw)
-
-
-@c3warn.deprecated_callable(
-    "2024.9",
-    reason="allow more customised parser implementations",
-    is_discontinued=True,
-)
-def FromFileParser(f, format, dialign_recode=False, **kw):  # pragma: no cover
-    format = format.lower()
-    if format in XML_PARSERS:
-        doctype = format
-        format = "xml"
-    else:
-        doctype = None
-    if format == "xml":
-        source = dom = xml.dom.minidom.parse(f)
-        if doctype is None:
-            doctype = str(dom.doctype.name).lower()
-        if doctype not in XML_PARSERS:
-            raise FileFormatError(f"Unsupported XML doctype {doctype}")
-        parser = XML_PARSERS[doctype]
-    else:
-        if format not in PARSERS:
-            raise FileFormatError(f"Unsupported file format {format}")
-        parser = PARSERS[format]
-        source = f
-    for name, seq in parser(source, **kw):
-        if isinstance(seq, str):
-            if dialign_recode:
-                seq = seq.translate(_lc_to_wc)
-            if not seq.isupper():
-                seq = seq.upper()
-        yield name, seq
-
-
 ParserOutputType = typing.Iterable[typing.Tuple[str, str]]
 
 
diff --git a/src/cogent3/util/deserialise.py b/src/cogent3/util/deserialise.py
index 98b9d9fa1..dc63d7a59 100644
--- a/src/cogent3/util/deserialise.py
+++ b/src/cogent3/util/deserialise.py
@@ -111,7 +111,14 @@ def deserialise_not_completed(data):
 
 def deserialise_map_spans(map_element):
     map_element.pop("version", None)
-    map_klass = _get_class(map_element.pop("type"))
+    type_ = map_element.pop("type")
+    if type_.endswith(".Map"):
+        # old style Map
+        type_ = type_.replace(".Map", ".FeatureMap")
+        for k in ("tidy", "termini_unknown"):
+            map_element.pop(k, None)
+
+    map_klass = _get_class(type_)
     spans = []
     for element in map_element["spans"]:
         element.pop("version", None)


Test patch:
diff --git a/.github/workflows/testing_develop.yml b/.github/workflows/testing_develop.yml
index 4445bfdeb..58f2a89f0 100644
--- a/.github/workflows/testing_develop.yml
+++ b/.github/workflows/testing_develop.yml
@@ -16,7 +16,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ["3.9", "3.10", "3.11", "3.12"]
+        python-version: ["3.10", "3.11", "3.12"]
 
     steps:
       - uses: "actions/checkout@v4"
diff --git a/tests/test_core/test_alignment.py b/tests/test_core/test_alignment.py
index d19e36119..57700cd7b 100644
--- a/tests/test_core/test_alignment.py
+++ b/tests/test_core/test_alignment.py
@@ -539,7 +539,7 @@ def test_with_modified_termini(self):  # will not port
             aln.with_modified_termini(), {"s1": "AATGR??", "s2": "?T-AG??"}
         )
 
-    def test_make_gap_filter(self):
+    def test_make_gap_filter(self):  # ported
         """make_gap_filter returns f(seq) -> True if aligned ok w/ query"""
         s1 = RNA.make_seq(seq="UC-----CU---C")
         s3 = RNA.make_seq(seq="UUCCUUCUU-UUC")
@@ -944,7 +944,7 @@ class AlignmentBaseTests(SequenceCollectionBaseTests):
     as a constructor.
     """
 
-    def test_alignment_quality(self):
+    def test_alignment_quality(self):  # ported
         """check alignment method correctly invokes underlying app"""
         aln = self.Class(["AAAC", "ACGC", "AGCC", "A-TC"], moltype="dna")
         got = aln.alignment_quality(equifreq_mprobs=False)
@@ -956,7 +956,7 @@ def test_alignment_quality(self):
         )
         assert_allclose(got, expect)
 
-    def test_filter_drop_remainder(self):
+    def test_filter_drop_remainder(self):  # ported
         """filter allows dropping"""
         raw = {"a": "ACGACGACG", "b": "CCC---CCC", "c": "AAAA--AAA"}
         aln = self.Class(raw)
@@ -970,14 +970,14 @@ def test_filter_drop_remainder(self):
         got = aln.filtered(func, motif_length=2, drop_remainder=True, warn=False)
         self.assertEqual(len(got), 4)
 
-    def test_take_positions_info(self):
+    def test_take_positions_info(self):  # ported
         aln = self.Class(
             {"a": "AAAAAAA", "b": "A--A-AA", "c": "AA-----"}, info={"key": "value"}
         )
         tps = aln.take_positions([5, 4, 0])
         self.assertEqual(tps.info["key"], "value")
 
-    def test_take_positions_if(self):
+    def test_take_positions_if(self):  # ported
         """SequenceCollection take_positions_if should return cols where f(col) is True"""
 
         def gap_1st(x):
@@ -1021,7 +1021,7 @@ def is_list(x):
             {"a": "AA", "b": "A-", "c": "AA"},
         )
 
-    def test_omit_gap_pos2(self):
+    def test_omit_gap_pos2(self):  # ported
         """consistency with different motif_length values"""
         data = {
             "seq1": "CAGGTCGACCTCGGC---------CACGAC",
@@ -1037,7 +1037,7 @@ def test_omit_gap_pos2(self):
         self.assertEqual(len(got3), len(got1))
         self.assertEqual(got3.to_dict(), got1.to_dict())
 
-    def test_omit_bad_seqs(self):
+    def test_omit_bad_seqs(self):  # ported
         """omit_bad_seqs should return alignment w/o seqs causing most gaps"""
         data = {
             "s1": "---ACC---TT-",
@@ -1061,7 +1061,7 @@ def test_omit_bad_seqs(self):
         result = aln.omit_bad_seqs(0.5)
         self.assertEqual(result.to_dict(), expect)
 
-    def test_matching_ref(self):
+    def test_matching_ref(self):  # ported
         """Alignment.matching_ref returns new aln with well-aln to temp"""
         aln = self.omitSeqsTemplate_aln
         result = aln.matching_ref("s3", 0.9, 5)
@@ -1076,7 +1076,7 @@ def test_matching_ref(self):
         result4 = aln.matching_ref("s3", 0.5, 13)
         self.assertEqual(result4, {"s3": "UUCCUUCUU-UUC", "s4": "UU-UUUU-UUUUC"})
 
-    def test_iupac_consensus_RNA(self):
+    def test_iupac_consensus_RNA(self):  # ported
         """SequenceCollection iupac_consensus should use RNA IUPAC symbols correctly"""
         alignmentUpper = self.Class(
             [
@@ -1095,7 +1095,7 @@ def test_iupac_consensus_RNA(self):
             alignmentUpper.iupac_consensus(), "UYHBN?BSNN??KBVSN?NN??AGCWD?-"
         )
 
-    def test_iupac_consensus_DNA(self):
+    def test_iupac_consensus_DNA(self):  # ported
         """SequenceCollection iupac_consensus should use DNA IUPAC symbols correctly"""
         alignmentUpper = self.Class(
             [
@@ -1112,7 +1112,7 @@ def test_iupac_consensus_DNA(self):
             alignmentUpper.iupac_consensus(DNA), "TYHBN?BSNN??KBVSN?NN??AGCWD?-"
         )
 
-    def test_iupac_consensus_Protein(self):
+    def test_iupac_consensus_Protein(self):  # ported
         """SequenceCollection iupac_consensus should use protein IUPAC symbols correctly"""
         alignmentUpper = self.Class(
             [
@@ -1128,12 +1128,12 @@ def test_iupac_consensus_Protein(self):
             alignmentUpper.iupac_consensus(PROTEIN), "ACBEFGHIKLMNPZRS?UVWX-"
         )
 
-    def test_is_ragged(self):
+    def test_is_ragged(self):  # ported
         """SequenceCollection is_ragged should return true if ragged alignment"""
         assert not self.identical.is_ragged()
         assert not self.gaps.is_ragged()
 
-    def test_probs_per_pos(self):
+    def test_probs_per_pos(self):  # ported
         """SequenceCollection.probs_per_pos should find Pr(symbol) in each
         column"""
         # make an alignment with 4 seqs (easy to calculate probabilities)
@@ -1149,12 +1149,12 @@ def test_probs_per_pos(self):
             for char, prob in probs.items():
                 assert_allclose(got[pos, char], prob)
 
-    def test_majority_consensus(self):
+    def test_majority_consensus(self):  # ported
         """SequenceCollection.majority_consensus should return commonest symbol per column"""
         # Check the exact strings expected from string transform
         self.assertEqual(self.sequences.majority_consensus(), "UCAG")
 
-    def test_uncertainties(self):
+    def test_uncertainties(self):  # ported
         """SequenceCollection.uncertainties should match hand-calculated values"""
         aln = self.Class(["ABC", "AXC"])
         obs = aln.entropy_per_pos()
@@ -1164,7 +1164,7 @@ def test_uncertainties(self):
         obs = aln.entropy_per_pos()
         assert_allclose(obs, [0, 0, 0])
 
-    def test_sample_with_replacement(self):
+    def test_sample_with_replacement(self):  # ported
         # test with replacement -- just verify that it rnus
         alignment = self.Class({"seq1": "gatc", "seq2": "gatc"})
         sample = alignment.sample(1000, with_replacement=True)
@@ -1174,7 +1174,7 @@ def test_sample_with_replacement(self):
         result = alignment1.sample(with_replacement=True)
         self.assertEqual(len(result), 1)
 
-    def test_sample_tuples(self):
+    def test_sample_tuples(self):  # ported
         ##### test with motif size != 1 #####
         alignment = self.Class(
             {
@@ -1204,7 +1204,7 @@ def test_copy(self):
         copied = aln.copy()
         self.assertEqual(aln.info, copied.info)
 
-    def test_to_pretty(self):
+    def test_to_pretty(self):  # ported
         """produce correct pretty print formatted text"""
         seqs = {"seq1": "ACGAANGA", "seq2": "-CGAACGA", "seq3": "ATGAACGA"}
         expect = ["seq1    ACGAANGA", "seq2    -....C..", "seq3    .T...C.."]
@@ -1225,7 +1225,7 @@ def test_to_pretty(self):
         ]
         self.assertEqual(got, "\n".join(expect))
 
-    def test_to_html(self):
+    def test_to_html(self):  # ported
         """produce correct html formatted text"""
         seqs = {"seq1": "ACG", "seq2": "-CT"}
 
@@ -1269,7 +1269,7 @@ def test_to_html(self):
         # order now changes
         self.assertTrue(got.find(ref_row) < got.find(other_row))
 
-    def test_variable_positions(self):
+    def test_variable_positions(self):  # ported
         """correctly identify variable positions"""
         new_seqs = {"A": "-CG-C", "B": "ACAA?", "C": "GCGAC"}
         aln = self.Class(data=new_seqs, moltype=DNA)
@@ -1280,7 +1280,7 @@ def test_variable_positions(self):
         self.assertEqual(aln.variable_positions(include_gap_motif=True), [])
         self.assertEqual(aln.variable_positions(include_gap_motif=False), [])
 
-    def test_to_type_info(self):
+    def test_to_type_info(self):  # will not port
         """interconverting between alignment types preserves info attribute"""
         new_seqs = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
         array_align = self.Class == ArrayAlignment
@@ -1291,7 +1291,7 @@ def test_to_type_info(self):
         self.assertEqual(id(aln), id(new))
         self.assertEqual(new.info["key"], "value")
 
-    def test_to_dna(self):
+    def test_to_dna(self):  # ported
         """alignment cast to DNA works"""
         data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
         aln = self.Class(data=data)
@@ -1302,14 +1302,14 @@ def test_to_dna(self):
         paln = dna.get_translation()
         self.assertRaises(AlphabetError, paln.to_dna)
 
-    def test_to_dna_info(self):
+    def test_to_dna_info(self):  # ported
         """alignment cast to DNA preserves info attribute"""
         data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
         aln = self.Class(data=data, info={"key": "value"})
         dna = aln.to_dna()
         self.assertEqual(dna.info["key"], "value")
 
-    def test_to_rna(self):
+    def test_to_rna(self):  # ported
         """alignment cast to RNA works"""
         data = {"seq1": "ACGUACGUA", "seq2": "ACCGAA---", "seq3": "ACGUACGUU"}
         aln = self.Class(data=data)
@@ -1317,14 +1317,14 @@ def test_to_rna(self):
         self.assertEqual(set(rna.names), set(aln.names))
         self.assertTrue(rna.moltype == RNA)
 
-    def test_to_rna_info(self):
+    def test_to_rna_info(self):  # ported
         """alignment cast to RNA preserves info attribute"""
         data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
         aln = self.Class(data=data, info={"key": "value"})
         rna = aln.to_rna()
         self.assertEqual(rna.info["key"], "value")
 
-    def test_to_protein(self):
+    def test_to_protein(self):  # will not port (use get_translation)
         """alignment cast to protein works"""
         data = {"seq1": "TYV", "seq3": "TYV", "seq2": "TE-"}
         aln = self.Class(data=data)
@@ -1334,7 +1334,7 @@ def test_to_protein(self):
         # should fail if invalid character set
         self.assertRaises(AlphabetError, paln.to_dna)
 
-    def test_to_protein_info(self):
+    def test_to_protein_info(self):  # will not port (use get_translation)
         """alignment cast to protein preserves info attribute"""
         data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
         aln = self.Class(data=data, info={"key": "value"})
@@ -1375,7 +1375,7 @@ def test_counts(self):  # ported
         for k, v in expect.items():
             self.assertEqual(got[k], v)
 
-    def test_counts_per_pos(self):
+    def test_counts_per_pos(self):  # ported
         """correctly count motifs"""
         exp = array(
             [
@@ -1418,7 +1418,7 @@ def test_counts_per_pos(self):
         c = aln.counts_per_pos(include_ambiguity=False, allow_gap=True)
         assert_equal(set(c.motifs), set("ACGT-"))
 
-    def test_counts_per_seq_default_moltype(self):
+    def test_counts_per_seq_default_moltype(self):  # ported
         """produce correct counts per seq with default moltypes"""
         data = {"a": "AAAA??????", "b": "CCCGGG--NN", "c": "CCGGTTCCAA"}
         coll = self.Class(data=data)
@@ -1430,7 +1430,7 @@ def test_counts_per_seq_default_moltype(self):
         got = coll.counts_per_seq(include_ambiguity=True, allow_gap=True)
         self.assertEqual(got.col_sum()["-"], 2)
 
-    def test_counts_per_pos_default_moltype(self):  # will not port
+    def test_counts_per_pos_default_moltype(self):  # ported
         """produce correct counts per pos with default moltypes"""
         data = {"a": "AAAA??????", "b": "CCCGGG--NN", "c": "CCGGTTCCAA"}
         coll = self.Class(data=data)
@@ -1453,7 +1453,7 @@ def test_counts_per_pos_default_moltype(self):  # will not port
         self.assertTrue("-" not in found_motifs)
         self.assertEqual(lengths, {2})
 
-    def test_entropy_per_pos_just_gaps(self):
+    def test_entropy_per_pos_just_gaps(self):  # ported
         """pos with just gaps have nan"""
         a = self.Class(dict(a="A---", b="C---", c="C---"), moltype=DNA)
         entropy = a.entropy_per_pos()
@@ -1462,14 +1462,14 @@ def test_entropy_per_pos_just_gaps(self):
         entropy = a.entropy_per_pos()
         assert_allclose(entropy, [numpy.nan, numpy.nan, numpy.nan])
 
-    def test_entropy_excluding_unobserved(self):
+    def test_entropy_excluding_unobserved(self):  # ported
         """omitting unobserved motifs should not affect entropy calculation"""
         a = self.Class(dict(a="ACAGGG", b="AGACCC", c="GGCCTA"), moltype=DNA)
         entropy_excluded = a.entropy_per_seq(exclude_unobserved=True)
         entropy_unexcluded = a.entropy_per_seq(exclude_unobserved=False)
         assert_allclose(entropy_excluded, entropy_unexcluded)
 
-    def test_distance_matrix(self):
+    def test_distance_matrix(self):  # ported
         """Alignment distance_matrix should produce correct scores"""
         data = dict([("s1", "ACGTACGTA"), ("s2", "GTGTACGTA")])
         aln = self.Class(data=data, moltype="dna")
@@ -1494,12 +1494,12 @@ def test_distance_matrix(self):
         # but setting drop_invalid=False allows calc
         dists = aln.distance_matrix(calc="paralinear", drop_invalid=True)
 
-    def test_get_gapped_seq(self):
+    def test_get_gapped_seq(self):  # ported
         """Alignment.get_gapped_seq should return seq, with gaps"""
         aln = self.Class({"seq1": "--TTT?", "seq2": "GATC??"})
         self.assertEqual(str(aln.get_gapped_seq("seq1")), "--TTT?")
 
-    def test_count_gaps_per_seq(self):
+    def test_count_gaps_per_seq(self):  # ported
         """correctly compute the number of gaps"""
         data = {"a": "AAAA---GGT", "b": "CCC--GG?GT"}
         aln = self.Class(data=data, moltype=DNA)
@@ -1529,13 +1529,13 @@ def test_count_gaps_per_seq(self):
         assert_equal(got.array, [2, 1, 2])
         assert_equal(got["b"], 1)
 
-    def test_info_source(self):
+    def test_info_source(self):  # ported
         """info.source exists if load_aligned_seqs given a filename"""
         array_align = self.Class == ArrayAlignment
         seqs = load_aligned_seqs("data/brca1.fasta", array_align=array_align)
         self.assertEqual(seqs.info.source, "data/brca1.fasta")
 
-    def test_seq_entropy_just_gaps(self):
+    def test_seq_entropy_just_gaps(self):  # ported
         """get_seq_entropy should get entropy of each seq"""
         a = self.Class(dict(a="A---", b="----"), moltype=DNA)
         entropy = a.entropy_per_seq()
@@ -1574,7 +1574,7 @@ def test_repr_html(self):  # ported
         got = aln._repr_html_()
         self.assertIn("%d x %d" % (aln.num_seqs, aln.seq_len), got.splitlines()[-2])
 
-    def test_seqlogo(self):
+    def test_seqlogo(self):  # ported
         """exercise producing a seq logo"""
         data = {
             "seq1": "CAGGTCGACCTCGGC---------CACGAC",
@@ -1601,7 +1601,7 @@ def test_seqlogo(self):
 class ArrayAlignmentTests(AlignmentBaseTests, TestCase):
     Class = ArrayAlignment
 
-    def test_slice_align(self):
+    def test_slice_align(self):  # ported
         """slicing alignment should work correctly"""
         data = {"seq1": "ACGACGACG", "seq2": "ACGACGACG", "seq3": "ACGACGACG"}
         alignment = self.Class(data=data)
@@ -1614,7 +1614,7 @@ def test_slice_align(self):
         expect = {"seq1": "GGG", "seq2": "GGG", "seq3": "GGG"}
         self.assertEqual(sub_align.to_dict(), expect)
 
-    def test_slice_align_info(self):
+    def test_slice_align_info(self):  # ported
         """slicing alignment preserves info attribute"""
         data = {"seq1": "ACGACGACG", "seq2": "ACGACGACG", "seq3": "ACGACGACG"}
         alignment = self.Class(data=data, info={"key": "value"})
@@ -1626,7 +1626,7 @@ def test_slice_align_info(self):
 class AlignmentTests(AlignmentBaseTests, TestCase):
     Class = Alignment
 
-    def test_sliding_windows(self):
+    def test_sliding_windows(self):  # ported
         """sliding_windows should return slices of alignments."""
         alignment = self.Class(
             {"seq1": "ACGTACGT", "seq2": "ACGTACGT", "seq3": "ACGTACGT"}
@@ -1667,7 +1667,7 @@ def test_with_gaps_from(self):
         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(
@@ -1983,7 +1983,7 @@ def test_model_aln_to_model_aln(self):
     "raw_seq,coords",
     (("ACGGTAAAG", ((2, 4), (5, 8))), ("CCC---CCC", ((0, 3), (6, 9)))),
 )
-def test_featuremap_slice_aligned(raw_seq, coords):
+def test_featuremap_slice_aligned(raw_seq, coords):  # ported
     from cogent3.core.location import FeatureMap, Span
 
     im, seq = DNA.make_seq(seq=raw_seq).parse_out_gaps()
@@ -2119,7 +2119,7 @@ def seqcoll_db():
     return seq_coll
 
 
-def test_seqcoll_query(seqcoll_db):
+def test_seqcoll_query(seqcoll_db):  # ported
     """querying from a SequenceCollection produces features bound to their seqs"""
     matches = list(seqcoll_db.get_features(seqid="I"))
     # 11 features
@@ -2132,7 +2132,7 @@ def test_seqcoll_query(seqcoll_db):
     assert len(matches) == 3
 
 
-def test_align_get_features():
+def test_align_get_features():  # ported
     #                    0123456789   the positions
     seq1 = make_seq(seq="ACG--ACCGT", moltype="dna", name="seq1")
     seq2 = make_seq(seq="ACGGGCCCGT", moltype="dna", name="seq2")
@@ -2148,7 +2148,7 @@ def test_align_get_features():
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
-def test_init_annotated_seqs(cls):  # ported for SequenceCollection
+def test_init_annotated_seqs(cls):  # ported
     """correctly construct from list with annotated seq"""
     seq = make_seq(seq="GCCAGGGGGGAAAG-GGAGAA", name="seq1")
     _ = seq.add_feature(biotype="exon", name="name", spans=[(4, 10)])
@@ -2158,7 +2158,7 @@ def test_init_annotated_seqs(cls):  # ported for SequenceCollection
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
-def test_get_annotations_from_any_seq(cls):  # ported for SequenceCollection
+def test_get_annotations_from_any_seq(cls):  # ported
     """get_annotations_from_any_seq returns correct annotations"""
     data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
     seqs = cls(data, moltype=DNA)
@@ -2186,7 +2186,7 @@ def test_get_annotations_from_any_seq(cls):  # ported for SequenceCollection
     assert "biotype='exon', name='annotation3', map=[3:6]/9" in str(got[0])
 
 
-def test_annotate_matches_to():
+def test_annotate_matches_to():  # ported
     """Aligned.annotate_matches_to correctly delegates to sequence"""
 
     aln = Alignment(dict(x="TTCCACTTCCGCTT"), moltype="dna")
@@ -2232,7 +2232,7 @@ def gff_db(DATA_DIR):
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
-def test_copy_annotations(cls, gff_db):  # ported for SequenceCollection
+def test_copy_annotations(cls, gff_db):  # ported
     """copy_annotations copies records from annotation db"""
 
     seq_coll = cls({"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"})
@@ -2255,7 +2255,7 @@ def _make_seq(name):
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
-def test_init_seqs_have_annotations(cls, gff_db):  # ported for SequenceCollection
+def test_init_seqs_have_annotations(cls, gff_db):  # ported
     """annotations on input seqs correctly merged and propagated"""
 
     seq_coll = cls({"seq1": _make_seq("seq1"), "seq2": _make_seq("seq2")})
@@ -2270,7 +2270,7 @@ def test_init_seqs_have_annotations(cls, gff_db):  # ported for SequenceCollecti
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
-def test_add_to_seq_updates_coll(cls, gff_db):  # ported for SequenceCollection
+def test_add_to_seq_updates_coll(cls, gff_db):  # ported
     """annotating a seq updates the db of the propagated"""
     seq_coll = cls(
         {"x": "AACCCAAAATTTTTTGGGGGGGGGGCCCC", "y": "AACCCAAAATTTTTTGGGGGGGGGGCCCC"}
@@ -2282,7 +2282,7 @@ def test_add_to_seq_updates_coll(cls, gff_db):  # ported for SequenceCollection
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
-def test_assign_none(cls, gff_db):  # ported for SequenceCollection
+def test_assign_none(cls, gff_db):  # ported
     """assigning None to annotation_db breaks conection"""
 
     seq_coll = cls({"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"})
@@ -2364,7 +2364,7 @@ def test_deepcopy_aligned(cls):
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
-def test_seq_rename_preserves_annotations(cls):
+def test_seq_rename_preserves_annotations(cls):  # ported
     """rename seqs discards all annotations"""
     data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
     seqs = cls(data, moltype=DNA)
@@ -2506,7 +2506,7 @@ def test_to_rich_dict_alignment():
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
-def test_dotplot_annotated(cls):  # ported for SequenceCollection
+def test_dotplot_annotated(cls):  # ported
     """exercising dotplot method with annotated sequences"""
     db = GffAnnotationDb()
     db.add_feature(seqid="Human", biotype="exon", name="fred", spans=[(10, 15)])
@@ -2581,7 +2581,7 @@ def test_distance_matrix_passes_correct_moltype(moltype):  # ported
 @pytest.mark.parametrize(
     "seqs", ({"seq1": "GATTTT", "seq2": "GATC??"}, {"seq1": "GAT---", "seq2": "?GATCT"})
 )
-def test_get_translation2(cls, seqs):  # ported for SequenceCollection
+def test_get_translation2(cls, seqs):  # ported
     """SequenceCollection.get_translation translates each seq"""
     alignment = cls(data=seqs, moltype=DNA)
     got = alignment.get_translation()
@@ -2590,7 +2590,7 @@ def test_get_translation2(cls, seqs):  # ported for SequenceCollection
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
-def test_get_translation_with_stop(cls):  # ported for SequenceCollection
+def test_get_translation_with_stop(cls):  # ported
     seqs = {"seq1": "GATTAG", "seq2": "?GATCT"}
     alignment = cls(data=seqs, moltype=DNA)
     got = alignment.get_translation(include_stop=True)
@@ -2598,7 +2598,7 @@ def test_get_translation_with_stop(cls):  # ported for SequenceCollection
 
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment, SequenceCollection))
-def test_get_translation_trim_stop(cls):  # ported for SequenceCollection
+def test_get_translation_trim_stop(cls):  # ported
     seqs = {"seq1": "GATTCCTAG", "seq2": "GATTCCTCC"}
     alignment = cls(data=seqs, moltype=DNA)
     expect = {"seq1": "DS", "seq2": "DSS"}
@@ -2613,7 +2613,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):  # ported for SequenceCollection
+def test_get_translation_error(cls, seqs):  # ported
     """SequenceCollection.get_translation translates each seq"""
     # check for a failure when no moltype specified
     alignment = cls(data=seqs)
@@ -2623,7 +2623,7 @@ def test_get_translation_error(cls, seqs):  # ported for SequenceCollection
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
 @pytest.mark.parametrize("method", ("ic_score", "cogent3_score", "sp_score"))
-def test_alignment_quality_methods(cls, method):
+def test_alignment_quality_methods(cls, method):  # ported
     data = {
         "DogFaced": "TG----AATATGT------GAAAGAG",
         "FreeTaile": "TTGAAGAATATGT------GAAAGAG",
@@ -2674,7 +2674,7 @@ def test_alignment_quality_methods_zero_length(method):
     assert_allclose(score, 0.0)
 
 
-def test_get_gap_array_equivalence():
+def test_get_gap_array_equivalence():  # will not port
     # make sure produced gap arrays are identical between the
     # two Alignment classes
     data = {
@@ -2709,7 +2709,7 @@ def test_aligned_rich_dict(reverse):
         {"seq1": "GAT---", "seq2": "?GATCT"},
     ),
 )
-def test_get_translation_info(cls, seqs):  # ported for SequenceCollection
+def test_get_translation_info(cls, seqs):  # ported
     """SequenceCollection.get_translation preserves info attribute"""
     alignment = cls(data=seqs, moltype=DNA, info={"key": "value"})
     got = alignment.get_translation()
@@ -2725,7 +2725,7 @@ def test_get_translation_info(cls, seqs):  # ported for SequenceCollection
         (2, ("GATTTT", "TCCAGG")),
     ),
 )
-def test_has_terminal_stop_true(cls, gc, seqs):  # ported for SequenceCollection
+def test_has_terminal_stop_true(cls, gc, seqs):  # ported
     gc = get_code(gc)
     data = {f"s{i}": s for i, s in enumerate(seqs)}
     seqs = cls(data=data, moltype="dna")
@@ -2737,7 +2737,7 @@ def test_has_terminal_stop_true(cls, gc, seqs):  # ported for SequenceCollection
     "gc,seqs",
     ((1, ("TCCTCA", "GATTTT")), (2, ("GATTTT", "TCCCGG")), (1, ("CCTCA", "ATTTT"))),
 )
-def test_has_terminal_stop_false(cls, gc, seqs):  # ported for SequenceCollection
+def test_has_terminal_stop_false(cls, gc, seqs):  # ported
     gc = get_code(gc)
     data = {f"s{i}": s for i, s in enumerate(seqs)}
     seqs = cls(data=data, moltype="dna")
@@ -2745,7 +2745,7 @@ def test_has_terminal_stop_false(cls, gc, seqs):  # ported for SequenceCollectio
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
-def test_has_terminal_stop_strict(cls):  # ported for SequenceCollection
+def test_has_terminal_stop_strict(cls):  # ported
     gc = get_code(1)
     data = {f"s{i}": s for i, s in enumerate(("CCTCA", "ATTTT"))}
     seqs = cls(data=data, moltype="dna")
@@ -2763,7 +2763,7 @@ def test_has_terminal_stop_strict(cls):  # ported for SequenceCollection
         (2, ("GATTTT", "TCCAGG")),
     ),
 )
-def test_trim_stops_true(cls, gc, seqs):  # ported for SequenceCollection
+def test_trim_stops_true(cls, gc, seqs):  # ported
     gc = get_code(gc)
     data = {f"s{i}": s for i, s in enumerate(seqs)}
 
@@ -2786,7 +2786,7 @@ def test_trim_stops_true(cls, gc, seqs):  # ported for SequenceCollection
     "gc,seqs",
     ((1, ("T-CTGC", "GATAA?")), (2, ("GATTTT", "TCCCGG")), (1, ("CCTGC", "GATAA"))),
 )
-def test_trim_terminal_stops_nostop(cls, gc, seqs):  # ported for SequenceCollection
+def test_trim_terminal_stops_nostop(cls, gc, seqs):  # ported
     gc = get_code(gc)
     data = {f"s{i}": s for i, s in enumerate(seqs)}
     seqs = cls(data=data, moltype="dna")
@@ -2796,7 +2796,7 @@ def test_trim_terminal_stops_nostop(cls, gc, seqs):  # ported for SequenceCollec
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
 @pytest.mark.parametrize("seqs", (("CCTCA", "ATTTT"), ("CCTCA-", "ATTTTA")))
-def test_trim_terminal_stops_strict(cls, seqs):  # ported for SequenceCollection
+def test_trim_terminal_stops_strict(cls, seqs):  # ported
     gc = get_code(1)
     data = {f"s{i}": s for i, s in enumerate(seqs)}
     seqs = cls(data=data, moltype="dna")
@@ -2805,7 +2805,7 @@ def test_trim_terminal_stops_strict(cls, seqs):  # ported for SequenceCollection
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
-def test_trim_stop_codons_info(cls):  # ported for SequenceCollection
+def test_trim_stop_codons_info(cls):  # ported
     """trim_stop_codons should preserve info attribute"""
     coll = cls(
         data={"seq1": "ACGTAA", "seq2": "ACGACG", "seq3": "ACGCGT"},
@@ -2817,7 +2817,7 @@ def test_trim_stop_codons_info(cls):  # ported for SequenceCollection
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
-def test_get_translation_incomplete(cls):  # ported for SequenceCollection
+def test_get_translation_incomplete(cls):  # ported
     """get translation works on incomplete codons"""
     alignment = cls(data={"seq1": "GATN--", "seq2": "?GATCT"}, moltype=DNA)
     got = alignment.get_translation(incomplete_ok=True)
@@ -2827,7 +2827,7 @@ def test_get_translation_incomplete(cls):  # ported for SequenceCollection
 
 
 @pytest.mark.parametrize("name", ("s1", "s2", "s3"))
-def test_get_seq_with_sliced_aln(name):
+def test_get_seq_with_sliced_aln(name):  # ported
     seqs = {
         "s1": "GTTGAAGTAGTAGAAGTTCCAAATAATGAA",
         "s2": "GTG------GTAGAAGTTCCAAATAATGAA",
@@ -2846,7 +2846,7 @@ def test_get_seq_with_sliced_aln(name):
 
 
 @pytest.mark.parametrize("name", ("s1", "s2", "s3"))
-def test_get_seq_with_sliced_rced_aln(name):
+def test_get_seq_with_sliced_rced_aln(name):  # ported
     seqs = {
         "s1": "GTTGAAGTAGTAGAAGTTCCAAATAATGAA",
         "s2": "GTG------GTAGAAGTTCCAAATAATGAA",
@@ -2864,7 +2864,7 @@ def test_get_seq_with_sliced_rced_aln(name):
 
 
 @pytest.mark.parametrize("name", ("s1", "s2", "s3"))
-def test_get_seq_with_sliced_aln_multiple_spans(name):
+def test_get_seq_with_sliced_aln_multiple_spans(name):  # ported
     seqs = {  # the sliced seq has:
         "s1": "GTTGA--TAGTAGAAGTTCCAAATAATGAA",  # span gap span
         "s2": "G----TT------AAGTTCCAAATAATGAA",  # gap span gap
@@ -2882,7 +2882,7 @@ def test_get_seq_with_sliced_aln_multiple_spans(name):
 
 
 @pytest.mark.parametrize("name", ("s1", "s2", "s3"))
-def test_get_seq_with_sliced_rced_aln_multiple_spans(name):
+def test_get_seq_with_sliced_rced_aln_multiple_spans(name):  # ported
     seqs = {  # the sliced seq has:
         "s1": "GTTGA--TAGTAGAAGTTCCAAATAATGAA",  # span gap span
         "s2": "G----TT------AAGTTCCAAATAATGAA",  # gap span gap
@@ -2899,7 +2899,7 @@ def test_get_seq_with_sliced_rced_aln_multiple_spans(name):
 
 
 @pytest.mark.parametrize("name", ("s1", "s2", "s3"))
-def test_get_gapped_seq_with_sliced_aln(name):
+def test_get_gapped_seq_with_sliced_aln(name):  # ported
     seqs = {
         "s1": "G-TG---TAGTAGAAGTTCCAAATAATGAA",
         "s2": "GTG------GTAGAAGTTCCAAATAATGAA",
@@ -2919,7 +2919,7 @@ def test_get_gapped_seq_with_sliced_aln(name):
 
 @pytest.mark.parametrize("name", ("s1", "s2", "s3"))
 @pytest.mark.parametrize("array_align", (True, False))
-def test_aln_rev_slice(name, array_align):
+def test_aln_rev_slice(name, array_align):  # ported
     seqs = {
         "s1": "AAGGTTCC",
         "s2": "AAGGTTCC",
@@ -3002,7 +3002,7 @@ def test_add(cls):  # will not port for SequenceCollection
 
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
-def test_count_gaps_per_pos(cls):
+def test_count_gaps_per_pos(cls):  # ported
     """correctly compute the number of gaps"""
     data = {"a": "AAAA---GGT", "b": "CCC--GG?GT"}
     aln = cls(data=data, moltype=DNA)
@@ -3028,7 +3028,7 @@ def _make_and_filter(cls, raw, expected, motif_length, drop_remainder):
 
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
-def test_filtered(cls):
+def test_filtered(cls):  # ported
     """filtered should return new alignment with positions consistent with
     provided callback function"""
     # a simple filter option
@@ -3090,7 +3090,7 @@ def test_coevolution_segments():
 
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
-def test_iter_positions(cls):
+def test_iter_positions(cls):  # ported
     data = {"a": "AAAAAA", "b": "AAA---", "c": "AAAA--"}
     r = cls({k: data[k] for k in "cb"})
     assert list(r.iter_positions(pos_order=[5, 1, 3])) == list(
@@ -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(
         [
@@ -3442,7 +3442,7 @@ def test_iter_selected(cls):  # ported for SequenceCollection
 
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
-def test_take_positions(cls):
+def test_take_positions(cls):  # ported
     """SequenceCollection take_positions should return new alignment w/ specified pos"""
     gaps = cls({"a": "AAAAAAA", "b": "A--A-AA", "c": "AA-----"})
     assert gaps.take_positions([5, 4, 0]) == {"a": "AAA", "b": "A-A", "c": "--A"}
@@ -3507,7 +3507,7 @@ def test_to_json(cls):  # ported for SequenceCollection
 
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
-def test_get_position_indices(cls):
+def test_get_position_indices(cls):  # ported
     """get_position_indices should return names of cols where f(col)"""
 
     def gap_1st(x):
@@ -3568,7 +3568,7 @@ def test_array_align_error_with_mixed_length():
 
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment, SequenceCollection))
-def test_add_seqs(cls):
+def test_add_seqs(cls):  # will not port adding seqs at positions
     """add_seqs should return an alignment with the new sequences appended or inserted"""
     data = [("name1", "AAA"), ("name2", "AAA"), ("name3", "AAA"), ("name4", "AAA")]
     data1 = [("name1", "AAA"), ("name2", "AAA")]
@@ -3618,7 +3618,7 @@ def test_add_seqs(cls):
 
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
-def test_omit_gap_pos(cls):
+def test_omit_gap_pos(cls):  # ported
     """Alignment omit_gap_pos should return alignment w/o positions of gaps"""
     aln = cls({"a": "--A-BC-", "b": "-CB-A--", "c": "--D-EF-"}, names=["a", "b", "c"])
     # first, check behavior when we're just acting on the cols (and not
@@ -3653,7 +3653,7 @@ def test_omit_gap_pos(cls):
 
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
-def test_no_degenerates(cls):
+def test_no_degenerates(cls):  # ported
     """no_degenerates correctly excludes columns containing IUPAC ambiguity codes"""
     data = {
         "s1": "AAA CCC GGG TTT".replace(" ", ""),
@@ -3701,7 +3701,7 @@ def test_no_degenerates(cls):
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
 @pytest.mark.parametrize("moltype", ("bytes", "text"))
-def test_no_degenerates_invalid_moltype(cls, moltype):
+def test_no_degenerates_invalid_moltype(cls, moltype):  # will not port
     # raises ValueError if a default moltype -- with no
     # degen characters -- is used
     data = {
@@ -3737,14 +3737,14 @@ def test_quick_tree(cls, calc, brca1_data):
 
 
 @pytest.mark.parametrize("raw", ("-AAAGGGGGAACCCT", "AAAGGGGGAACCCT"))
-def test_slice_aligned(raw):
+def test_slice_aligned(raw):  # ported
     imap, seq = DNA.make_seq(seq=raw, name="x").parse_out_gaps()
     al = Aligned(imap, seq)
     sliced = al[:-3]
     assert str(sliced) == raw[:-3]
 
 
-def test_slice_aligned_featuremap_allgap():
+def test_slice_aligned_featuremap_allgap():  # ported
     from cogent3.core.location import FeatureMap, LostSpan
 
     imap, seq = DNA.make_seq(seq="AAAGGGGGAACCCT", name="x").parse_out_gaps()
@@ -3754,7 +3754,7 @@ def test_slice_aligned_featuremap_allgap():
     assert not sliced
 
 
-def test_slice_aligned_featuremap_multi_spans():
+def test_slice_aligned_featuremap_multi_spans():  # ported
     from cogent3.core.location import FeatureMap
 
     #                    1111111
diff --git a/tests/test_core/test_annotation.py b/tests/test_core/test_annotation.py
index f144ef434..5daab68e2 100644
--- a/tests/test_core/test_annotation.py
+++ b/tests/test_core/test_annotation.py
@@ -14,7 +14,7 @@
 DNA = get_moltype("dna")
 
 
-def makeSampleSequence(name, with_gaps=False):
+def makeSampleSequence(name, with_gaps=False):  # ported
     raw_seq = "AACCCAAAATTTTTTGGGGGGGGGGCCCC"
     cds = (15, 25)
     utr = (12, 15)
@@ -27,7 +27,7 @@ def makeSampleSequence(name, with_gaps=False):
     return seq
 
 
-def makeSampleAlignment():
+def makeSampleAlignment():  # ported
     seq1 = makeSampleSequence("FAKE01")
     seq2 = makeSampleSequence("FAKE02", with_gaps=True)
     seqs = {seq1.name: seq1, seq2.name: seq2}
@@ -57,7 +57,7 @@ def test_add_annotated_seqs_drops_annotations(self):
         newseq = a + b
         assert newseq.annotation_db is None
 
-    def test_aln_annotations(self):
+    def test_aln_annotations(self):  # ported
         """test that annotations to alignment and its' sequences"""
         aln_expecteds = {
             "misc_feature": {"FAKE01": "TTTGGGGGGGGGG", "FAKE02": "TTTGGGGGGGGGG"},
@@ -100,7 +100,7 @@ def test_span(self):
 
 
 @pytest.mark.parametrize("alignment", (False, True))
-def test_constructing_collections(alignment):
+def test_constructing_collections(alignment):  # ported
     seq1 = makeSampleSequence("FAKE01")
     seq2 = makeSampleSequence("FAKE02", with_gaps=True)
     seqs = {"FAKE01": seq1, "FAKE02": seq2}
@@ -115,7 +115,7 @@ def test_constructing_collections(alignment):
 
 @pytest.mark.parametrize("reversed", (False, True))
 @pytest.mark.parametrize("annot_type", ("LTR", "misc_feature", "CDS", "5'UTR"))
-def test_region_union_on_alignment(annot_type, reversed):
+def test_region_union_on_alignment(annot_type, reversed):  # ported
     # >FAKE01
     # AACCCAAAATTTTTTGGGGGGGGGGCCCC
     # >FAKE02
@@ -146,7 +146,7 @@ def ann_aln():
     return makeSampleAlignment()
 
 
-def test_feature_projection_ungapped(ann_aln):
+def test_feature_projection_ungapped(ann_aln):  # ported
     # projection onto ungapped sequence
     expecteds = {"FAKE01": "CCCAAAATTTTTT", "FAKE02": "CCC-----TTTTT"}
     aln_ltr = list(ann_aln.get_features(biotype="LTR"))[0]
@@ -160,7 +160,7 @@ def test_feature_projection_ungapped(ann_aln):
     assert seq_ltr.parent == ann_aln.get_seq(seq_name)
 
 
-def test_feature_projection_gapped(ann_aln):
+def test_feature_projection_gapped(ann_aln):  # ported
     # projection onto gapped sequence
     expecteds = {"FAKE01": "CCCAAAATTTTTT", "FAKE02": "CCC-----TTTTT"}
     aln_ltr = list(ann_aln.get_features(biotype="LTR"))[0]
@@ -185,7 +185,7 @@ def ann_seq():
 
 
 @pytest.mark.parametrize("annot_type", ("CDS", "5'UTR"))
-def test_slice_seq_with_full_annotations(ann_seq, annot_type):
+def test_slice_seq_with_full_annotations(ann_seq, annot_type):  # ported
     # this slice contains both features intact
     newseq = ann_seq[10:]
     orig = list(ann_seq.get_features(biotype=annot_type))[0]
@@ -196,7 +196,7 @@ def test_slice_seq_with_full_annotations(ann_seq, annot_type):
 
 
 @pytest.mark.parametrize("annot_type,num", (("CDS", 0), ("5'UTR", 1)))
-def test_slice_seq_with_partial_end(ann_seq, annot_type, num):
+def test_slice_seq_with_partial_end(ann_seq, annot_type, num):  # ported
     # this slice contains both features intact
     newseq = ann_seq[:14]
     # only UTR is present
@@ -211,7 +211,7 @@ def test_slice_seq_with_partial_end(ann_seq, annot_type, num):
         assert len(gapless) < len(feat)
 
 
-@pytest.mark.parametrize("annot_type,num", (("CDS", 1), ("5'UTR", 0)))
+@pytest.mark.parametrize("annot_type,num", (("CDS", 1), ("5'UTR", 0)))  # ported
 def test_slice_seq_with_partial_start(ann_seq, annot_type, num):
     # this slice contains both features intact
     newseq = ann_seq[18:]
@@ -227,7 +227,7 @@ def test_slice_seq_with_partial_start(ann_seq, annot_type, num):
         assert len(gapless) < len(feat)
 
 
-def test_seq_feature_to_dict():
+def test_seq_feature_to_dict():  # ported
     """create the attributes necessary to write into the user table"""
     seq = DNA.make_seq(seq="ATTGTACGCCCCTGA", name="test_seq")
     feature_data = {
@@ -249,7 +249,7 @@ def test_seq_feature_to_dict():
     assert str(f.get_slice()) == str(c.get_slice())
 
 
-def test_aln_feature_to_dict():
+def test_aln_feature_to_dict():  # ported
     seqs = [
         makeSampleSequence("s1", with_gaps=False),
         makeSampleSequence("s2", with_gaps=True),
@@ -274,14 +274,14 @@ def test_aln_feature_to_dict():
 
 @pytest.mark.parametrize("fix", ("ann_seq", "ann_aln"))
 @pytest.mark.parametrize("type_", (list, tuple))
-def test_aln_slice_feat_invalid(type_, fix, request):
+def test_aln_slice_feat_invalid(type_, fix, request):  # ported
     # incorrect parent
     obj = request.getfixturevalue(fix)
     with pytest.raises(TypeError):
         _ = obj[type_(obj.get_features(biotype="exon"))]
 
 
-def test_seq_slice_seqfeat_invalid(ann_aln):
+def test_seq_slice_seqfeat_invalid(ann_aln):  # ported
     # incorrect parent
     seq1 = ann_aln.get_seq("FAKE01")
     seq2 = ann_aln.get_seq("FAKE02")
@@ -289,7 +289,7 @@ def test_seq_slice_seqfeat_invalid(ann_aln):
         _ = seq2[list(seq1.get_features(biotype="CDS"))[0]]
 
 
-def test_gbdb_get_children_get_parent(DATA_DIR):
+def test_gbdb_get_children_get_parent(DATA_DIR):  # ported
     seq = load_seq(DATA_DIR / "annotated_seq.gb")
     seq = seq[2900:6000]
     (orig,) = list(seq.get_features(biotype="gene", name="CNA00110"))
@@ -299,7 +299,7 @@ def test_gbdb_get_children_get_parent(DATA_DIR):
 
 
 @pytest.mark.parametrize("rev", (False, True))
-def test_features_survives_seq_rename(rev):
+def test_features_survives_seq_rename(rev):  # ported
     segments = ["A" * 10, "C" * 10, "T" * 5, "C" * 5, "A" * 5]
 
     seq = DNA.make_seq(seq="".join(segments), name="original")
@@ -327,7 +327,7 @@ def test_features_survives_seq_rename(rev):
 
 @pytest.mark.parametrize("rev", (False, True))
 @pytest.mark.parametrize("make_cls", (make_unaligned_seqs, Alignment))
-def test_features_survives_aligned_seq_rename(rev, make_cls):
+def test_features_survives_aligned_seq_rename(rev, make_cls):  # ported
     segments = ["A" * 10, "C" * 10, "T" * 5, "C" * 5, "A" * 5]
 
     seqs = make_cls({"original": "".join(segments)}, moltype="dna")
@@ -348,7 +348,7 @@ def test_features_survives_aligned_seq_rename(rev, make_cls):
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
-def test_features_invalid_seqid(cls):
+def test_features_invalid_seqid(cls):  # ported
     segments = ["A" * 10, "C" * 10, "T" * 5, "C" * 5, "A" * 5]
 
     seqs = cls({"original": "".join(segments)}, moltype="dna")
diff --git a/tests/test_core/test_features.py b/tests/test_core/test_features.py
index 1bad66029..f058cb1d5 100755
--- a/tests/test_core/test_features.py
+++ b/tests/test_core/test_features.py
@@ -287,7 +287,7 @@ def test_terminal_gaps():
     assert aln_exons[0].get_slice().to_dict() == dict(x="AAAAA", y="--T--")
 
 
-def test_annotated_region_masks():
+def test_annotated_region_masks():  # ported to test_new_aln_annotation.py
     """masking a sequence with specific features"""
 
     # Annotated regions can be masked (observed sequence characters
@@ -388,7 +388,7 @@ def test_annotated_region_masks():
     }
 
 
-def test_nested_annotated_region_masks():
+def test_nested_annotated_region_masks():  # ported to test_new_aln_annotation.py
     """masking a sequence with specific features when nested annotations"""
     db = GffAnnotationDb()
     db.add_feature(seqid="x", biotype="gene", name="norwegian", spans=[(0, 4)])
diff --git a/tests/test_core/test_location.py b/tests/test_core/test_location.py
index c37db2a2b..9a87fdb07 100644
--- a/tests/test_core/test_location.py
+++ b/tests/test_core/test_location.py
@@ -8,6 +8,7 @@
 import pytest
 
 from cogent3 import DNA, make_seq
+from cogent3.core import new_moltype
 from cogent3.core.location import (
     FeatureMap,
     IndelMap,
@@ -700,19 +701,26 @@ def test_indelmap_slice_zero():
 
 
 def test_indelmap_invalid_slice_range():
+    # If we mirror python slicing, an invalid slice should return an empty map
     imap = IndelMap(
         gap_pos=numpy.array([10], dtype=int),
         gap_lengths=numpy.array([2], dtype=int),
         parent_length=10,
     )
-    with pytest.raises(IndexError):
-        imap[-100]
 
-    with pytest.raises(IndexError):
-        imap[-100:]
+    expect = numpy.array([], dtype=int)
 
-    with pytest.raises(IndexError):
-        imap[:-99]
+    got = imap[-100]
+    assert (got.gap_pos == expect).all()
+    assert (got.cum_gap_lengths == expect).all()
+
+    got = imap[-100:]
+    assert (got.gap_pos == expect).all()
+    assert (got.cum_gap_lengths == expect).all()
+
+    got = imap[:-99]
+    assert (got.gap_pos == expect).all()
+    assert (got.cum_gap_lengths == expect).all()
 
 
 def test_indelmap_get_indices_errors():
@@ -953,17 +961,6 @@ def test_gapped_convert_aln2seq_invalid():
         gaps.get_seq_index(-100)
 
 
-@pytest.mark.parametrize(
-    "invalid_slice",
-    (slice(None, None, -1), slice(None, None, 2)),
-)
-def test_gap_pos_invalid_slice(invalid_slice):
-    pos, lengths = numpy.array([[1, 3]], dtype=numpy.int32).T
-    gp = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=20)
-    with pytest.raises(NotImplementedError):
-        _ = gp[invalid_slice]
-
-
 @pytest.mark.parametrize(
     "aslice",
     (
@@ -1386,3 +1383,94 @@ def test_indelmap_make_seq_feature_map():
     got = im.make_seq_feature_map(align_map)
     assert got.get_coordinates() == expect.get_coordinates()
     assert got.parent_length == expect.parent_length
+
+
+@pytest.mark.parametrize("start", range(10))
+@pytest.mark.parametrize("stop", range(11))
+@pytest.mark.parametrize("step", range(1, 5))
+@pytest.mark.parametrize(
+    "data",
+    [
+        "TCAGTCAGTC",
+        "AAAAA-----",
+        "-----AAAAA",
+        "--AA--AA--",
+        "AA--AA--AA",
+        "A-A-A-A-A-",
+        "---TTTT---",
+        "C--------C",
+        "----------",
+    ],
+)
+def test_indelmap_positive_step_variant_slices(start, stop, step, data):
+    imap, _ = new_moltype.DNA.make_seq(seq=data).parse_out_gaps()
+    got = imap[start:stop:step]
+    expect, _ = new_moltype.DNA.make_seq(seq=data[start:stop:step]).parse_out_gaps()
+    assert (got.gap_pos == expect.gap_pos).all()
+    assert (got.cum_gap_lengths == expect.cum_gap_lengths).all()
+
+
+@pytest.mark.parametrize("start", range(11))
+@pytest.mark.parametrize("stop", range(10))
+@pytest.mark.parametrize("step", [-1, -2, -3])
+@pytest.mark.parametrize(
+    "data",
+    [
+        "TCAGTCAGTC",
+        "AAAAA-----",
+        "AAAAAAA--A",
+        "-----AAAAA",
+        "--AA--AA--",
+        "AA--AA--AA",
+        "A-A-A-A-A-",
+        "---TTTT---",
+        "C--------C",
+        "----------",
+    ],
+)
+def test_indelmap_negative_step_variant_slices(start, stop, step, data):
+    imap, _ = new_moltype.DNA.make_seq(seq=data).parse_out_gaps()
+    got = imap[start:stop:step]
+    expect, _ = new_moltype.DNA.make_seq(seq=data[start:stop:step]).parse_out_gaps()
+    assert (got.gap_pos == expect.gap_pos).all()
+    assert (got.cum_gap_lengths == expect.cum_gap_lengths).all()
+
+
+@pytest.mark.parametrize("scale", [1 / 2, 1 / 1.1])
+def test_indelmap_scaled_invalid(scale):
+    im = IndelMap(
+        gap_pos=numpy.array([1, 3]), gap_lengths=numpy.array([2, 1]), parent_length=5
+    )
+    with pytest.raises(ValueError):
+        im.scaled(scale)
+
+
+@pytest.mark.parametrize("scale", [1, 1.0])
+def test_indelmap_scaled_one(scale):
+    im = IndelMap(
+        gap_pos=numpy.array([1, 3]), gap_lengths=numpy.array([2, 1]), parent_length=5
+    )
+    got = im.scaled(scale)
+    assert got is im
+
+
+@pytest.fixture
+def codon_and_aa_maps():
+    import cogent3
+
+    data = dict(s1="ATG --- --- GAT --- AAA", s2="ATG CAA TCG AAT GAA ATA")
+    dna = cogent3.make_aligned_seqs(
+        {n: s.replace(" ", "") for n, s in data.items()}, moltype="dna", new_type=True
+    )
+    aa = dna.get_translation()
+    dna_1 = dna.seqs["s1"].map
+    aa_1 = aa.seqs["s1"].map
+    return dna_1, aa_1
+
+
+def test_indelmap_scaled(codon_and_aa_maps):
+    cmap, amap = codon_and_aa_maps
+    cmap_to_amap = cmap.scaled(1 / 3)
+    amap_to_cmap = amap.scaled(3)
+    numpy.testing.assert_allclose(cmap_to_amap.array, amap.array)
+    numpy.testing.assert_allclose(amap_to_cmap.array, cmap.array)
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
index 924bfc1b3..77da9ef34 100644
--- a/tests/test_core/test_new_alignment.py
+++ b/tests/test_core/test_new_alignment.py
@@ -7,11 +7,12 @@
 import numpy
 import pytest
 
-from cogent3 import load_unaligned_seqs, open_
+from cogent3 import get_app, load_aligned_seqs, load_unaligned_seqs, open_
 from cogent3._version import __version__
 from cogent3.core import new_alignment, new_alphabet, new_moltype, new_sequence
 from cogent3.core.annotation import Feature
 from cogent3.core.annotation_db import GffAnnotationDb, load_annotations
+from cogent3.core.location import FeatureMap, LostSpan, Span
 from cogent3.util.deserialise import deserialise_object
 from cogent3.util.misc import get_object_provenance
 
@@ -42,14 +43,35 @@ def arr_seqs_dict():
 
 
 @pytest.fixture
-def alpha(moltype="dna"):
-    moltype = new_moltype.get_moltype(moltype)
+def dna_alphabet():
+    moltype = new_moltype.get_moltype("dna")
     return moltype.degen_gapped_alphabet
 
 
 @pytest.fixture
-def dna_sd(str_seqs_dict: dict[str, str], alpha):
-    return new_alignment.SeqsData(data=str_seqs_dict, alphabet=alpha)
+def dna_moltype():
+    return new_moltype.get_moltype("dna")
+
+
+@pytest.fixture
+def dna_make_seq():
+    return new_moltype.get_moltype("dna").make_seq
+
+
+@pytest.fixture
+def rna_alphabet():
+    moltype = new_moltype.get_moltype("rna")
+    return moltype.degen_gapped_alphabet
+
+
+@pytest.fixture
+def rna_moltype():
+    return new_moltype.get_moltype("rna")
+
+
+@pytest.fixture
+def dna_sd(str_seqs_dict: dict[str, str], dna_alphabet):
+    return new_alignment.SeqsData.from_seqs(data=str_seqs_dict, alphabet=dna_alphabet)
 
 
 @pytest.fixture
@@ -59,7 +81,7 @@ def int_arr():
 
 @pytest.fixture
 def sdv_s2(dna_sd: new_alignment.SeqsData) -> new_alignment.SeqDataView:
-    return dna_sd.get_seq_view(seqid="seq2")
+    return dna_sd.get_view("seq2")
 
 
 @pytest.fixture(scope="function")
@@ -69,10 +91,13 @@ def seqs() -> new_alignment.SequenceCollection:
 
 
 @pytest.fixture
-def ragged_padded():
-    return new_alignment.make_unaligned_seqs(
-        {"a": "AAAAAA", "b": "AAA---", "c": "AAAA--"}, moltype="dna"
-    )
+def ragged_padded_dict():
+    return {"a": "AAAAAA", "b": "AAA---", "c": "AAAA--"}
+
+
+@pytest.fixture
+def ragged_padded(ragged_padded_dict):
+    return new_alignment.make_unaligned_seqs(ragged_padded_dict, moltype="dna")
 
 
 @pytest.fixture
@@ -91,20 +116,16 @@ def unordered():
 
 @pytest.fixture
 def ordered1():
-    seqs = new_alignment.make_unaligned_seqs(
+    return new_alignment.make_unaligned_seqs(
         {"a": "AAAAA", "c": "CCCCC"}, moltype="dna"
     )
-    seqs.names = ["a", "c"]
-    return seqs
 
 
 @pytest.fixture
 def ordered2():
-    seqs = new_alignment.make_unaligned_seqs(
-        {"a": "AAAAA", "c": "CCCCC"}, moltype="dna"
+    return new_alignment.make_unaligned_seqs(
+        {"c": "CCCCC", "a": "AAAAA"}, moltype="dna"
     )
-    seqs.names = ["c", "a"]
-    return seqs
 
 
 @pytest.fixture(scope="function")
@@ -134,75 +155,93 @@ def seqcoll_db(DATA_DIR):
     return seq_coll
 
 
+def make_typed(seq, data_type, moltype):
+    if data_type is numpy.ndarray:
+        seq = moltype.most_degen_alphabet().to_indices(seq)
+    elif data_type is bytes:
+        seq = seq.encode("utf-8")
+    return seq
+
+
+def test_seqs_data_construction(str_seqs_dict, dna_alphabet):
+    """SeqsData can be constructed from a dict and alphabet, either directly or via from_seqs"""
+    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=dna_alphabet)
+    assert isinstance(sd, new_alignment.SeqsData)
+
+    sd = new_alignment.SeqsData.from_seqs(data=str_seqs_dict, alphabet=dna_alphabet)
+    assert isinstance(sd, new_alignment.SeqsData)
+
+
+def test_seqs_data_construction_wrong_alphabet(str_seqs_dict, rna_alphabet):
+    """SeqsData should raise ValueError if alphabet is incompatible with data"""
+    with pytest.raises(new_alphabet.AlphabetError):
+        _ = new_alignment.SeqsData(data=str_seqs_dict, alphabet=rna_alphabet)
+
+
 def test_seqs_data_default_attributes(dna_sd: new_alignment.SeqsData):
     assert dna_sd.names == ["seq1", "seq2", "seq3"]
     assert isinstance(dna_sd.alphabet, new_alphabet.CharAlphabet)
 
 
-def test_seqs_data_view_zero_step_raises(dna_sd):
-    with pytest.raises(ValueError):
-        new_alignment.SeqDataView(seq=dna_sd, seqid=seq1, step=0, seq_len=4)
-
-
 @pytest.mark.parametrize("seqid", ["seq1", "seq2"])
 def test_seqs_data_view_repr_default(dna_sd: new_alignment.SeqsData, seqid: str):
     seq = dna_sd.get_seq_str(seqid=seqid)
-    seq_len = len(seq)
-    expect = f"SeqDataView(seq={seq}, start=0, stop={seq_len}, step=1, offset=0, seqid='{seqid}', seq_len={seq_len})"
-    got = dna_sd.get_seq_view(seqid)
+    got = dna_sd.get_view(seqid)
+    expect = (
+        f"SeqDataView(seqid='{seqid}', parent={seq}, slice_record={got.slice_record!r})"
+    )
     assert repr(got) == expect
 
 
-def test_seqs_data_view_repr_default_long(alpha):
+def test_seqs_data_view_repr_default_long(dna_alphabet):
     longseq = "CGATCGTAGTACGTGTCAAGTCTGAC"
-    seq_len = len(longseq)
     trunc = f"{longseq[:10]}...{longseq[-5:]}"
-    expect = f"SeqDataView(seq={trunc}, start=0, stop={seq_len}, step=1, offset=0, seqid='long', seq_len={seq_len})"
 
     d = {"long": longseq}
-    sd = new_alignment.SeqsData(data=d, alphabet=alpha)
-    got = sd.get_seq_view(seqid="long")
+    sd = new_alignment.SeqsData.from_seqs(data=d, alphabet=dna_alphabet)
+    got = sd.get_view("long")
+    expect = (
+        f"SeqDataView(seqid='long', parent={trunc}, slice_record={got.slice_record!r})"
+    )
     assert repr(got) == expect
 
 
 @pytest.mark.parametrize("seqid", ["seq1", "seq2"])
 @pytest.mark.parametrize("sliced", (False, True))
 @pytest.mark.parametrize("step", (1, -1))
-def test_seqs_data_view_copy(alpha, seqid, sliced, step):
+def test_seqs_data_view_copy(dna_alphabet, seqid, sliced, step):
     seq1 = "ATGTTCTC"
     seq2 = "ATGAACTCATT"
     start, stop = 2, 6
     data = {"seq1": seq1, "seq2": seq2}
 
-    sd = new_alignment.SeqsData(data=data, alphabet=alpha)
-    sdv = sd.get_seq_view(seqid=seqid)
+    sd = new_alignment.SeqsData(data=data, alphabet=dna_alphabet)
+    sdv = sd.get_view(seqid)
     sliced_sdv = sdv[start:stop:step]
-
     copied_sdv = sliced_sdv.copy(sliced=sliced)
 
     assert copied_sdv.str_value == data[seqid][start:stop:step]
 
 
 @pytest.mark.parametrize("seqid", ["seq1", "seq2"])
-def test_seqs_data_get_seq_view(str_seqs_dict, alpha, seqid):
-    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=alpha)
+def test_seqs_data_get_seq_view(str_seqs_dict, dna_alphabet, seqid):
+    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=dna_alphabet)
     seq = str_seqs_dict[seqid]
-    seq_len = len(seq)
-    got = sd.get_seq_view(seqid)
+    parent_len = len(seq)
+    got = sd.get_view(seqid)
     assert isinstance(got, new_alignment.SeqDataView)
-    assert got.seq == sd
-    assert got.stop == seq_len
+    assert got.parent == sd
     assert got.seqid == seqid
-    assert got.seq_len == seq_len
+    assert got.parent_len == parent_len
 
 
 @pytest.mark.parametrize("seq", ("seq1", "seq2"))
-@pytest.mark.parametrize("start", (None, -1, 0, 1, 4))
-@pytest.mark.parametrize("stop", (None, -1, 0, 1, 4))
-def test_seqs_data_get_seq_str(str_seqs_dict, alpha, seq, start, stop):
+@pytest.mark.parametrize("start", (None, 0, 1))
+@pytest.mark.parametrize("stop", (None, 3, 4))
+def test_seqs_data_get_seq_str(str_seqs_dict, dna_alphabet, seq, start, stop):
     # slicing should be tested in test_get_seq_array
     expect = str_seqs_dict[seq][start:stop]
-    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=alpha)
+    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=dna_alphabet)
     got = sd.get_seq_str(seqid=seq, start=start, stop=stop)
     assert expect == got
 
@@ -212,37 +251,32 @@ def test_seqs_data_get_seq_str_empty(dna_sd: new_alignment.SeqsData):
         dna_sd.get_seq_str()
 
 
-def test_seqs_data_names(str_seqs_dict, alpha):
+def test_seqs_data_names(str_seqs_dict, dna_alphabet):
     expect = str_seqs_dict.keys()
-    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=alpha)
+    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=dna_alphabet)
     got = sd.names
     # returns iterator
     assert list(got) == list(expect)
 
 
-def test_seqs_data_seq_lengths(str_seqs_dict, arr_seqs_dict, alpha):
-    expect = {k: len(v) for k, v in str_seqs_dict.items()}
-    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=alpha)
-    got = sd.seq_lengths()
-    assert got == expect
+@pytest.fixture(params=["str_seqs_dict", "arr_seqs_dict"])
+def seqs_dicts(request):
+    return request.getfixturevalue(request.param)
 
-    expect = {k: len(v) for k, v in arr_seqs_dict.items()}
-    sd = new_alignment.SeqsData(data=arr_seqs_dict, alphabet=alpha)
-    got = sd.seq_lengths()
-    assert got == expect
 
+@pytest.mark.parametrize("seqid", ["seq1", "seq2", "seq3"])
+def test_seqs_data_seq_lengths(seqs_dicts, dna_alphabet, seqid):
+    sd = new_alignment.SeqsData(data=seqs_dicts, alphabet=dna_alphabet)
+    expect = len(seqs_dicts[seqid])
+    got = sd.get_seq_length(seqid)
+    assert got == expect
 
-def test_seqs_data_get_seq_array(str_seqs_dict, alpha):
-    # seq1
-    expect = numpy.array([2, 1, 3, 0], dtype="uint8")
-    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=alpha)
-    got = sd.get_seq_array(seqid="seq1")
-    assert numpy.array_equal(got, expect)
 
-    # seq2
-    expect = numpy.array([3, 0, 0, 0, 3, 1, 2], dtype="uint8")
-    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=alpha)
-    got = sd.get_seq_array(seqid="seq2")
+@pytest.mark.parametrize("seqid", ["seq1", "seq2", "seq3"])
+def test_seqs_data_get_seq_array(arr_seqs_dict, dna_alphabet, seqid):
+    expect = arr_seqs_dict[seqid]
+    sd = new_alignment.SeqsData(data=arr_seqs_dict, alphabet=dna_alphabet)
+    got = sd.get_seq_array(seqid=seqid)
     assert numpy.array_equal(got, expect)
 
 
@@ -252,81 +286,28 @@ def test_seqs_data_get_seq_bytes(dna_sd: new_alignment.SeqsData):
     assert isinstance(got, bytes)
 
 
-def test_seqs_data_make_seq_default(dna_sd):
-    assert dna_sd.make_seq is None
-
-
-def test_seqs_data_make_seq_setget(dna_sd):
-    dna_sd.make_seq = new_moltype.get_moltype("dna").make_seq
-    assert dna_sd.make_seq == new_moltype.get_moltype("dna").make_seq
-
-
 @pytest.mark.parametrize("seq", ("seq1", "seq2"))
-def test_seqs_data_getitem_str_1(dna_sd, seq):
+def test_seqs_data_getitem_str(dna_sd, seq):
     got = dna_sd[seq]
-    assert got.seq == dna_sd
+    assert isinstance(got, new_alignment.SeqDataView)
+    assert got.parent == dna_sd
     assert got.seqid == seq
 
 
 @pytest.mark.parametrize("idx", (0, 1))
-def test_seqs_data_getitem_int(str_seqs_dict, alpha, idx):
-    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=alpha)
-    got = sd[idx]
-    assert got.seq == sd
+def test_seqs_data_getitem_int(str_seqs_dict, dna_sd, idx):
+    got = dna_sd[idx]
+    assert isinstance(got, new_alignment.SeqDataView)
+    assert got.parent == dna_sd
     assert got.seqid == list(str_seqs_dict)[idx]
 
 
-@pytest.mark.parametrize("seqid", ("seq1", "seq2"))
-def test_seqs_data_getitem_str(
-    dna_sd,
-    seqid,
-):
-    got = dna_sd[seqid]
-    assert got.seq == dna_sd
-    assert got.seqid == seqid
-
-
-@pytest.mark.parametrize("make_seq", (True, False))
-def test_seqs_data_getitem_raises(dna_sd, make_seq):
-    ms = new_moltype.get_moltype("dna").make_seq if make_seq else None
-    dna_sd.make_seq = ms
+def test_seqs_data_getitem_raises(dna_sd):
     invalid_index = ["this", "shouldn't", "work"]
     with pytest.raises(NotImplementedError):
         _ = dna_sd[invalid_index]
 
 
-def test_seqs_data_subset(dna_sd):
-    got = dna_sd.subset("seq1")
-    assert isinstance(got, new_alignment.SeqsData)
-    assert got.names == ["seq1"]
-    assert got.get_seq_str(seqid="seq1") == dna_sd.get_seq_str(seqid="seq1")
-
-    got = dna_sd.subset(["seq1", "seq2"])
-    assert isinstance(got, new_alignment.SeqsData)
-    assert got.names == ["seq1", "seq2"]
-    assert got.get_seq_str(seqid="seq1") == dna_sd.get_seq_str(seqid="seq1")
-    assert got.get_seq_str(seqid="seq2") == dna_sd.get_seq_str(seqid="seq2")
-
-    got = dna_sd.subset(["seq2", "seq1"])
-    assert got.names == ["seq2", "seq1"]
-    assert got.get_seq_str(seqid="seq1") == dna_sd.get_seq_str(seqid="seq1")
-    assert got.get_seq_str(seqid="seq2") == dna_sd.get_seq_str(seqid="seq2")
-
-    got = dna_sd.subset(["seq3"])
-    assert got.names == ["seq3"]
-    assert got.get_seq_str(seqid="seq3") == dna_sd.get_seq_str(seqid="seq3")
-
-
-def test_seqs_data_subset_raises(dna_sd):
-    # should handle missing seqids
-    got = dna_sd.subset(["seq1", "seq4"])
-    assert got.names == ["seq1"]
-
-    # but will raise error if resulting in empty selection
-    with pytest.raises(ValueError):
-        _ = dna_sd.subset(["seq4"])
-
-
 def test_seqs_data_to_alphabet():
     ASCII = new_moltype.ASCII.alphabet
     DNA = new_moltype.DNA.degen_gapped_alphabet
@@ -350,25 +331,10 @@ 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)
 
 
-@pytest.mark.parametrize("reverse", (False, True))
-def test_seqs_data_round_trip(reverse, alpha):
-    seqs_data = new_alignment.SeqsData(
-        data={"seq1": "ACGG", "seq2": "CGCA", "seq3": "CCG-"}, alphabet=alpha
-    )
-    seqs_data = seqs_data.reverse_seqs() if reverse else seqs_data
-
-    rd = seqs_data.to_rich_dict()
-    got = deserialise_object(rd)
-    assert isinstance(got, new_alignment.SeqsData)
-    assert got.to_rich_dict() == seqs_data.to_rich_dict()
-    expect = "ACGG"[::-1] if reverse else "ACGG"
-    assert str(got.get_seq_view("seq1")) == expect
-
-
 @pytest.mark.parametrize(
     "index",
     [
@@ -380,8 +346,14 @@ def test_seqs_data_round_trip(reverse, alpha):
         slice(None),
     ],
 )
-def test_seq_data_view_slice_returns_self(seq1: str, index: slice):
-    sdv = new_alignment.SeqDataView(seq=seq1, seqid="seq1", seq_len=len(seq1))
+def test_seq_data_view_slice_returns_self(seq1: str, index: slice, dna_alphabet):
+    sdv = new_alignment.SeqDataView(
+        parent=seq1,
+        seqid="seq1",
+        alphabet=dna_alphabet,
+        parent_len=len(seq1),
+        slice_record=new_sequence.SliceRecord(parent_len=len(seq1)),
+    )
     got = sdv[index]
     assert isinstance(got, new_alignment.SeqDataView)
 
@@ -390,12 +362,12 @@ def test_seq_data_view_slice_returns_self(seq1: str, index: slice):
 @pytest.mark.parametrize("start", (None, 0, 1, 4, -1, -4))
 @pytest.mark.parametrize("stop", (None, 0, 1, 4, -1, -4))
 @pytest.mark.parametrize("step", (None, 1, 2, 3, -1, -2, -3))
-def test_seq_data_view_value(str_seqs_dict: dict, alpha, start, stop, step):
+def test_seq_data_view_value(str_seqs_dict: dict, dna_alphabet, start, stop, step):
     seq = "seq2"
     expect = str_seqs_dict[seq][start:stop:step]
-    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=alpha)
+    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=dna_alphabet)
     # Get SeqDataView on seq
-    sdv = sd.get_seq_view(seqid=seq)
+    sdv = sd.get_view(seq)
     sdv2 = sdv[start:stop:step]
     got = sdv2.str_value
     assert got == expect
@@ -406,16 +378,16 @@ def test_seq_data_view_to_rich_dict(rev):
     data = {"seq1": "ACGG", "seq2": "CGCA", "seq3": "CCG-"}
     alpha = new_moltype.DNA.degen_gapped_alphabet
     sd = new_alignment.SeqsData(data=data, alphabet=alpha)
-    sdv = sd.get_seq_view(seqid="seq1")
+    sdv = sd.get_view("seq1")
     sdv = sdv[::-1] if rev else sdv
     got = sdv.to_rich_dict()
     expect = {
         "init_args": {
             "seqid": "seq1",
-            "step": sdv.step,
-            "offset": sdv.offset,
-            "seq_len": sdv.seq_len,
-            "seq": sdv.str_value,
+            "parent": sdv.str_value,
+            "parent_len": len(sdv.str_value),
+            "alphabet": alpha.to_rich_dict(),
+            "slice_record": sdv.slice_record.to_rich_dict(),
         },
         "type": get_object_provenance(sdv),
         "version": __version__,
@@ -427,12 +399,12 @@ def test_seq_data_view_to_rich_dict(rev):
 @pytest.mark.parametrize("start", (None, 0, 1, 4, -1, -4))
 @pytest.mark.parametrize("stop", (None, 0, 1, 4, -1, -4))
 @pytest.mark.parametrize("step", (None, 1, 2, 3, -1, -2, -3))
-def test_seqs_data_array_value(arr_seqs_dict: dict, alpha, start, stop, step):
+def test_seqs_data_array_value(arr_seqs_dict: dict, dna_alphabet, start, stop, step):
     seq = "seq2"
     expect = arr_seqs_dict[seq][start:stop:step]
-    sd = new_alignment.SeqsData(data=arr_seqs_dict, alphabet=alpha)
+    sd = new_alignment.SeqsData(data=arr_seqs_dict, alphabet=dna_alphabet)
     # Get SeqDataView on seq
-    sdv = sd.get_seq_view(seqid=seq)
+    sdv = sd.get_view(seq)
     got = sdv.array_value[start:stop:step]
     assert numpy.array_equal(got, expect)
 
@@ -440,13 +412,13 @@ def test_seqs_data_array_value(arr_seqs_dict: dict, alpha, start, stop, step):
 @pytest.mark.parametrize("start", (None, 0, 1, 4, -1, -4))
 @pytest.mark.parametrize("stop", (None, 0, 1, 4, -1, -4))
 @pytest.mark.parametrize("step", (None, 1, 2, 3, -1, -2, -3))
-def test_seqs_data_bytes_value(str_seqs_dict: dict, alpha, start, stop, step):
+def test_seqs_data_bytes_value(str_seqs_dict: dict, dna_alphabet, start, stop, step):
     seq = "seq2"
     expect = str_seqs_dict[seq][start:stop:step]
     expect = expect.encode("utf8")
-    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=alpha)
+    sd = new_alignment.SeqsData(data=str_seqs_dict, alphabet=dna_alphabet)
     # Get SeqDataView on seq
-    sdv = sd.get_seq_view(seqid=seq)
+    sdv = sd.get_view(seq)
     got = sdv.bytes_value[start:stop:step]
     assert expect == got
 
@@ -464,128 +436,265 @@ def test_bytes(sdv_s2: new_alignment.SeqDataView):
     assert expect == got
 
 
-@pytest.mark.parametrize("moltype", ("dna", "rna", "protein", "protein_with_stop"))
-def test_make_unaligned_seqs_dict(moltype):
-    """test SequenceCollection constructor utility function"""
-    data = {"a": "AGGCCC", "b": "AGAAAA"}
-    got = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-    assert isinstance(got, new_alignment.SequenceCollection)
-    assert got._seqs_data.make_seq == new_moltype.get_moltype(moltype).make_seq
-
-    # should also work if seqs are arrays
-    data = {"a": numpy.array(["AGGCCC"]), "b": numpy.array(["AGAAAA"])}
-    got = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-    assert isinstance(got, new_alignment.SequenceCollection)
-    assert got._seqs_data.make_seq == new_moltype.get_moltype(moltype).make_seq
+# tests of SequenceCollection and Alignment constructor utility function
 
 
+@pytest.mark.parametrize(
+    "mk_cls, cls",
+    [
+        (new_alignment.make_unaligned_seqs, new_alignment.SequenceCollection),
+        (new_alignment.make_aligned_seqs, new_alignment.Alignment),
+    ],
+)
 @pytest.mark.parametrize("moltype", ("dna", "rna", "protein", "protein_with_stop"))
-@pytest.mark.parametrize("cls", (list, set))
-def test_make_unaligned_seqs_collection(moltype, cls):
-    """test SequenceCollection constructor utility function"""
-    data = cls(["AGGCCC", "AGAAAA"])
-    got = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-    assert isinstance(got, new_alignment.SequenceCollection)
-    assert got._seqs_data.make_seq == new_moltype.get_moltype(moltype).make_seq
+def test_make_seqs(moltype, mk_cls, cls):
+    """SequenceCollection and Alignment constructor functions should handle
+    dict/list/set as input"""
+    # dict of strings
+    data = {"a": "AGGCCC", "b": "AGAAAA"}
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
+
+    # dict of arrays
+    data = {"a": numpy.array([0, 2, 1, 3, 2, 3]), "b": numpy.array([0, 2, 1, 3, 2, 1])}
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
+
+    # list of str
+    data = ["AGGCCC", "AGAAAA"]
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
     assert got.num_seqs == 2
 
-    # should also work if seqs are arrays
-    data = [numpy.array(["AGGCCC"]), numpy.array(["AGAAAA"])]
-    got = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-    assert isinstance(got, new_alignment.SequenceCollection)
-    assert got._seqs_data.make_seq == new_moltype.get_moltype(moltype).make_seq
+    # set of str
+    data = {"AGGCCC", "AGAAAA"}
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
+    assert got.num_seqs == 2
 
-    # also when seqs are list(name: seq) pairs
+    # list of array
+    data = [numpy.array([0, 2, 1, 3, 2, 3]), numpy.array([0, 2, 1, 3, 2, 1])]
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
+
+    # list(name: seq) pairs
     data = [["seq1", "AGGCCC"], ["seq2", "AGAAAA"]]
-    got = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-    assert isinstance(got, new_alignment.SequenceCollection)
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
 
-    # or tuples
+    # tuples
     data = [("seq1", "AGGCCC"), ("seq2", "AGAAAA")]
-    got = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-    assert isinstance(got, new_alignment.SequenceCollection)
+    got = mk_cls(data, moltype=moltype)
+    assert isinstance(got, cls)
 
 
-def test_make_unaligned_seqs_label_to_name():
-    """test SequenceCollection constructor utility function"""
-    data = {"a": "AGGCCC", "b": "AGAAAA"}
+@pytest.mark.parametrize(
+    "mk_cls, cls",
+    [
+        (new_alignment.make_unaligned_seqs, new_alignment.SeqsData),
+        (new_alignment.make_aligned_seqs, new_alignment.AlignedSeqsData),
+    ],
+)
+def test_make_seqs_label_to_name(mk_cls, cls, dna_alphabet):
+    """SequenceCollection and Alignment constructor functions should convert names
+    using label_to_name function if provided"""
 
     def f(x):
         return x.upper()
 
-    got = new_alignment.make_unaligned_seqs(data, moltype="dna", label_to_name=f)
+    # for data as a dict
+    data = {"a": "AGGCCC", "b": "AGAAAA"}
+    got = mk_cls(data, moltype="dna", label_to_name=f)
+    assert list(got.names) == ["A", "B"]
+
+    # for data as a SeqsData object
+    data = cls.from_seqs(data=data, alphabet=dna_alphabet)
+    got = mk_cls(data, moltype="dna", label_to_name=f)
     assert list(got.names) == ["A", "B"]
-    assert got._seqs_data.make_seq == new_moltype.get_moltype("dna").make_seq
 
 
-def test_make_unaligned_seqs_raises():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_make_seqs_raises(mk_cls):
+    """cannot construct SequenceCollection or Alignment from a string"""
     data = "AGTCCTGA"
     with pytest.raises(NotImplementedError):
-        new_alignment.make_unaligned_seqs(data, moltype="dna")
-
-
-def test_make_unaligned_seqs_incompatible_moltype(dna_sd):
-    with pytest.raises(ValueError):
-        _ = new_alignment.make_unaligned_seqs(dna_sd, moltype="rna")
+        mk_cls(data, moltype="dna")
 
 
-def test_make_unaligned_seqs_no_seqs():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_make_seqs_no_seqs(mk_cls):
+    """cannot construct SequenceCollection or Alignment from an empty dict"""
     data = {}
     with pytest.raises(ValueError):
-        new_alignment.make_unaligned_seqs(data, moltype="dna")
+        mk_cls(data, moltype="dna")
 
 
-def test_sequence_collection_init(seqs):
-    assert isinstance(seqs.seqs, new_alignment.SeqsData)
-    assert seqs.seqs.make_seq is not None
-
-
-def test_sequence_collection_names_is_list():
-    """expected to be a list"""
-    seqs = new_alignment.make_unaligned_seqs(
-        {"a": b"AAAAA", "b": b"TTTTT"}, moltype="dna"
-    )
-    assert isinstance(seqs.names, list)
+@pytest.mark.parametrize(
+    "mk_cls, seqs_data_cls",
+    [
+        (new_alignment.make_unaligned_seqs, new_alignment.SeqsData),
+        (new_alignment.make_aligned_seqs, new_alignment.AlignedSeqsData),
+    ],
+)
+def test_make_seqs_incompatible_moltype(mk_cls, seqs_data_cls, dna_alphabet):
+    """SequenceCollection and Alignment constructor functions should raise an error
+    if the provided moltype is incompatible with the alphabet of the seqs data"""
 
+    data = {"a": "AGGCCC", "b": "AGAAAA"}
+    seqs_data = seqs_data_cls.from_seqs(data=data, alphabet=dna_alphabet)
 
-def test_sequence_collection_names(seqs):
-    assert seqs.names == ["seq1", "seq2", "seq3"]
-    seqs.names = ["seq2", "seq3", "seq1"]
-    assert seqs.names == ["seq2", "seq3", "seq1"]
-    seqs.names = ["seq1", "seq2"]
-    assert seqs.names == ["seq1", "seq2"]
     with pytest.raises(ValueError):
-        seqs.names = ["seq1", "seq2", "seq3", "seq4"]
+        _ = mk_cls(seqs_data, moltype="rna")
 
 
-def test_make_unaligned_seqs_list_str():
-    """SequenceCollection init from list of sequences should use indices as keys"""
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_make_seqs_from_list_generates_correct_names(mk_cls):
+    """SequenceCollection/Alignment init from list of sequences should use indices as keys"""
     seqs = ["TTTTT", "CCCCC", "GGGGG"]
-    a = new_alignment.make_unaligned_seqs(seqs, moltype="dna")
-    assert len(a.seqs) == 3
-    assert a.seqs["seq_0"] == "TTTTT"
-    assert a.seqs["seq_1"] == "CCCCC"
-    assert a.seqs["seq_2"] == "GGGGG"
+    a = mk_cls(seqs, moltype="dna")
+    assert str(a.seqs["seq_0"]) == "TTTTT"
+    assert str(a.seqs["seq_1"]) == "CCCCC"
+    assert str(a.seqs["seq_2"]) == "GGGGG"
     assert a.names == ["seq_0", "seq_1", "seq_2"]
 
 
-def test_make_unaligned_seqs_pairs():
-    """SequenceCollection init from list of (key,val) pairs should work correctly"""
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_make_seqs_from_pairs(mk_cls):
+    """SequenceCollection/Alignment init from list of (key,val) pairs should work correctly"""
     seqs = [["a", "AAA"], ["t", "TTT"], ["c", "CCC"]]
-    a = new_alignment.make_unaligned_seqs(seqs, moltype="dna")
-    assert len(a.seqs) == 3
-    assert a.seqs["a"] == "AAA"
-    assert a.seqs["t"] == "TTT"
-    assert a.seqs["c"] == "CCC"
+    a = mk_cls(seqs, moltype="dna")
+    assert str(a.seqs["a"]) == "AAA"
+    assert str(a.seqs["t"]) == "TTT"
+    assert str(a.seqs["c"]) == "CCC"
     assert a.names == ["a", "t", "c"]
 
 
-def test_make_unaligned_seqs_list_sequences():
-    """correctly construct from list of sequences of length 2"""
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_make_seqs_from_sequences(mk_cls):
+    """SequenceCollection and Alignment constructor functions can be provided with
+    a list of Sequence objects"""
+    # if no names, they should be generated via the standard naming convention,
+    # seq_0, seq_1, etc.
+    seq1 = new_moltype.DNA.make_seq(seq="AC")
+    seq2 = new_moltype.DNA.make_seq(seq="AC")
+    coll = mk_cls([seq1, seq2], moltype="dna")
+    assert isinstance(coll, new_alignment.SequenceCollection)
+    assert coll.names == ["seq_0", "seq_1"]
+
+    # if the sequences have names, they should be used
     seq1 = new_moltype.DNA.make_seq(seq="AC", name="seq1")
     seq2 = new_moltype.DNA.make_seq(seq="AC", name="seq2")
-    coll = new_alignment.make_unaligned_seqs([seq1, seq2], moltype="dna")
+    coll = mk_cls([seq1, seq2], moltype="dna")
+    assert isinstance(coll, new_alignment.SequenceCollection)
+    assert coll.names == ["seq1", "seq2"]
+
+    # if the data dict has different names to the seq names,
+    # the names from data should be used
+    coll = mk_cls({"s1": seq1, "s2": seq2}, moltype="dna")
     assert isinstance(coll, new_alignment.SequenceCollection)
+    assert coll.names == ["s1", "s2"]
+    assert coll.get_seq("s1") == seq1
+
+
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+@pytest.mark.parametrize(
+    "seq_name, parent_name", [("seq_1", "parent_1"), ("seq_2", "parent_2")]
+)
+def test_make_seqs_renamed_seqs(mk_cls, seq_name, parent_name, dna_alphabet):
+    # the parent_name should persist from sequence object to SequenceCollection
+    seq_view_1 = new_sequence.SeqView(
+        parent="AAAA", parent_len=4, seqid="parent_1", alphabet=dna_alphabet
+    )
+    seq_view_2 = new_sequence.SeqView(
+        parent="TTTT", parent_len=4, seqid="parent_2", alphabet=dna_alphabet
+    )
+
+    seq_1 = new_moltype.DNA.make_seq(seq=seq_view_1, name="seq_1")
+    seq_2 = new_moltype.DNA.make_seq(seq=seq_view_2, name="seq_2")
+
+    seqs = mk_cls([seq_1, seq_2], moltype="dna")
+    assert set(seqs.names) == {"seq_1", "seq_2"}
+    assert seqs.get_seq(seq_name)._seq.seqid == parent_name
+
+
+@pytest.mark.parametrize(
+    "mk_cls, data_cls",
+    [
+        (new_alignment.make_unaligned_seqs, new_alignment.SeqsData),
+        (new_alignment.make_aligned_seqs, new_alignment.AlignedSeqsData),
+    ],
+)
+@pytest.mark.parametrize("seq", ("a", "b"))
+def test_make_seqs_offset(mk_cls, data_cls, seq, dna_alphabet):
+    """SequenceCollection and Alignment constructor functions should handle
+    offset argument"""
+    data = {"a": "AGGCCC", "b": "AGAAAA"}
+    offset = {"a": 1, "b": 2}
+    seqs = mk_cls(data, moltype="dna", offset=offset)
+    got = seqs.get_seq(seq)
+    assert got._seq.offset == offset[seq]
+
+    # if data is a SeqsData object, this should fail
+    data = data_cls.from_seqs(data=data, alphabet=new_moltype.DNA.degen_gapped_alphabet)
+    with pytest.raises(ValueError):
+        _ = mk_cls(data, moltype="dna", offset=offset)
+
+    # if provided with sequence objects with offsets, they should be propogated
+    seq_view_1 = new_sequence.SeqView(
+        parent="AAAA", parent_len=4, alphabet=dna_alphabet, offset=1
+    )
+    seq_view_2 = new_sequence.SeqView(
+        parent="TTTT", parent_len=4, alphabet=dna_alphabet, offset=2
+    )
+    seq_1 = new_moltype.DNA.make_seq(seq=seq_view_1, name="seq_1")
+    seq_2 = new_moltype.DNA.make_seq(seq=seq_view_2, name="seq_2")
+
+    seqs = mk_cls([seq_1, seq_2], moltype="dna")
+
+    got = seqs.get_seq("seq_1")
+    assert got._seq.offset == 1
+
+    got = seqs.get_seq("seq_2")
+    assert got._seq.offset == 2
+
+
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_make_seqs_invalid_chars(mk_cls):
+    data = {"seq1": "AGT1CCT", "seq2": "AGT$CCC"}
+    with pytest.raises(new_alphabet.AlphabetError):
+        mk_cls(data, moltype="dna")
+
+
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_sequence_collection_names_is_list(mk_cls):
+    """expected to be a list"""
+    seqs = mk_cls({"a": b"AAAAA", "b": b"TTTTT"}, moltype="dna")
+    assert isinstance(seqs.names, list)
+    assert seqs.names == ["a", "b"]
 
 
 def test_sequence_collection_init_ordered(ordered1, ordered2):
@@ -597,29 +706,41 @@ def test_sequence_collection_init_ordered(ordered1, ordered2):
     assert sec.names == ["c", "a"]
 
 
-def test_sequence_collection_info_source():
+@pytest.mark.parametrize("load_cls", [load_aligned_seqs, load_unaligned_seqs])
+def test_sequence_collection_info_source(load_cls):
     """info.source exists if load seqs given a filename"""
     path = pathlib.Path("data/brca1.fasta")
-    seqs = load_unaligned_seqs(path, moltype="dna", new_type=True)
+    seqs = load_cls(path, moltype="dna", new_type=True)
     assert seqs.info.source == str(path)
 
 
-def test_sequence_collection_init_ambig():
-    """SequenceCollection should tolerate ambiguous chars"""
-    _ = new_alignment.make_unaligned_seqs(["AAA", "CCC"], moltype="dna")
-    _ = new_alignment.make_unaligned_seqs(["ANS", "CWC"], moltype="dna")
-    _ = new_alignment.make_unaligned_seqs(["A-A", "CC-"], moltype="dna")
-    _ = new_alignment.make_unaligned_seqs(["A?A", "CC-"], moltype="dna")
+@pytest.mark.parametrize(
+    "collection_maker",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_sequence_collection_init_ambig(collection_maker):
+    """SequenceCollection and Alignment should tolerate ambiguous chars"""
+    _ = collection_maker({"s0": "AAA", "s1": "CCC"}, moltype="dna")
+    _ = collection_maker({"s0": "ANS", "s1": "CWC"}, moltype="dna")
+    _ = collection_maker({"s0": "A-A", "s1": "CC-"}, moltype="dna")
+    _ = collection_maker({"s0": "A?A", "s1": "CC-"}, moltype="dna")
 
 
-def test_sequence_collection_iter_seqs_ragged_padded(ragged_padded):
+@pytest.mark.parametrize(
+    "collection_maker",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_sequence_collection_iter_seqs_ragged_padded(
+    ragged_padded_dict, collection_maker
+):
     """SequenceCollection.iter_seqs() method should support reordering of seqs"""
-    seqs = list(ragged_padded.iter_seqs())
-    assert seqs == ["AAAAAA", "AAA---", "AAAA--"]
-    seqs = list(ragged_padded.iter_seqs(seq_order=["b", "a", "a"]))
-    assert seqs == ["AAA---", "AAAAAA", "AAAAAA"]
-    assert seqs[1] == seqs[2]
-    assert seqs[0] == ragged_padded.seqs["b"]
+    coll = collection_maker(ragged_padded_dict, moltype="dna")
+    seqs = list(coll.iter_seqs())
+    assert list(map(str, seqs)) == ["AAAAAA", "AAA---", "AAAA--"]
+    seqs = list(coll.iter_seqs(seq_order=["b", "a", "a"]))
+    assert list(map(str, seqs)) == ["AAA---", "AAAAAA", "AAAAAA"]
+    assert str(seqs[1]) == str(seqs[2])
+    assert str(seqs[0]) == str(coll.seqs["b"])
 
 
 def test_sequence_collection_iter_seqs_ragged(ragged):
@@ -632,6 +753,14 @@ def test_sequence_collection_iter_seqs_ragged(ragged):
     assert seqs[0] == ragged.seqs["b"]
 
 
+def test_sequence_collection_iter_seqs_renamed(ragged_padded_dict):
+    """SequenceCollection iter_seqs() method should support renaming of seqs"""
+    coll = new_alignment.make_unaligned_seqs(ragged_padded_dict, moltype="dna")
+    renamed = coll.rename_seqs(renamer=lambda x: f"seq_{x}")
+    seqs = list(renamed.iter_seqs())
+    assert seqs == ["AAAAAA", "AAA---", "AAAA--"]
+
+
 def test_sequence_collection_repr():
     data = {
         "ENSMUSG00000056468": "GCCAGGGGGAAAAGGGAGAA",
@@ -697,17 +826,15 @@ def test_sequence_collection_repr_html_applies_policy(seqs):
     seqs.set_repr_policy(num_seqs=3)
     got = seqs._repr_html_()
     assert got.count("</tr>") == 4
-    seqs.set_repr_policy(num_seqs=len(seqs.seqs))
+    seqs.set_repr_policy(num_seqs=seqs.num_seqs)
     got = seqs._repr_html_()
-    assert got.count("</tr>") == len(seqs.seqs) + 1
+    assert got.count("</tr>") == seqs.num_seqs + 1
 
 
 def test_sequence_collection_repr_html_correct_num_seqs(seqs):
     # tests _repr_html_ displays correct number of sequences
     got = seqs._repr_html_()
-    seq_lens = numpy.array(
-        [len(seqs.seqs.get_seq_str(seqid=name)) for name in seqs.names]
-    )
+    seq_lens = numpy.array([len(seqs.seqs[name]) for name in seqs.names])
     assert (
         f"{seqs.num_seqs} x {{min={seq_lens.min()}, median={numpy.median(seq_lens)}, max={seq_lens.max()}}}"
         in got.splitlines()[-2]
@@ -746,38 +873,62 @@ def test_sequence_collection_set_repr_policy_valid_input(seqs):
     assert seqs._repr_policy == dict(num_seqs=5, num_pos=40, ref_name="seq1", wrap=10)
 
 
-def test_sequence_collection_take_seqs(ragged_padded):
-    """SequenceCollection take_seqs should return new SequenceCollection with selected seqs."""
-    a = ragged_padded.take_seqs(["b", "c"])
-    assert isinstance(a, new_alignment.SequenceCollection)
-    assert a.names == ["b", "c"]
-    assert str(a.seqs["b"]) == "AAA---"
-    assert str(a.seqs["c"]) == "AAAA--"
-    assert a.num_seqs == 2
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+@pytest.mark.parametrize(
+    "sample", [["a"], ["b"], ["c"], ["a", "b"], ["a", "c"], ["b", "c"]]
+)
+def test_sequence_collection_take_seqs(ragged_padded_dict, mk_cls, sample):
+    """take_seqs should return new SequenceCollection/Alignment with selected seqs."""
+    orig = mk_cls(ragged_padded_dict, moltype="dna")
+    subset = orig.take_seqs(sample)
+    assert subset.names == sample
+    assert subset.num_seqs == len(sample)
     # should be able to negate
-    a = ragged_padded.take_seqs(list("bc"), negate=True)
-    assert a.names == ["a"]
-    assert str(a.seqs["a"]) == "AAAAAA"
+    neg_subset = orig.take_seqs(sample, negate=True)
+    assert neg_subset.names == [name for name in orig.names if name not in sample]
+
+
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_take_seqs_rc(mk_cls):
+    data = {"a": "ACGT", "b": "CGTA", "c": "TTTT"}
+    orig = mk_cls(data, moltype="dna")
+    rc_seqs = orig.rc().take_seqs(["a", "b"])
+    got = rc_seqs.to_dict()
+    expect = {"a": "ACGT", "b": "TACG"}
+    assert got == expect
 
 
-def test_sequence_collection_take_seqs_str(ragged_padded):
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_take_seqs_str(ragged_padded_dict, mk_cls):
     """string arg to SequenceCollection take_seqs should work."""
-    a = ragged_padded.take_seqs("a")
-    assert a.names == ["a"]
-    assert a.num_seqs == 1
+    orig = mk_cls(ragged_padded_dict, moltype="dna")
+    subset = orig.take_seqs("a")
+    assert subset.names == ["a"]
+    assert subset.num_seqs == 1
 
     # should be able to negate
-    a = ragged_padded.take_seqs("a", negate=True)
-    assert isinstance(a, new_alignment.SequenceCollection)
-    assert a.names == ["b", "c"]
-    assert a.num_seqs == 2
+    neg_subset = orig.take_seqs("a", negate=True)
+    assert neg_subset.names == ["b", "c"]
+    assert neg_subset.num_seqs == 2
 
 
-def test_sequence_collection_take_seqs_info():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_take_seqs_info(ragged_padded_dict, mk_cls):
     """take_seqs should preserve info attribute"""
-    data = {"a": "CCCCCC", "b": "AAA---", "c": "AAAA--"}
-    orig = new_alignment.make_unaligned_seqs(
-        data,
+    orig = mk_cls(
+        ragged_padded_dict,
         moltype="dna",
         info={"key": "value"},
     )
@@ -785,27 +936,37 @@ def test_sequence_collection_take_seqs_info():
     assert set(subset.info) == set(orig.info)
 
 
-def test_sequence_collection_take_seqs_moltype():
+@pytest.mark.parametrize(
+    "collection_maker",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_take_seqs_moltype(ragged_padded_dict, collection_maker):
     """take_seqs should preserve the MolType"""
-    data = {"a": "CCCCCC", "b": "AAA---", "c": "AAAA--"}
-    orig = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    orig = collection_maker(ragged_padded_dict, moltype="dna")
     subset = orig.take_seqs(list("ab"))
     assert set(subset.moltype) == set(orig.moltype)
 
 
-def test_sequence_collection_take_seqs_empty_names():
+@pytest.mark.parametrize(
+    "collection_maker",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_take_seqs_empty_names(
+    ragged_padded_dict, collection_maker
+):
     """take_seqs should raise ValueError if no seqs are selected."""
-    data = {"a": "CCCCCC", "b": "AAA---", "c": "AAAA--"}
-    orig = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    orig = new_alignment.make_unaligned_seqs(ragged_padded_dict, 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_alignment.make_unaligned_seqs(
-        data, moltype="dna", annotation_db=gff_db
-    )
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_take_seqs_copy_annotations(gff_db, mk_cls):
+    data = {"test_seq": "ACGT--", "test_seq2": "CGTTTA"}
+    seq_coll = mk_cls(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)
@@ -851,11 +1012,8 @@ def test_sequence_collection_get_seq_names_if(ragged_padded):
 
     assert ragged_padded.get_seq_names_if(is_long) == []
     assert ragged_padded.get_seq_names_if(is_med) == ["a", "c"]
-    # return order should reflect names when updated
-    ragged_padded.names = ["b", "c", "a"]
-    assert ragged_padded.get_seq_names_if(is_med) == ["c", "a"]
     assert ragged_padded.get_seq_names_if(is_med, negate=True) == ["b"]
-    assert ragged_padded.get_seq_names_if(is_any) == ["b", "c", "a"]
+    assert ragged_padded.get_seq_names_if(is_any) == ["a", "b", "c"]
     assert ragged_padded.get_seq_names_if(is_any, negate=True) == []
 
 
@@ -896,33 +1054,190 @@ def test_sequence_collection_get_seq():
         seqs.get_seq("seqx")
 
 
-def test_sequence_collection_degap():
+@pytest.fixture
+def gap_ambig_seqs():
+    return {"s1": "ATGRY?", "s2": "T-AG??"}
+
+
+@pytest.mark.parametrize("rced", [False, True])
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_degap(mk_cls, gap_ambig_seqs, rced):
     """SequenceCollection.degap should strip gaps from each seq"""
-    data = {"s1": "ATGRY?", "s2": "T-AG??"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
-    got = seqs.degap().to_dict()
-    expect = {"s1": "ATGRY", "s2": "TAG"}
+
+    seqs = mk_cls(gap_ambig_seqs, moltype="dna")
+    # Test normal case
+    degapped = seqs.rc().degap() if rced else seqs.degap()
+    got = degapped.to_dict()
+
+    expect = {"s1": "RYCAT", "s2": "CTA"} if rced else {"s1": "ATGRY", "s2": "TAG"}
     assert got == expect
 
+    # Test empty sequences case
+    empty_seqs = mk_cls({"empty1": "", "empty2": ""}, moltype="dna")
+    empty_seqs = empty_seqs.degap()
+    got_empty = empty_seqs.to_dict()
+    expect_empty = {"empty1": "", "empty2": ""}
+    assert got_empty == expect_empty
+
 
-def test_sequence_collection_degap_info():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_degap_info(mk_cls, gap_ambig_seqs):
     """.degap should preserve info attributes"""
-    data = {"s1": "ATGRY?", "s2": "T-AG??"}
-    aln = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    aln = mk_cls(gap_ambig_seqs, moltype="dna")
     aln.info.path = "blah"
     got = aln.degap()
     assert got.info.path == "blah"
 
 
-def test_sequence_collection_to_fasta():
-    """SequenceCollection should return correct FASTA string"""
+def test_alignment_degap_sliced(gap_ambig_seqs):
+    """degap should apply slice_record to alignment"""
+    aln = new_alignment.make_aligned_seqs(gap_ambig_seqs, moltype="dna")
+    sliced = aln[:3]
+    got = sliced.degap()
+    expect = {"s1": "ATG", "s2": "TA"}
+    assert got.to_dict() == expect
+
+    # ATGRY?
+    # T-AG??
+    #  * * *
+    sliced = aln[1::2]
+    got = sliced.degap()
+    expect = {"s1": "TR", "s2": "G"}
+    assert got.to_dict() == expect
+
+    sliced = aln[::-1]
+    got = sliced.degap()
+    expect = {"s1": "YRGTA", "s2": "GAT"}
+
+
+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"
@@ -935,11 +1250,9 @@ def test_sequence_collection_is_ragged(ragged, ragged_padded):
 
 
 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",
+    assert [ragged.seqs[name] for name in ragged.names] == [
         "AAAAAA",
+        "AAA",
         "AAAA",
     ]
 
@@ -969,6 +1282,9 @@ def test_sequence_collection_to_phylip_ragged():
         align_rag.to_phylip()
 
 
+@pytest.mark.parametrize(
+    "mk_cls", [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs]
+)
 @pytest.mark.parametrize(
     "gc,seqs",
     (
@@ -977,45 +1293,65 @@ def test_sequence_collection_to_phylip_ragged():
         (2, ("GATTTT", "TCCAGG")),
     ),
 )
-def test_sequence_collection_has_terminal_stop_true(gc, seqs):
+def test_sequence_collection_has_terminal_stop_true(gc, seqs, mk_cls):
     data = {f"s{i}": s for i, s in enumerate(seqs)}
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seq_coll = mk_cls(data, moltype="dna")
     assert seq_coll.has_terminal_stop(gc=gc)
 
 
+@pytest.mark.parametrize(
+    "mk_cls", [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs]
+)
 @pytest.mark.parametrize(
     "gc,seqs",
     ((1, ("TCCTCA", "GATTTT")), (2, ("GATTTT", "TCCCGG")), (1, ("CCTCA", "ATTTT"))),
 )
-def test_sequence_collection_has_terminal_stop_false(gc, seqs):
+def test_sequence_collection_has_terminal_stop_false(gc, seqs, mk_cls):
     data = {f"s{i}": s for i, s in enumerate(seqs)}
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seq_coll = mk_cls(data, moltype="dna")
     assert not seq_coll.has_terminal_stop(gc=gc)
 
 
-def test_sequence_collection_has_terminal_stop_strict():
+@pytest.mark.parametrize(
+    "mk_cls", [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs]
+)
+def test_sequence_collection_has_terminal_stop_strict(mk_cls):
     data = {f"s{i}": s for i, s in enumerate(("CCTCA", "ATTTT"))}
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seq_coll = mk_cls(data, moltype="dna")
     with pytest.raises(new_alphabet.AlphabetError):
         seq_coll.has_terminal_stop(gc=1, strict=True)
 
 
-def test_sequence_collection_get_translation_trim_stop():
+@pytest.mark.parametrize(
+    "mk_cls,expect",
+    (
+        (new_alignment.make_unaligned_seqs, {"seq1": "DS", "seq2": "DSS"}),
+        (new_alignment.make_aligned_seqs, {"seq1": "DS-", "seq2": "DSS"}),
+    ),
+)
+def test_get_translation_trim_stop(mk_cls, expect):
     data = {"seq1": "GATTCCTAG", "seq2": "GATTCCTCC"}
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="dna")
-    got = seq_coll.get_translation(trim_stop=True)
-    expect = {"seq1": "DS", "seq2": "DSS"}
+    seqs = mk_cls(data, moltype="dna")
+    got = seqs.get_translation(trim_stop=True)
     assert got.to_dict() == expect
 
 
-def test_sequence_collection_get_translation_raises():
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_get_translation_raises(mk_cls):
     """should raise error if self.moltype is not a nucleic acid"""
     data = {"seq1": "PAR", "seq2": "PQR"}
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="protein")
-    with pytest.raises(new_alphabet.AlphabetError):
-        _ = seq_coll.get_translation(trim_stop=True)
+    seqs = mk_cls(data, moltype="protein")
+    with pytest.raises(new_moltype.MolTypeError):
+        _ = seqs.get_translation(trim_stop=True)
 
 
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
 @pytest.mark.parametrize(
     "seqs",
     (
@@ -1025,42 +1361,65 @@ def test_sequence_collection_get_translation_raises():
         {"seq1": "GATTTT", "seq2": "?GATCT"},
     ),
 )
-def test_sequence_collection_get_translation(seqs):
+def test_get_translation(seqs, mk_cls):
     """SequenceCollection.get_translation translates each seq"""
-    seq_coll = new_alignment.make_unaligned_seqs(seqs, moltype="dna")
-    got = seq_coll.get_translation(incomplete_ok=True)
+    seqs = mk_cls(seqs, moltype="dna")
+    got = seqs.get_translation(incomplete_ok=True)
     assert got.num_seqs == 2
     assert got.moltype == new_moltype.PROTEIN
 
 
-def test_sequence_collection_get_translation_with_stop():
-    data = {"seq1": "?GATCT", "seq2": "GATTAG"}
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="dna")
-    got = seq_coll.get_translation(
-        incomplete_ok=True, include_stop=True, trim_stop=False
-    )
-    assert got.to_dict() == {"seq1": "XS", "seq2": "D*"}
-    assert got.moltype == new_moltype.PROTEIN_WITH_STOP
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_get_translation_renamed(seqs, mk_cls):
+    seqs = mk_cls({"s1": "GATTTT", "s2": "GATCTT"}, moltype="dna")
+    renamed = seqs.rename_seqs(lambda x: f"{x}_renamed")
+    got = renamed.get_translation(incomplete_ok=True)
+    assert got.names == ["s1_renamed", "s2_renamed"]
 
 
-def test_sequence_collection_get_translation_non_div_3():
-    data = {"seq1": "?GATCTA", "seq2": "GATTAGGG"}
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="dna")
-    got = seq_coll.get_translation(incomplete_ok=True, include_stop=True)
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_get_translation_with_stop(mk_cls):
+    data = {"seq1": "?GATAG", "seq2": "GATTAG"}
+    seqs = mk_cls(data, moltype="dna")
+    got = seqs.get_translation(incomplete_ok=True, include_stop=True, trim_stop=False)
+    assert got.to_dict() == {"seq1": "X*", "seq2": "D*"}
+    assert got.moltype == new_moltype.PROTEIN_WITH_STOP
+
+
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_get_translation_non_div_3(mk_cls):
+    data = {"seq1": "?GATCTA", "seq2": "GATTAGG"}
+    seqs = mk_cls(data, moltype="dna")
+    got = seqs.get_translation(incomplete_ok=True, include_stop=True)
     assert got.to_dict() == {"seq1": "XS", "seq2": "D*"}
 
 
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
 @pytest.mark.parametrize(
     "data", ({"seq1": "GATTTT", "seq2": "GATC??"}, {"seq1": "GAT---", "seq2": "?GATCT"})
 )
-def test_sequence_collection_get_translation_error(data):
-    """SequenceCollection.get_translation translates each seq"""
-    # check for a failure when no moltype specified
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="dna")
+def test_get_translation_error(data, mk_cls):
+    seqs = mk_cls(data, moltype="dna")
     with pytest.raises(TypeError):
-        seq_coll.get_translation()
+        seqs.get_translation()
 
 
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
 @pytest.mark.parametrize(
     "data",
     (
@@ -1071,23 +1430,25 @@ def test_sequence_collection_get_translation_error(data):
         {"seq1": "GAT-T-", "seq2": "GATCTT"},
     ),
 )
-def test_sequence_collection_get_translation_info(data):
+def test_get_translation_info(data, mk_cls):
     """SequenceCollection.get_translation preserves info attribute"""
-    seq_coll = new_alignment.make_unaligned_seqs(
-        data, moltype="dna", info={"key": "value"}
-    )
-    got = seq_coll.get_translation(incomplete_ok=True)
+    seqs = mk_cls(data, moltype="dna", info={"key": "value"})
+    got = seqs.get_translation(incomplete_ok=True)
     assert got.info["key"] == "value"
 
 
-def test_sequence_collection_get_translation_incomplete():
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_get_translation_incomplete(mk_cls):
     """get translation works on incomplete codons"""
     data = {"seq1": "GATN--", "seq2": "?GATCT"}
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="dna")
-    got = seq_coll.get_translation(incomplete_ok=True)
+    seqs = mk_cls(data, moltype="dna")
+    got = seqs.get_translation(incomplete_ok=True)
     assert got.to_dict() == {"seq1": "DX", "seq2": "XS"}
     with pytest.raises(new_alphabet.AlphabetError):
-        _ = seq_coll.get_translation(incomplete_ok=False)
+        _ = seqs.get_translation(incomplete_ok=False)
 
 
 @pytest.mark.parametrize(
@@ -1099,65 +1460,85 @@ def test_sequence_collection_get_translation_incomplete():
         (2, ("GATTTT", "TCCAGG")),
     ),
 )
-def test_sequence_collection_trim_stop_codons(gc, seqs):
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_trim_stop_codons(gc, seqs, mk_cls):
     data = {f"s{i}": s for i, s in enumerate(seqs)}
 
     expect = {}
     for k, v in data.items():
-        if "-" in v:
+        if "-" in v or mk_cls == new_alignment.make_aligned_seqs:
             v = re.sub("(TGA|AGG)(?=[-]*$)", "---", v)
         else:
             v = re.sub("(TGA|AGG)", "", v)
         expect[k] = v
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seqs = mk_cls(data, moltype="dna")
     got = seqs.trim_stop_codons(gc=gc).to_dict()
 
     assert got == expect
 
 
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
 @pytest.mark.parametrize(
     "gc,seqs",
     ((1, ("T-CTGC", "GATAA?")), (2, ("GATTTT", "TCCCGG")), (1, ("CCTGC", "GATAA"))),
 )
-def test_sequence_collection_trim_stop_codons_no_stop(gc, seqs):
+def test_trim_stop_codons_no_stop(gc, seqs, mk_cls):
     data = {f"s{i}": s for i, s in enumerate(seqs)}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seqs = mk_cls(data, moltype="dna")
     got = seqs.trim_stop_codons(gc=gc)
     assert got is seqs
 
 
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
 @pytest.mark.parametrize(
     "data", ({"s1": "CCTCA", "s2": "ATTTT"}, {"s1": "CCTCA-", "s2": "ATTTTA"})
 )
-def test_sequence_collection_trim_stop_codons_strict(data):
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+def test_trim_stop_codons_strict(data, mk_cls):
+    seqs = mk_cls(data, moltype="dna")
     with pytest.raises(new_alphabet.AlphabetError):
         seqs.trim_stop_codons(gc=1, strict=True)
 
 
-def test_sequence_collection_trim_stop_codons_info():
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_trim_stop_codons_info(mk_cls):
     """trim_stop_codons should preserve info attribute"""
     data = {"seq1": "ACGTAA", "seq2": "ACGACG", "seq3": "ACGCGT"}
-    seq_coll = new_alignment.make_unaligned_seqs(
+    seqs = mk_cls(
         data,
         moltype="dna",
         info={"key": "value"},
     )
-    seq_coll = seq_coll.trim_stop_codons()
-    assert seq_coll.info["key"] == "value"
+    seqs = seqs.trim_stop_codons()
+    assert seqs.info["key"] == "value"
 
 
-def test_sequence_collection_trim_stop_codons_annotation_db(gff_db):
+@pytest.mark.parametrize(
+    "mk_cls",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_trim_stop_codons_annotation_db(gff_db, mk_cls):
     """trim_stop_codons should preserve info attribute"""
     data = {"seq_1": "ACGTAA", "seq_2": "ACGACG", "seq_3": "ACGCGT"}
-    seq_coll = new_alignment.make_unaligned_seqs(
+    seqs = mk_cls(
         data,
         moltype="dna",
         info={"key": "value"},
     )
-    seq_coll.annotation_db = gff_db
-    trimmed = seq_coll.trim_stop_codons()
-    assert trimmed.annotation_db == seq_coll.annotation_db
+    seqs.annotation_db = gff_db
+    trimmed = seqs.trim_stop_codons()
+    assert trimmed.annotation_db == seqs.annotation_db
 
 
 @pytest.fixture(scope="function")
@@ -1295,15 +1676,6 @@ def test_sequence_collection_get_similar(transform):
     assert got.names == ["d"]
 
 
-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)])
-    coll = new_alignment.make_unaligned_seqs([seq], moltype="dna")
-    features = list(coll.get_features(biotype="exon"))
-    assert len(features) == 1
-
-
 def test_sequence_collection_get_motif_probs():
     data = {"a": "AC-", "b": "AC", "c": "AC"}
     aln = new_alignment.make_unaligned_seqs(data, moltype="dna")
@@ -1330,12 +1702,14 @@ def test_sequence_collection_get_motif_probs():
     aln = new_alignment.make_unaligned_seqs(data, moltype="dna")
     got = aln.get_motif_probs(pseudocount=1)
     expect = {"A": 4 / 10, "C": 4 / 10, "G": 1 / 10, "T": 1 / 10}
+    assert got == expect
 
     # pseudocount and allow gap
     data = {"a": "AC-", "b": "AC", "c": "AC"}
     aln = new_alignment.make_unaligned_seqs(data, moltype="dna")
-    got = aln.get_motif_probs(pseudocount=1)
-    expect = {"A": 4 / 11, "C": 4 / 11, "G": 1 / 11, "T": 1 / 11, "-": 1 / 11}
+    got = aln.get_motif_probs(pseudocount=1, allow_gap=True)
+    expect = {"A": 4 / 12, "C": 4 / 12, "G": 1 / 12, "T": 1 / 12, "-": 2 / 12}
+    assert got == expect
 
 
 def test_sequence_collection_get_motif_probs_protein():
@@ -1358,10 +1732,13 @@ def test_sequence_collection_get_motif_probs_protein():
     expect = {"M": 3 / 10, "V": 3 / 10, "S": 2 / 10, "P": 1 / 10, "B": 1 / 10}
 
 
-def test_sequence_collection_counts_per_seq():
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_sequence_collection_counts_per_seq(mk_cls):
     """SequenceCollection.counts_per_seq handles motif length, allow_gaps etc.."""
     data = {"a": "AAAA??????", "b": "CCCGGG--NN", "c": "CCGGTTCCAA"}
-    coll = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    coll = mk_cls(data, moltype="dna")
     mtype = coll.moltype
     got = coll.counts_per_seq()
     assert got["a", "A"] == 4
@@ -1385,9 +1762,46 @@ def test_sequence_collection_counts_per_seq():
         assert c[k] == expect[k]
 
 
-def test_sequence_collection_probs_per_seq():
+def test_counts_per_seq_text_moltype():
+    """produce correct counts per seq with text moltypes"""
+    data = {"a": "AAAA??????", "b": "CCCGGG--NN", "c": "CCGGTTCCAA"}
+    coll = new_alignment.make_aligned_seqs(data, moltype="text")
+    got = coll.counts_per_seq(include_ambiguity=True, allow_gap=True)
+    assert got.col_sum()["-"] == 2
+    assert got.col_sum()["?"] == 6
+    assert got.col_sum()["T"] == 2
+
+
+def test_counts_per_pos_text_moltype():
+    """produce correct counts per pos with default moltypes"""
+    data = {"a": "AAAA??????", "b": "CCCGGG--NN", "c": "CCGGTTCCAA"}
+    coll = new_alignment.make_aligned_seqs(data, moltype="text")
+    got = coll.counts_per_pos()
+    # should not include gap character
+    assert "-" not in got.motifs
+    # allowing gaps
+    got = coll.counts_per_pos(allow_gap=True)
+    # should include gap character
+    assert got[5, "-"] == 0
+    assert got[6, "-"] == 1
+
+    # now with motif-length 2
+    got = coll.counts_per_pos(motif_length=2)
+    found_motifs = set()
+    lengths = set()
+    for m in got.motifs:
+        lengths.add(len(m))
+        found_motifs.update(m)
+    assert "-" not in found_motifs
+    assert lengths == {2}
+
+
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_sequence_collection_probs_per_seq(mk_cls):
     data = {"seq1": "AA??", "seq2": "CG-N", "seq3": "CGAA"}
-    coll = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    coll = mk_cls(data, moltype="dna")
     got = coll.probs_per_seq()
     assert got["seq1", "A"] == 1.0
     assert got["seq2", "C"] == 0.5
@@ -1425,64 +1839,6 @@ def test_sequence_collection_counts():
     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"""
-    data = {"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"}
-    seq_coll = new_alignment.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_alignment.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"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
-    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)])
-    db.add_feature(seqid="seq3", biotype="exon", name="annotation3", spans=[(3, 6)])
-    seqs.annotation_db = db
-    got = list(seqs.get_features())
-    assert len(got) == 3
-    assert "biotype='exon', name='annotation1', map=[3:8]/9" in str(got[0])
-    assert "biotype='exon', name='annotation2', map=[1:2]/9" in str(got[1])
-    assert "biotype='exon', name='annotation3', map=[3:6]/9" in str(got[2])
-
-    got = list(seqs.get_features(name="annotation1"))
-    assert len(got) == 1
-    assert "biotype='exon', name='annotation1', map=[3:8]/9" in str(got[0])
-
-    got = list(seqs.get_features(biotype="exon", name="annotation2"))
-    assert len(got) == 1
-    assert "biotype='exon', name='annotation2', map=[1:2]/9" in str(got[0])
-
-    got = list(seqs.get_features(name="annotation3"))
-    assert len(got) == 1
-    assert "biotype='exon', name='annotation3', map=[3:6]/9" in str(got[0])
-
-
-def test_sequence_collection_add_feature(seqs):
-    _ = seqs.add_feature(seqid="seq1", biotype="xyz", name="abc", spans=[(1, 2)])
-    assert seqs.annotation_db.num_matches() == 1
-
-    # if seqid is not in seqs, raise error
-    with pytest.raises(ValueError):
-        _ = seqs.add_feature(seqid="bad_seq", biotype="xyz", name="abc", spans=[(1, 2)])
-
-
 def test_sequence_collection_make_feature(seqs):
     data = {"seqid": "seq1", "biotype": "xyz", "name": "abc", "spans": [(1, 2)]}
     feat = seqs.make_feature(feature=data)
@@ -1527,54 +1883,6 @@ def test_sequence_collection_get_features(seqs):
         )
 
 
-def test_sequence_collection_copy_annotations(gff_db):
-    """copy_annotations copies records from annotation db"""
-    data = {"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"}
-    seq_coll = new_alignment.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()
-    seq_coll.copy_annotations(gff_db)
-    assert seq_coll.annotation_db.num_matches() == expect
-
-    # copy annotations with no current annotations
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="rna")
-    seq_coll.copy_annotations(gff_db)
-    assert seq_coll.annotation_db.num_matches() == gff_db.num_matches()
-
-
-def test_sequence_collection_copy_annotations_same_annotations(gff_db):
-    data = {"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"}
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="rna")
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="rna")
-
-    # copy annotations with the same annotation_db
-    seq_coll.annotation_db = gff_db
-    seq_coll.copy_annotations(gff_db)
-
-    assert seq_coll.annotation_db.num_matches() == gff_db.num_matches()
-
-
-def test_sequence_collection_copy_annotations_none_matching(gff_db):
-    """copy annotations should old copy annotations for matching seqids"""
-    data = {"name1": "ACGU", "name2": "CGUA", "name_3": "CCGU"}
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="rna")
-    seq_coll.add_feature(seqid="name1", biotype="xyz", name="abc", spans=[(1, 2)])
-    seq_coll.add_feature(seqid="name2", biotype="xyzzz", name="abc", spans=[(1, 2)])
-    expect = seq_coll.annotation_db.num_matches()
-    assert gff_db.num_matches() > 0
-    seq_coll.copy_annotations(gff_db)
-    assert seq_coll.annotation_db.num_matches() == expect
-
-
-def test_sequence_collection_copy_annotations_no_db(gff_db):
-    data = {"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"}
-    seq_coll = new_alignment.make_unaligned_seqs(data, moltype="rna")
-
-    seq_coll.copy_annotations(gff_db)
-    assert seq_coll.annotation_db.num_matches() == gff_db.num_matches()
-
-
 def test_sequence_collection_get_seq_annotated():
     """SequenceCollection.get_seq should return specified seq"""
 
@@ -1600,32 +1908,40 @@ def _make_seq(name):
     return seq
 
 
-def test_sequence_collection_init_seqs_have_annotations():
-    """annotations on input seqs correctly merged and propagated"""
+@pytest.mark.parametrize("rc", (True, False))
+def test_sequence_collection_init_seqs_rc(rc):
+    """the rc status of the input seqs is propagated and annotations maintained
+    when all seqs have the same rc status"""
     data = {"seq1": _make_seq("seq1"), "seq2": _make_seq("seq2")}
+    data = {k: v.rc() for k, v in data.items()} if rc else data
     seq_coll = new_alignment.make_unaligned_seqs(data, moltype="dna")
-    coll_db = seq_coll.annotation_db
-    assert len(coll_db) == 4
-    for name in seq_coll.names:
-        seq = seq_coll.get_seq(name)
-        db = seq.annotation_db
-        assert db is coll_db
-
+    assert seq_coll._is_reversed == rc
+    assert len(seq_coll.annotation_db) > 0
+    got = seq_coll.to_dict()
+    expect = {k: str(v) for k, v in data.items()}
+    assert got == expect
 
-def test_sequence_collection_add_to_seq_updates_coll():
-    """annotating a seq updates the db of the propagated"""
-    data = {
-        "x": "AACCCAAAATTTTTTGGGGGGGGGGCCCC",
-        "y": "AACCCAAAATTTTTTGGGGGGGGGGCCCC",
-    }
+    # when is_reversed is specified, it should override the rc status of the input seqs
     seq_coll = new_alignment.make_unaligned_seqs(
-        data,
-        moltype="dna",
+        data, moltype="dna", is_reversed=not rc
     )
-    x = seq_coll.get_seq("x")
-    assert len(seq_coll.annotation_db) == len(x.annotation_db) == 0
-    x.add_feature(biotype="exon", name="E1", spans=[(3, 8)])
-    assert len(seq_coll.annotation_db) == len(x.annotation_db) == 1
+    assert seq_coll._is_reversed == (not rc)
+    assert len(seq_coll.annotation_db) > 0
+    got = seq_coll.to_dict()
+    expect = {k: str(v.rc()) for k, v in data.items()}
+    assert got == expect
+
+
+def test_sequence_collection_init_seqs_mixed_rc():
+    """annotations on input seqs with mixed rc means dropping annotations, the
+    rc of the collection is set to False"""
+    data = {"seq1": _make_seq("seq1"), "seq2": _make_seq("seq2").rc()}
+    with catch_warnings():
+        filterwarnings("ignore", category=UserWarning)
+        seq_coll = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    coll_db = seq_coll.annotation_db
+    assert not len(coll_db)
+    assert not seq_coll._is_reversed
 
 
 def test_sequence_collection_copy_annotations_incompat_type_fails(seqcoll_db, seqs):
@@ -1633,18 +1949,64 @@ 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, mk_cls, seq_cls):
+    data = dict(s1="ACGAA-", s2="ACCCAA")
+    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, seq_cls)
+
+    # should also work with moltype objects
+    mt = new_moltype.get_moltype(moltype)
+    mt_seqs = seqs.to_moltype(moltype=mt)
+    got = mt_seqs.seqs["s1"]
+    assert got.moltype.label == moltype
+    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
@@ -1654,24 +2016,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)])
@@ -1681,10 +2049,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()
@@ -1693,7 +2065,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() == {
@@ -1701,9 +2073,51 @@ def test_sequence_collection_to_dna():
     }
 
 
-def test_sequence_collection_to_rna():
+def test_to_dna():
+    """alignment cast to DNA works"""
+    data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
+    aln = new_alignment.make_aligned_seqs(data, moltype="text")
+    dna = aln.to_dna()
+    assert set(dna.names) == set(aln.names)
+    assert dna.moltype.label == "dna"
+    # should fail if invalid character set
+    paln = dna.get_translation()
+    with pytest.raises(new_moltype.MolTypeError):
+        _ = paln.to_dna()
+
+
+def test_to_dna_info():
+    """alignment cast to DNA preserves info attribute"""
+    data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
+    aln = new_alignment.make_aligned_seqs(data, info={"key": "value"}, moltype="text")
+    dna = aln.to_dna()
+    assert dna.info["key"] == "value"
+
+
+def test_to_rna():
+    """alignment cast to RNA works"""
     data = {"seq1": "ACGUACGUA", "seq2": "ACCGAA---", "seq3": "ACGUACGUU"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="text")
+    aln = new_alignment.make_aligned_seqs(data, moltype="text")
+    rna = aln.to_rna()
+    assert set(rna.names) == set(aln.names)
+    assert rna.moltype.label == "rna"
+
+
+def test_to_rna_info():
+    """alignment cast to RNA preserves info attribute"""
+    data = {"seq1": "ACGUACGUA", "seq2": "ACCGAA---", "seq3": "ACGUACGUU"}
+    aln = new_alignment.make_aligned_seqs(data, info={"key": "value"}, moltype="text")
+    rna = aln.to_rna()
+    assert rna.info["key"] == "value"
+
+
+@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 = mk_cls(data, moltype="text")
 
     # convert from text to rna
     rna_seqs = seqs.to_rna()
@@ -1712,7 +2126,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() == {
@@ -1720,73 +2134,45 @@ def test_sequence_collection_to_rna():
     }
 
 
-@pytest.fixture(scope="function")
-def dotplot_seqs():
-    data = {
-        "Human": "CAGATTTGGCAGTT-",
-        "Mouse": "CAGATTCAGCAGGTG",
-        "Rat": "CAGATTCAGCAGG--G",
-    }
-    return new_alignment.make_unaligned_seqs(data, moltype="dna")
-
-
-def test_sequence_collection_dotplot(dotplot_seqs):
-    """exercising dotplot method"""
-    # with provided names
-    plot = dotplot_seqs.dotplot(name1="Human", name2="Mouse")
-    assert str(plot.seq1) != str(plot.seq2)
-
-    # without providing names
-    plot = dotplot_seqs.dotplot()
-    assert str(plot.seq1) != str(plot.seq2)
-
-    # providing only one name
-    plot = dotplot_seqs.dotplot(name1="Human")
-    assert str(plot.seq1) != str(plot.seq2)
-
-    # a collection of one sequence should make dotplot with itself
-    less_seqs = dotplot_seqs.take_seqs("Human")
-    plot = less_seqs.dotplot()
-    assert str(plot.seq1) == str(plot.seq2)
-
-    # k larger than window should raise an error
-    with pytest.raises(AssertionError):
-        dotplot_seqs.dotplot(window=5, k=11)
-
-    # names not in the collection should raise an error
-    with pytest.raises(ValueError):
-        dotplot_seqs.dotplot(name1="Human", name2="Dog")
-
-
-def test_sequence_collection_dotplot_annotated():
-    """exercising dotplot method with annotated sequences"""
-    db = GffAnnotationDb()
-    db.add_feature(seqid="Human", biotype="exon", name="fred", spans=[(10, 15)])
-
-    data = {"Human": "CAGATTTGGCAGTT-", "Mouse": "CAGATTCAGCAGGTG"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
-    seqs.annotation_db = db
-    seqs = seqs.take_seqs(["Human", "Mouse"], copy_annotations=True)
-    _ = seqs.dotplot(show_progress=False)
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_add_seqs(mk_cls):
+    data = dict(
+        [("name1", "AAA"), ("name2", "A--"), ("name3", "AAA"), ("name4", "AAA")]
+    )
+    data2 = dict([("name5", "TTT"), ("name6", "---")])
+    aln = mk_cls(data, moltype="dna", info={"key": "foo"})
+    out_aln = aln.add_seqs(data2)
+    assert len(out_aln.names) == 6
 
 
-def test_sequence_collection_add_seqs():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_add_seqs_reversed(mk_cls):
     data = dict(
-        [("name1", "AAA"), ("name2", "AAA"), ("name3", "AAA"), ("name4", "AAA")]
+        [("name1", "AAA"), ("name2", "A--"), ("name3", "AAA"), ("name4", "AAA")]
     )
-    data2 = dict([("name5", "BBB"), ("name6", "CCC")])
-    aln = new_alignment.make_unaligned_seqs(data, moltype="text", info={"key": "foo"})
+    data2 = dict([("name5", "TTT"), ("name6", "---")])
+    aln = mk_cls(data, moltype="dna", info={"key": "foo"})
     out_aln = aln.add_seqs(data2)
     assert len(out_aln.names) == 6
 
 
-def test_sequence_collection_add_seqs_duplicate_raises():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_add_seqs_duplicate_raises(mk_cls):
     """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_alignment.make_unaligned_seqs(data, moltype="text", info={"key": "foo"})
+    aln = mk_cls(data, moltype="text", info={"key": "foo"})
     with pytest.raises(ValueError):
         _ = aln.add_seqs(data2)
 
@@ -1795,22 +2181,30 @@ def test_sequence_collection_add_seqs_duplicate_raises():
     assert len(out_aln.names) == 5
 
 
-def test_sequence_collection_add_seqs_info():
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_add_seqs_info(mk_cls):
     """add_seqs should preserve info attribute"""
     data = dict(
         [("name1", "AAA"), ("name2", "AAA"), ("name3", "AAA"), ("name4", "AAA")]
     )
     data2 = dict([("name5", "BBB"), ("name6", "CCC")])
-    aln = new_alignment.make_unaligned_seqs(data, moltype="text", info={"key": "foo"})
-    aln2 = new_alignment.make_unaligned_seqs(data2, moltype="text", info={"key": "bar"})
+    aln = mk_cls(data, moltype="text", info={"key": "foo"})
+    aln2 = mk_cls(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):
+@pytest.mark.parametrize(
+    "collection_maker",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_write(collection_maker, tmp_path):
     """SequenceCollection.write should write in correct format"""
     data = {"a": "AAAA", "b": "TTTT", "c": "CCCC"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seqs = collection_maker(data, moltype="dna")
     fn = tmp_path / "seqs.fasta"
     seqs.write(fn)
     with open(fn, newline=None) as infile:
@@ -1818,28 +2212,37 @@ def test_sequence_collection_write(tmp_path):
     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_alignment.make_unaligned_seqs(data, moltype="dna")
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_sequence_collection_write_gapped(mk_cls, tmp_path):
+    data = {"a": "AAA--", "b": "TTTTT", "c": "CCCCC"}
+    seqs = mk_cls(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"
+    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
@@ -1848,10 +2251,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
 
 
@@ -1866,9 +2267,25 @@ def test_sequence_collection_pad_seqs(ragged):
     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)
+    # will pad to max sequence length if pad_length less than max length
+    padded3 = ragged.pad_seqs(pad_length=5)
+    seqs3 = list(padded3.iter_seqs(seq_order=["a", "b", "c"]))
+    assert list(map(str, seqs3)) == ["AAAAAA", "AAA---", "AAAA--"]
+
+
+def test_sequence_collection_pad_seqs_reversed():
+    mk_seq = new_moltype.DNA.make_seq
+    data = {
+        "a": mk_seq(seq="T", name="A"),
+        "b": mk_seq(seq="TCG", name="B"),
+        "c": mk_seq(seq="TTGG", name="C"),
+    }
+    ragged = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    rc = ragged.rc()
+    padded = rc.pad_seqs()
+    got = padded.to_dict()
+    expect = {"a": "A---", "b": "CGA-", "c": "CCAA"}
+    assert got == expect
 
 
 @pytest.fixture
@@ -1905,13 +2322,57 @@ 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
+    # the names should not change in the seqsdata
+    assert set(new._seqs_data.names) == set(data)
+
+
+@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 = 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"}
+    assert set(new_again.names) == expect
+    # the names should not change in the seqsdata
+    assert set(new._seqs_data.names) == set(data)
+
+
+@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 = 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():
@@ -1932,7 +2393,7 @@ def test_sequence_collection_apply_pssm():
         "ENSMUSG00000023892": "GTAACATCAGTACAGCACAG",
     }
     seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
-    max_seq_len = max(seqs.seqs.seq_lengths().values())
+    max_seq_len = max(seqs.get_lengths())
 
     scores = seqs.apply_pssm(path="data/sample.jaspar", show_progress=False)
     assert scores.shape == (len(data), max_seq_len - pwm.shape[0] + 1)
@@ -2015,54 +2476,15 @@ def test_sequence_collection_to_rich_dict():
     seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
 
     got = seqs.to_rich_dict()
-    seqs_data = {
-        "init_args": {
-            "data": {name: seqs.seqs.get_seq_str(seqid=name) for name in seqs.names},
-            "alphabet": seqs.moltype.most_degen_alphabet().to_rich_dict(),
-            "reversed_seqs": seqs.seqs.reversed,
-        },
-        "type": get_object_provenance(seqs.seqs),
-        "version": __version__,
-    }
     expect = {
-        "seqs_data": seqs_data,
+        "seqs": data,
         "type": get_object_provenance(seqs),
         "version": __version__,
         "init_args": {
             "moltype": seqs.moltype.label,
-            "names": seqs.names,
-            "info": seqs.info,
-        },
-    }
-    assert got == expect
-
-
-def test_sequence_collection_to_rich_dict_annotation_db():
-    data = {"seq1": "ACGG", "seq2": "CGCA", "seq3": "CCG-"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
-    seqs.add_feature(seqid="seq1", biotype="xyz", name="abc", spans=[(1, 2)])
-
-    got = seqs.to_rich_dict()
-    db = seqs.annotation_db.to_rich_dict()
-    seqs_data = {
-        "init_args": {
-            "data": {name: seqs.seqs.get_seq_str(seqid=name) for name in seqs.names},
-            "alphabet": seqs.moltype.most_degen_alphabet().to_rich_dict(),
-            "reversed_seqs": seqs.seqs.reversed,
-        },
-        "type": get_object_provenance(seqs.seqs),
-        "version": __version__,
-    }
-    expect = {
-        "seqs_data": seqs_data,
-        "init_args": {
-            "moltype": seqs.moltype.label,
-            "names": seqs.names,
+            "name_map": seqs._name_map,
             "info": seqs.info,
-            "annotation_db": db,
         },
-        "type": get_object_provenance(seqs),
-        "version": __version__,
     }
     assert got == expect
 
@@ -2070,23 +2492,14 @@ def test_sequence_collection_to_rich_dict_annotation_db():
 def test_sequence_collection_to_rich_dict_reversed_seqs():
     data = {"seq1": "ACGG", "seq2": "CGCA", "seq3": "CCG-"}
     seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
-    reversed_seqs = seqs.reverse_complement()
+    reversed_seqs = seqs.rc()
 
     got = reversed_seqs.to_rich_dict()
-    seqs_data = {
-        "init_args": {
-            "data": {name: seqs.seqs.get_seq_str(seqid=name) for name in seqs.names},
-            "alphabet": seqs.moltype.most_degen_alphabet().to_rich_dict(),
-            "reversed_seqs": {"seq1": True, "seq2": True, "seq3": True},
-        },
-        "type": get_object_provenance(seqs.seqs),
-        "version": __version__,
-    }
     expect = {
-        "seqs_data": seqs_data,
+        "seqs": reversed_seqs.to_dict(),
         "init_args": {
             "moltype": seqs.moltype.label,
-            "names": seqs.names,
+            "name_map": seqs._name_map,
             "info": seqs.info,
         },
         "type": get_object_provenance(seqs),
@@ -2106,12 +2519,12 @@ def test_sequence_collection_to_json():
     assert got == expect
 
 
-@pytest.mark.parametrize("reverse", (False, True))
-def test_sequence_collection_round_trip(reverse):
+@pytest.mark.parametrize("rc", (False, True))
+def test_sequence_collection_round_trip(rc):
     seq_coll = new_alignment.make_unaligned_seqs(
         {"seq1": "ACGG", "seq2": "CGCA", "seq3": "CCG-"}, moltype="dna"
     )
-    seq_coll = seq_coll.reverse_complement() if reverse else seq_coll
+    seq_coll = seq_coll.rc() if rc else seq_coll
 
     rd = seq_coll.to_rich_dict()
     got = deserialise_object(rd)
@@ -2119,12 +2532,11 @@ def test_sequence_collection_round_trip(reverse):
     assert got.to_rich_dict() == seq_coll.to_rich_dict()
 
 
-@pytest.mark.parametrize("moltype", ("dna", "rna"))
-def test_sequence_collection_distance_matrix_singleton_collection(moltype):
+def test_sequence_collection_distance_matrix_singleton_collection(dna_moltype):
     """SequenceCollection.distance_matrix() should raise error if collection
     only contains a single sequence"""
     collection = new_alignment.make_unaligned_seqs(
-        {"s1": "ACGTACGTAGTCGCG"}, moltype=moltype
+        {"s1": "ACGTACGTAGTCGCG"}, moltype=dna_moltype
     )
     with pytest.raises(ValueError):
         _ = collection.distance_matrix()
@@ -2162,15 +2574,19 @@ def test_sequence_collection_distance_matrix_raises_wrong_moltype(moltype):
 
 @pytest.mark.parametrize("moltype", ("dna", "rna"))
 def test_sequence_collection_distance_matrix_passes_correct_moltype(moltype):
-    data = {"s1": "ACGTA", "s2": "ACGTA"}
+    data = {"s1": "ACGA", "s2": "ACGA"}
     seqs = new_alignment.make_unaligned_seqs(data, moltype=moltype)
-
-    seqs.distance_matrix()
+    got = seqs.distance_matrix()
+    assert got[("s1", "s2")] == 0.0
 
 
-def test_sequence_collection_reverse_complement():
+@pytest.mark.parametrize(
+    "collection_maker",
+    (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+)
+def test_sequence_collection_reverse_complement(collection_maker):
     data = {"s1": "AACC", "s2": "GGTT"}
-    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seqs = collection_maker(data, moltype="dna")
     got = seqs.reverse_complement()
     expect = {"s1": "GGTT", "s2": "AACC"}
     assert got.to_dict() == expect
@@ -2178,3 +2594,2600 @@ def test_sequence_collection_reverse_complement():
 
     got = got.reverse_complement()
     assert got.to_dict() == data
+
+
+# Alignment tests
+
+
+@pytest.fixture
+def aligned_dict():
+    return dict(seq1="ACG--T", seq2="-CGAAT", seq3="------", seq4="--GA--")
+
+
+@pytest.fixture
+def gapped_seqs_dict():
+    return dict(
+        seq1="AA--CC",
+        seq2="--AA--",
+        seq3="-A-A-A",
+        seq4="AAA---",
+        seq5="---AAA",
+        seq6="------",
+        seq7="AAAAAA",
+    )
+
+
+@pytest.fixture
+def aligned_array_dict():
+    return dict(
+        seq1=numpy.array([1, 2, 3, 4, 4], dtype=numpy.uint8),
+        seq2=numpy.array([4, 2, 4, 3, 4], dtype=numpy.uint8),
+        seq3=numpy.array([4, 4, 4, 4, 4], dtype=numpy.uint8),
+        seq4=numpy.array([4, 4, 1, 4, 4], dtype=numpy.uint8),
+    )
+
+
+@pytest.fixture
+def aligned_seqs_data(aligned_dict, dna_alphabet):
+    return new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_dict, alphabet=dna_alphabet
+    )
+
+
+@pytest.fixture
+def gap_seqs():
+    return [
+        ("A---CTG-C", [[1, 3], [4, 4]]),
+        ("-GTAC----", [[0, 1], [4, 5]]),
+        ("---A--T--", [[0, 3], [1, 5], [2, 7]]),
+    ]
+
+
+@pytest.mark.parametrize("i", range(3))
+def test_decompose_gapped_seq_sequences(gap_seqs, i, dna_alphabet):
+    seq, gap_coords = gap_seqs[i]
+    dna = new_moltype.get_moltype("dna")
+    got_ungapped, got_map = new_alignment.decompose_gapped_seq(
+        dna.make_seq(seq=seq), alphabet=dna_alphabet
+    )
+    expect = dna_alphabet.to_indices(seq.replace("-", ""))
+    assert numpy.array_equal(got_ungapped, expect)
+    assert numpy.array_equal(got_map, gap_coords)
+
+
+@pytest.mark.parametrize("i", range(3))
+def test_decompose_gapped_seq_str(gap_seqs, i, dna_alphabet):
+    seq, gap_coords = gap_seqs[i]
+    got_ungapped, got_map = new_alignment.decompose_gapped_seq(
+        seq, alphabet=dna_alphabet
+    )
+    expect = dna_alphabet.to_indices(seq.replace("-", ""))
+    assert numpy.array_equal(got_ungapped, expect)
+    assert numpy.array_equal(got_map, gap_coords)
+
+
+def test_decompose_gapped_seq_str_all_gaps(dna_alphabet):
+    parent_seq = "-----"
+    expect_gaplen = numpy.array([len(parent_seq)])
+    got_ungap, got_map = new_alignment.decompose_gapped_seq(
+        parent_seq, alphabet=dna_alphabet
+    )
+    expect = numpy.array([])
+    assert numpy.array_equal(got_ungap, expect)
+    assert got_map[:, 1] == expect_gaplen
+
+
+def test_decompose_gapped_seq_str_no_gaps(dna_alphabet):
+    parent_seq = "ACTGC"
+    got_ungap, got_map = new_alignment.decompose_gapped_seq(
+        parent_seq, alphabet=dna_alphabet
+    )
+    expect = dna_alphabet.to_indices(parent_seq)
+    assert numpy.array_equal(got_ungap, expect)
+    assert got_map.size == 0
+
+
+def test_decompose_gapped_seq_arr_all_gaps(dna_alphabet):
+    parent_seq = dna_alphabet.to_indices("-----")
+    got_ungap, got_map = new_alignment.decompose_gapped_seq(
+        parent_seq, alphabet=dna_alphabet
+    )
+    assert got_ungap.size == 0
+    assert numpy.array_equal(got_map, numpy.array([[0, 5]]))
+
+
+def test_decompose_gapped_seq_arr_no_gaps(dna_alphabet):
+    parent_seq = dna_alphabet.to_indices("ACTGC")
+    got_ungap, got_empty_arr = new_alignment.decompose_gapped_seq(
+        parent_seq, alphabet=dna_alphabet
+    )
+    assert numpy.array_equal(got_ungap, parent_seq)
+    assert got_empty_arr.size == 0
+
+
+@pytest.mark.parametrize("i", range(3))
+def test_decompose_gapped_seq_arr(gap_seqs, i, dna_alphabet):
+    seq, gap_coords = gap_seqs[i]
+    seq = dna_alphabet.to_indices(seq)
+    got_ungapped, got_map = new_alignment.decompose_gapped_seq(
+        seq, alphabet=dna_alphabet
+    )
+    expect = seq[seq != 4]  # gap_char = 4
+    assert numpy.array_equal(got_ungapped, expect)
+    assert numpy.array_equal(got_map, gap_coords)
+
+
+@pytest.mark.parametrize("i", range(3))
+def test_decompose_gapped_seq_arr_dispatch_equal(gap_seqs, i, dna_alphabet):
+    """decompose_gapped_seq should return the same gap coords when input is a string/array/bytes"""
+    seq_str, _ = gap_seqs[i]
+    seq_array = dna_alphabet.to_indices(seq_str)
+    seq_bytes = dna_alphabet.array_to_bytes(seq_array)
+    seq_from_str, gaps_from_str = new_alignment.decompose_gapped_seq(
+        seq_str, alphabet=dna_alphabet
+    )
+    seq_from_array, gaps_from_arr = new_alignment.decompose_gapped_seq(
+        seq_array, alphabet=dna_alphabet
+    )
+    seq_from_bytes, gaps_from_bytes = new_alignment.decompose_gapped_seq(
+        seq_bytes, alphabet=dna_alphabet
+    )
+    assert numpy.array_equal(gaps_from_str, gaps_from_arr)
+    assert numpy.array_equal(gaps_from_arr, gaps_from_bytes)
+    assert numpy.array_equal(seq_from_str, seq_from_array)
+    assert numpy.array_equal(seq_from_array, seq_from_bytes)
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_aligned_str(aligned_dict, seqid, dna_moltype):
+    """str() of an Aligned instance should return gapped sequence cast to a string"""
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype=dna_moltype)
+    aligned = aln.seqs[seqid]
+    got = str(aligned)
+    expect = aligned_dict[seqid]
+    assert got == expect
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_aligned_array(aligned_dict, seqid, dna_moltype):
+    """array() of an Aligned instance should return gapped sequence as an array"""
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype=dna_moltype)
+    aligned = aln.seqs[seqid]
+    got = numpy.array(aligned)
+    expect = dna_moltype.degen_gapped_alphabet.to_indices(aligned_dict[seqid])
+    assert numpy.array_equal(got, expect)
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_aligned_bytes(aligned_dict, seqid, dna_moltype):
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype=dna_moltype)
+    aligned = aln.seqs[seqid]
+    got = bytes(aligned)
+    expect = dna_moltype.degen_gapped_alphabet.array_to_bytes(
+        dna_moltype.degen_gapped_alphabet.to_indices(aligned_dict[seqid])
+    )
+    assert got == expect
+
+
+@pytest.mark.parametrize("start", range(6))
+@pytest.mark.parametrize("stop", range(6))
+@pytest.mark.parametrize("step", range(1, 4))
+@pytest.mark.parametrize(
+    "seqid", ("seq1", "seq2", "seq3", "seq4", "seq5", "seq6", "seq7")
+)
+def test_aligned_getitem_slice(gapped_seqs_dict, seqid, start, stop, step):
+    aln = new_alignment.make_aligned_seqs(gapped_seqs_dict, moltype="dna")
+    al = aln.seqs[seqid]
+    got = al[start:stop:step]
+    expect = gapped_seqs_dict[seqid][start:stop:step]
+    assert str(got) == expect
+
+    got1 = got.gapped_seq
+    assert got1 == expect
+
+    # Aligned.seq should return the sequence without gaps
+    got2 = got.seq
+    expect = expect.replace("-", "")
+    assert got2 == expect
+
+
+@pytest.mark.parametrize(
+    "seqid", ("seq1", "seq2", "seq3", "seq4", "seq5", "seq6", "seq7")
+)
+@pytest.mark.parametrize("i", range(6))
+def test_aligned_getitem_int(gapped_seqs_dict, seqid, i):
+    aln = new_alignment.make_aligned_seqs(gapped_seqs_dict, moltype="dna")
+    al = aln.seqs[seqid]
+    assert isinstance(al, new_alignment.Aligned)
+    got = al[i]
+    expect = gapped_seqs_dict[seqid][i]
+    assert str(got) == expect
+
+
+@pytest.mark.parametrize(
+    "raw_seq,coords",
+    (("ACGGTAAAG", ((2, 4), (5, 8))), ("CCC---CCC", ((0, 3), (6, 9)))),
+)
+def test_aligned_getitem_featuremap(raw_seq, coords):
+    dna = new_moltype.get_moltype("dna")
+    im, seq = dna.make_seq(seq=raw_seq).parse_out_gaps()
+    gaps = numpy.array([im.gap_pos, im.cum_gap_lengths]).T
+    asd = new_alignment.AlignedSeqsData.from_seqs_and_gaps(
+        seqs={"seq1": numpy.array(seq)},
+        gaps={"seq1": gaps},
+        alphabet=dna.most_degen_alphabet(),
+    )
+    aln = new_alignment.make_aligned_seqs(asd, moltype=dna)
+    ia = aln.seqs["seq1"]
+    length = len(raw_seq)
+    fmap = FeatureMap(spans=[Span(s, e) for s, e in coords], parent_length=length)
+    expect = "".join(raw_seq[s:e] for s, e in fmap.get_coordinates())
+    got = ia[fmap]
+    assert str(got) == expect
+
+
+@pytest.fixture
+def aligned():
+    data = {
+        "seq1": "AAAGG--GGG-AACCCT",
+    }
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    return aln.seqs["seq1"]
+
+
+def test_aligned_getitem_featuremap_allgap(aligned):
+    fmap = FeatureMap(spans=[LostSpan(4)], parent_length=0)
+    sliced = aligned[fmap]
+    assert not sliced
+
+
+def test_aligned_getitem_featuremap_multi_spans(aligned):
+    #                      1111111
+    #            01234567890123456
+    #             ***   **    ***
+    # raw_seq = "AAAGG--GGG-AACCCT"
+    #            01234  567 890123
+    #                         1111
+
+    fmap = FeatureMap.from_locations(
+        locations=[(1, 4), (7, 9), (13, 16)], parent_length=17
+    )
+    sliced = aligned[fmap]
+    assert str(sliced) == "AAGGGCCC"
+
+
+def test_aligned_getitem_raises(gapped_seqs_dict):
+    aln = new_alignment.make_aligned_seqs(gapped_seqs_dict, moltype="dna")
+    al = aln.seqs["seq1"]
+    with pytest.raises(NotImplementedError):
+        _ = al[(9.0, 20.0)]
+
+
+@pytest.mark.parametrize(
+    "seqid", ("seq1", "seq2", "seq3", "seq4", "seq5", "seq6", "seq7")
+)
+def test_aligned_iter(seqid, gapped_seqs_dict):
+    aln = new_alignment.make_aligned_seqs(gapped_seqs_dict, moltype="dna")
+    aligned = aln.seqs[seqid]
+    for i, got in enumerate(aligned):
+        expect = gapped_seqs_dict[seqid][i]  # directly index the sequence
+        assert got == expect
+
+
+@pytest.mark.parametrize("seqid", ("seq0", "seq1", "seq2"))
+def test_aligned_seqs_data_init(seqid, gap_seqs, dna_alphabet):
+    """test initialisation of AlignedSeqsData object with dictionary of ungapped
+    sequences and dictionary of gap coordinates and cumulated gap lengths"""
+    seqs = {f"seq{i}": seq.replace("-", "") for i, (seq, _) in enumerate(gap_seqs)}
+    gaps = {
+        f"seq{i}": numpy.array(gaps, dtype=numpy.int32)
+        for i, (_, gaps) in enumerate(gap_seqs)
+    }
+    ad = new_alignment.AlignedSeqsData.from_seqs_and_gaps(
+        seqs={**seqs}, gaps=gaps, alphabet=dna_alphabet
+    )
+    got = ad.get_seq_str(seqid=seqid)
+    assert got == seqs[seqid]
+    assert numpy.array_equal(ad.get_gaps(seqid=seqid), gaps[seqid])
+
+
+@pytest.mark.parametrize(
+    "kwargs",
+    (
+        dict(offset=dict(s4=1)),
+        dict(gaps=dict(s4=numpy.array([[0, 1]]))),
+        dict(ungapped_seqs=dict(s4=numpy.array([1, 2, 3]))),
+    ),
+)
+def test_aligned_seqs_data_init_check_raises(dna_alphabet, kwargs):
+    raw = (
+        "A---CTG-C",
+        "-GTAC----",
+        "---A--T--",
+    )
+    gapped = numpy.array([dna_alphabet.to_indices(seq) for seq in raw])
+    names = "s1", "s2", "s3"
+    with pytest.raises(ValueError):
+        new_alignment.AlignedSeqsData(
+            gapped_seqs=gapped, names=names, alphabet=dna_alphabet, **kwargs
+        )
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4", "seq5"))
+@pytest.mark.parametrize("data_type", (str, numpy.array, bytes))
+def test_aligned_seqs_data_init_gapped(
+    gapped_seqs_dict, seqid, data_type, dna_alphabet, dna_moltype
+):
+    """AlignedSeqsData should handle data from gapped sequences correctly,
+    including edge cases (all gaps, no gaps, etc)"""
+
+    typed_data = {
+        name: make_typed(seq, data_type=data_type, moltype=dna_moltype)
+        for name, seq in gapped_seqs_dict.items()
+    }
+
+    seq_data = {
+        name: new_alignment.decompose_gapped_seq(seq, alphabet=dna_alphabet)[0]
+        for name, seq in typed_data.items()
+    }
+    gap_data = {
+        name: new_alignment.decompose_gapped_seq(seq, alphabet=dna_alphabet)[1]
+        for name, seq in typed_data.items()
+    }
+    asd = new_alignment.AlignedSeqsData.from_seqs_and_gaps(
+        seqs=seq_data, gaps=gap_data, alphabet=dna_alphabet
+    )
+    assert asd.align_len == 6
+    assert asd.get_gapped_seq_str(seqid=seqid) == gapped_seqs_dict[seqid]
+
+
+@pytest.mark.parametrize("data_type", (str, numpy.array, bytes))
+def test_aligned_seqs_data_unequal_seqlens_raises(data_type, dna_alphabet, dna_moltype):
+    """from_seqs for AlignedSeqsData should raise an error if sequences are of unequal length"""
+    data = dict(
+        seq1=make_typed("A-A", data_type=data_type, moltype=dna_moltype),
+        seq2=make_typed("AAAAAAA--", data_type=data_type, moltype=dna_moltype),
+    )
+    with pytest.raises(ValueError):
+        _ = new_alignment.AlignedSeqsData.from_seqs(data=data, alphabet=dna_alphabet)
+    # directly creating an AlignedSeqsData object should also raise an error
+    seq_data = {
+        name: new_alignment.decompose_gapped_seq(seq, alphabet=dna_alphabet)[0]
+        for name, seq in data.items()
+    }
+    gap_data = {
+        name: new_alignment.decompose_gapped_seq(seq, alphabet=dna_alphabet)[1]
+        for name, seq in data.items()
+    }
+    with pytest.raises(ValueError):
+        _ = new_alignment.AlignedSeqsData.from_seqs_and_gaps(
+            seqs=seq_data, gaps=gap_data, alphabet=dna_alphabet
+        )
+
+
+def test_from_seqs_and_gaps(dna_alphabet):
+    # AlignedSeqsData should be able to be constructed from sequences and gap maps
+    seqs = {"seq1": "ACCTA", "seq2": ""}
+    gaps = {"seq1": numpy.array([[0, 1]]), "seq2": numpy.array([[0, 6]])}
+    asd = new_alignment.AlignedSeqsData.from_seqs_and_gaps(
+        seqs=seqs, gaps=gaps, alphabet=dna_alphabet
+    )
+    assert asd.get_seq_str(seqid="seq1") == "ACCTA"
+    assert asd.get_gapped_seq_str(seqid="seq1") == "-ACCTA"
+    assert asd.get_seq_str(seqid="seq2") == ""
+    assert asd.get_gapped_seq_str(seqid="seq2") == "------"
+
+
+def test_from_seqs_and_gaps_diff_seq_lens_raises(dna_alphabet):
+    seqs = {"seq1": "ACCTA", "seq2": "A"}
+    gaps = {"seq1": numpy.array([[0, 1]]), "seq2": numpy.array([[0, 1]])}
+    with pytest.raises(ValueError):
+        _ = new_alignment.AlignedSeqsData.from_seqs_and_gaps(
+            seqs=seqs, gaps=gaps, alphabet=dna_alphabet
+        )
+
+
+def test_from_seqs_and_gaps_diff_keys_raises(dna_alphabet):
+    seqs = {"seq1": "ACCTA", "seq2": "A"}
+    gaps = {"seq1": numpy.array([[0, 1]]), "seq3": numpy.array([[0, 1]])}
+    with pytest.raises(ValueError):
+        _ = new_alignment.AlignedSeqsData.from_seqs_and_gaps(
+            seqs=seqs, gaps=gaps, alphabet=dna_alphabet
+        )
+
+
+@pytest.mark.parametrize("seqid, i", (("seq1", 0), ("seq2", 1), ("seq3", 2)))
+def test_from_names_and_array(dna_alphabet, seqid, i):
+    names = ["seq1", "seq2", "seq3"]
+    data = numpy.array([[0, 1, 2, 3], [3, 2, 1, 0], [4, 4, 4, 4]])
+    asd = new_alignment.AlignedSeqsData.from_names_and_array(
+        names=names, data=data, alphabet=dna_alphabet
+    )
+    assert asd.names == ("seq1", "seq2", "seq3")
+    got = asd.get_gapped_seq_array(seqid=seqid)
+    expect = data[i]
+    assert numpy.array_equal(got, expect)
+
+
+def test_from_names_and_array_empty_raises(dna_alphabet):
+    names = []
+    data = numpy.array([]).reshape(0, 0)
+
+    with pytest.raises(ValueError):
+        _ = new_alignment.AlignedSeqsData.from_names_and_array(
+            names=names, data=data, alphabet=dna_alphabet
+        )
+
+
+def test_from_names_and_array_mismatched_length(dna_alphabet):
+    names = ["seq1", "seq2"]
+    data = numpy.array([[1, 0, 1], [0, 1, 0], [1, 1, 1]])
+
+    with pytest.raises(ValueError):
+        _ = new_alignment.AlignedSeqsData.from_names_and_array(
+            names=names, data=data, alphabet=dna_alphabet
+        )
+
+
+def test_aligned_seqs_data_diff_keys_raises(dna_alphabet):
+    """AlignedSeqsData expect identical keys in seqs and gaps"""
+    seqs = dict(seq1=numpy.array([2, 1]), seq2=numpy.array([2, 0, 3, 1]))
+    gaps = dict(
+        seq1=numpy.array([[1, 3]], dtype=numpy.int32),
+        seq3=numpy.array([[0, 1]], dtype=numpy.int32),
+    )
+
+    with pytest.raises(ValueError):
+        _ = new_alignment.AlignedSeqsData.from_seqs_and_gaps(
+            seqs=seqs, gaps=gaps, alphabet=dna_alphabet
+        )
+    # assert that it would work if we indeed had the same keys
+    gaps["seq2"] = gaps.pop("seq3")
+    asd = new_alignment.AlignedSeqsData.from_seqs_and_gaps(
+        seqs=seqs, gaps=gaps, alphabet=dna_alphabet
+    )
+    got = asd.get_seq_str(seqid="seq1")
+    assert got == "AC"
+
+
+def test_aligned_seqs_data_omit_seqs_gaps_raises(dna_alphabet):
+    with pytest.raises(ValueError):
+        _ = new_alignment.AlignedSeqsData.from_seqs_and_gaps(
+            seqs={}, gaps={}, alphabet=dna_alphabet
+        )
+
+
+def test_aligned_seqs_data_names(aligned_dict, dna_alphabet):
+    got = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_dict, alphabet=dna_alphabet
+    )
+    assert isinstance(got, new_alignment.AlignedSeqsData)
+    assert got.names == ("seq1", "seq2", "seq3", "seq4")
+
+
+def test_aligned_seqs_data_len(aligned_dict, dna_alphabet):
+    got = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_dict, alphabet=dna_alphabet
+    )
+    assert len(got) == len(aligned_dict["seq1"])
+    assert got.align_len == len(aligned_dict["seq1"])
+
+
+@pytest.mark.parametrize(
+    "seqid, index", [("seq1", 0), ("seq2", 1), ("seq3", 2), ("seq4", 3)]
+)
+@pytest.mark.parametrize("moltype", ("dna_moltype", "rna_moltype"))
+def test_aligned_seqs_data_getitem(seqid, index, aligned_array_dict, moltype, request):
+    moltype = request.getfixturevalue(moltype)
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_array_dict, alphabet=moltype.degen_gapped_alphabet
+    )
+    got_with_seqid = numpy.array(ad[seqid])
+    got_with_index = numpy.array(ad[index])
+
+    expect = aligned_array_dict[seqid][aligned_array_dict[seqid] != 4]
+
+    assert numpy.array_equal(got_with_seqid, expect)
+    assert numpy.array_equal(got_with_index, expect)
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+@pytest.mark.parametrize("moltype", ("dna_moltype", "rna_moltype"))
+def test_aligned_seqs_data_get_seq_array(aligned_array_dict, seqid, moltype, request):
+    moltype = request.getfixturevalue(moltype)
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_array_dict, alphabet=moltype.degen_gapped_alphabet
+    )
+    got = ad.get_seq_array(seqid=seqid)
+    expect = aligned_array_dict[seqid]  # get original data
+    expect = expect[expect != 4]  # remove gaps
+    assert numpy.array_equal(got, expect)
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+@pytest.mark.parametrize("moltype", ("dna_moltype", "rna_moltype"))
+def test_aligned_seqs_data_get_gapped_seq_array(
+    aligned_array_dict, seqid, moltype, request
+):
+    moltype = request.getfixturevalue(moltype)
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_array_dict, alphabet=moltype.degen_gapped_alphabet
+    )
+    got = ad.get_gapped_seq_array(seqid=seqid)  # get original data
+    expect = aligned_array_dict[seqid]
+    assert numpy.array_equal(got, expect)
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+@pytest.mark.parametrize("moltype", ("dna_moltype", "rna_moltype"))
+def test_aligned_seqs_data_get_seq_str(aligned_array_dict, seqid, moltype, request):
+    moltype = request.getfixturevalue(moltype)
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_array_dict, alphabet=moltype.degen_gapped_alphabet
+    )
+    got = ad.get_seq_str(seqid=seqid)
+    expect = moltype.degen_gapped_alphabet.from_indices(aligned_array_dict[seqid])
+    expect = expect.replace("-", "")  # remove gaps
+    assert got == expect
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+@pytest.mark.parametrize("moltype", ("dna_moltype", "rna_moltype"))
+def test_aligned_seqs_data_get_gapped_seq_str(
+    aligned_array_dict, seqid, moltype, request
+):
+    moltype = request.getfixturevalue(moltype)
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_array_dict, alphabet=moltype.degen_gapped_alphabet
+    )
+    got = ad.get_gapped_seq_str(seqid=seqid)
+    expect = moltype.degen_gapped_alphabet.from_indices(aligned_array_dict[seqid])
+    assert got == expect
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+@pytest.mark.parametrize("moltype", ("dna_moltype", "rna_moltype"))
+def test_aligned_seqs_data_get_seq_bytes(aligned_array_dict, seqid, moltype, request):
+    moltype = request.getfixturevalue(moltype)
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_array_dict, alphabet=moltype.degen_gapped_alphabet
+    )
+    got = ad.get_seq_bytes(seqid=seqid)
+    raw = aligned_array_dict[seqid]
+    raw = raw[raw != 4]
+    expect = moltype.degen_gapped_alphabet.array_to_bytes(raw)
+    assert got == expect
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+@pytest.mark.parametrize("moltype", ("dna_moltype", "rna_moltype"))
+def test_aligned_seqs_data_get_gapped_seq_bytes(
+    aligned_array_dict, seqid, moltype, request
+):
+    moltype = request.getfixturevalue(moltype)
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_array_dict, alphabet=moltype.degen_gapped_alphabet
+    )
+    got = ad.get_gapped_seq_bytes(seqid=seqid)
+    raw = aligned_array_dict[seqid]
+    expect = moltype.degen_gapped_alphabet.array_to_bytes(raw)
+    assert got == expect
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_aligned_seqs_data_get_seq_length(seqid, aligned_dict, dna_alphabet):
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_dict, alphabet=dna_alphabet
+    )
+    raw = aligned_dict[seqid].replace("-", "")
+    assert ad.get_seq_length(seqid) == len(raw)
+
+
+def test_aligned_seqs_data_add_seqs(dna_alphabet):
+    data = {"seq1": "ACGT-", "seq2": "ACG-T", "seq3": "A---T"}
+    ad = new_alignment.AlignedSeqsData.from_seqs(data=data, alphabet=dna_alphabet)
+    new_data = {"seq4": "ACGTT", "seq5": "ACG--", "seq6": "-----"}
+    new_ad = ad.add_seqs(new_data)
+    assert new_ad.names == ("seq1", "seq2", "seq3", "seq4", "seq5", "seq6")
+
+
+def test_aligned_seqs_data_add_seqs_diff_lengths_raises(dna_alphabet):
+    """adding sequences of different lengths should raise an error"""
+
+    data = {"seq1": "ACGT-", "seq2": "ACG-T"}
+    ad = new_alignment.AlignedSeqsData.from_seqs(data=data, alphabet=dna_alphabet)
+    new_data = {"seq3": "AC", "seq4": "A-"}
+    with pytest.raises(ValueError):
+        _ = ad.add_seqs(new_data)
+
+    new_data = {"seq3": "AAA", "seq4": "A-"}
+    with pytest.raises(ValueError):
+        _ = ad.add_seqs(new_data)
+
+
+def test_aligned_seqs_data_add_seqs_diff_moltype_raises(dna_alphabet):
+    """adding sequences of different moltype should raise an error"""
+    data = {"seq1": "ACGT-", "seq2": "ACG-T"}  # DNA
+    ad = new_alignment.AlignedSeqsData.from_seqs(data=data, alphabet=dna_alphabet)
+    new_data = {"seq3": "ACGU-", "seq4": "ACG-U"}  # RNA
+    with pytest.raises(new_alphabet.AlphabetError):
+        _ = ad.add_seqs(new_data)
+
+
+def test_aligned_seqs_data_add_seqs_duplicate_keys_raises(dna_alphabet):
+    """AlignedSeqsData.add_seqs should raise an error if their are duplicated
+    sequence names"""
+    data = {"seq1": "ACGT-", "seq2": "ACG-T"}
+    ad = new_alignment.AlignedSeqsData.from_seqs(data=data, alphabet=dna_alphabet)
+    new_data = {"seq2": "ACGT-", "seq3": "ACT-T"}  # seq2 is duplicated
+    with pytest.raises(ValueError):
+        _ = ad.add_seqs(new_data)
+
+    # if we set force_unique_keys=False, it should not raise an error
+    new_ad = ad.add_seqs(new_data, force_unique_keys=False)
+    assert new_ad.names == ("seq1", "seq2", "seq3")
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_aligned_seqs_data_get_aligned_view(aligned_dict, seqid, dna_alphabet):
+    # str on an ADV should return the ungapped sequence
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_dict, alphabet=dna_alphabet
+    )
+    got = ad.get_view(seqid)
+    assert got.parent == ad
+    assert got.parent_len == ad.align_len
+    assert str(got) == aligned_dict[seqid].replace("-", "")
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_aligned_data_view_array(aligned_array_dict, dna_alphabet, seqid):
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_array_dict, alphabet=dna_alphabet
+    )
+    view = ad.get_view(seqid)
+    got = numpy.array(view)
+    expect = aligned_array_dict[seqid][aligned_array_dict[seqid] != 4]  # remove gaps
+    assert numpy.array_equal(got, expect)
+
+    # directly accessing .array_value property should return the same result
+    got = view.array_value
+    assert numpy.array_equal(got, expect)
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_aligned_data_view_gapped_array(aligned_array_dict, dna_alphabet, seqid):
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_array_dict, alphabet=dna_alphabet
+    )
+    view = ad.get_view(seqid)
+    got = view.gapped_array_value
+    expect = aligned_array_dict[seqid]
+    assert numpy.array_equal(got, expect)
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_aligned_data_view_str(aligned_dict, dna_alphabet, seqid):
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_dict, alphabet=dna_alphabet
+    )
+    view = ad.get_view(seqid)
+    got = str(view)
+    expect = aligned_dict[seqid].replace("-", "")
+    assert got == expect
+
+    # directly accessing .str_value property should return the same result
+    got = view.str_value
+    assert got == expect
+
+
+def test_aligned_data_view_gapped_str_value(aligned_dict, dna_alphabet):
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_dict, alphabet=dna_alphabet
+    )
+    view = ad.get_view("seq1")
+    got = view.gapped_str_value
+    expect = aligned_dict["seq1"]
+    assert got == expect
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_aligned_data_view_bytes(aligned_array_dict, dna_alphabet, seqid):
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_array_dict, alphabet=dna_alphabet
+    )
+    view = ad.get_view(seqid)
+    got = bytes(view)
+    expect = aligned_array_dict[seqid][aligned_array_dict[seqid] != 4]  # remove gaps
+    expect = dna_alphabet.array_to_bytes(expect)  # convert to bytes
+    assert numpy.array_equal(got, expect)
+
+    # directly accessing .bytes_value property should return the same result
+    got = view.bytes_value
+    assert numpy.array_equal(got, expect)
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_aligned_data_view_gapped_bytes_value(aligned_array_dict, dna_alphabet, seqid):
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_array_dict, alphabet=dna_alphabet
+    )
+    view = ad.get_view(seqid)
+    got = view.gapped_bytes_value
+    expect = dna_alphabet.array_to_bytes(aligned_array_dict[seqid])
+    assert numpy.array_equal(got, expect)
+
+
+@pytest.fixture
+def simple_aln():
+    return new_alignment.make_aligned_seqs(
+        {"a": "T-C", "b": "---", "c": "AAA"}, moltype="dna"
+    )
+
+
+def test_alignment_array_seqs(simple_aln):
+    got = simple_aln.array_seqs
+    expect = numpy.array([[0, 4, 1], [4, 4, 4], [2, 2, 2]])
+    assert numpy.array_equal(got, expect)
+
+
+def test_alignment_array_seqs_renamed(simple_aln):
+    # if the attribute ._array_seqs has been created (which happens when the
+    # property .array_seqs is accessed), it should not be recreated when the
+    # alignment is renamed
+    orig_arr_seqs = simple_aln.array_seqs
+    renamed = simple_aln.rename_seqs(renamer=lambda x: x.upper())
+    assert renamed.array_seqs is orig_arr_seqs
+
+
+def test_alignment_array_seqs_take_seqs(simple_aln):
+    """an alignment which has been subset should return the correct array_seqs"""
+    subset = simple_aln.take_seqs(["a", "c"])
+    got = subset.array_seqs
+    expect = numpy.array([[0, 4, 1], [2, 2, 2]])
+    assert numpy.array_equal(got, expect)
+
+
+def test_alignment_array_seqs_sliced(simple_aln):
+    """an alignment which has been sliced should be reflected in the array_seqs"""
+    # T-C
+    # ---
+    # AAA
+    # **
+
+    sliced = simple_aln[:2]
+    got = sliced.array_seqs
+    expect = numpy.array([[0, 4], [4, 4], [2, 2]])
+    assert numpy.array_equal(got, expect)
+
+    # T-C
+    # ---
+    # AAA
+    # * *
+    sliced = simple_aln[::2]
+    got = sliced.array_seqs
+    expect = numpy.array([[0, 1], [4, 4], [2, 2]])
+    assert numpy.array_equal(got, expect)
+
+
+def test_alignment_array_seqs_reverse_complement(simple_aln):
+    """reverse complementing an alignment should be reflected in the array_seqs"""
+    rc = simple_aln.rc()
+    got = rc.array_seqs
+    # grab the raw array data and reverse complement it
+    raw = simple_aln.to_dict(as_array=True)
+    expect = numpy.array([rc.moltype.complement(seq[::-1]) for seq in raw.values()])
+    assert numpy.array_equal(got, expect)
+
+
+def test_alignment_array_positions(simple_aln):
+    got = simple_aln.array_positions
+    expect = numpy.array([[0, 4, 2], [4, 4, 2], [1, 4, 2]])
+    assert numpy.array_equal(got, expect)
+
+
+def test_alignment_array_positions_take_positions(simple_aln):
+    """an alignment which has been subset should return the correct array_positions"""
+    subset = simple_aln.take_positions([0])
+    got = subset.array_positions
+    expect = numpy.array([[0, 4, 2]])
+    assert numpy.array_equal(got, expect)
+
+
+def test_alignment_array_positions_sliced(simple_aln):
+    """an alignment which has been sliced should be reflected in the array_positions"""
+    sliced = simple_aln[:2]
+    got = sliced.array_positions
+    expect = numpy.array([[0, 4, 2], [4, 4, 2]])
+    assert numpy.array_equal(got, expect)
+
+    sliced = simple_aln[::2]
+    expect = numpy.array([[0, 4, 2], [1, 4, 2]])
+    got = sliced.array_positions
+    assert numpy.array_equal(got, expect)
+
+
+def test_array_positions_reverse_complement(simple_aln):
+    # orig:
+    # T-C   041
+    # ---   444
+    # AAA   222
+
+    # rc:
+    # G-A   342
+    # ---   444
+    # TTT   000
+
+    # iter columns should iter over columns in rc
+
+    sliced = simple_aln[::-1]
+    got = sliced.array_positions
+    expect = numpy.array([[3, 4, 0], [4, 4, 0], [2, 4, 0]])
+    assert numpy.array_equal(got, expect)
+
+
+def test_alignment_init(aligned_dict, dna_moltype, dna_alphabet):
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_dict, alphabet=dna_alphabet
+    )
+    aln = new_alignment.Alignment(seqs_data=ad, moltype=dna_moltype)
+    assert aln.moltype == dna_moltype
+
+
+def test_make_aligned_seqs_dict(aligned_dict):
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
+    assert isinstance(aln, new_alignment.Alignment)
+    # if we index a seq, it should be an Aligned instance
+    assert isinstance(aln.seqs["seq1"], new_alignment.Aligned)
+    # if we use .get_seq, it should be a Sequence instance
+    assert isinstance(aln.get_seq("seq1"), new_sequence.Sequence)
+    assert aln.names == ["seq1", "seq2", "seq3", "seq4"]
+    assert aln.to_dict() == aligned_dict
+
+
+def test_make_aligned_seqs_aligned_seqs_data(aligned_dict, dna_alphabet):
+    aligned_seqs_data = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_dict, alphabet=dna_alphabet
+    )
+    aln = new_alignment.make_aligned_seqs(aligned_seqs_data, moltype="dna")
+    assert isinstance(aln, new_alignment.Alignment)
+    assert aln.moltype.label == "dna"
+    assert aln.names == ["seq1", "seq2", "seq3", "seq4"]
+    assert aligned_dict == aln.to_dict()
+
+
+def test_make_aligned_seqs_incompatible_moltype(aligned_dict, dna_alphabet):
+    ad = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_dict, alphabet=dna_alphabet
+    )
+    with pytest.raises(ValueError):
+        _ = new_alignment.make_aligned_seqs(ad, moltype="rna")
+
+
+def test_make_aligned_seqs_incompatible_type():
+    with pytest.raises(NotImplementedError):
+        _ = new_alignment.make_aligned_seqs("TGCA", moltype="dna")
+
+
+def test_alignment_get_gapped_seq():
+    """Alignment.get_gapped_seq should return seq, with gaps"""
+    aln = new_alignment.make_aligned_seqs(
+        {"seq1": "--TTT?", "seq2": "GATC??"}, moltype="dna"
+    )
+    got = aln.get_gapped_seq(seqname="seq1")
+    expect = "--TTT?"
+    assert got == expect
+
+
+def test_alignment_iter_positions():
+    data = {"a": "AAAAAA", "b": "AAA---", "c": "AAAA--"}
+    r = new_alignment.make_aligned_seqs({k: data[k] for k in "cb"}, moltype="dna")
+    assert list(r.iter_positions(pos_order=[5, 1, 3])) == list(
+        map(list, ["--", "AA", "A-"])
+    )
+    # reorder names
+    r = new_alignment.make_aligned_seqs(data, moltype="dna")
+    cols = list(r.iter_positions())
+    assert cols == list(map(list, ["AAA", "AAA", "AAA", "A-A", "A--", "A--"]))
+
+
+def test_alignment_to_dict(gapped_seqs_dict):
+    aln = new_alignment.make_aligned_seqs(gapped_seqs_dict, moltype="protein")
+    assert aln.to_dict() == gapped_seqs_dict
+
+
+def test_alignment_get_lengths(gapped_seqs_dict):
+    aln = new_alignment.make_aligned_seqs(gapped_seqs_dict, moltype="dna")
+    got = aln.get_lengths()
+    expect = {name: len(seq.replace("-", "")) for name, seq in gapped_seqs_dict.items()}
+    assert got == expect
+
+
+@pytest.mark.parametrize(
+    "moltype",
+    ["rna", "dna", "protein"],
+)
+def test_upac_consensus_allow_gaps(moltype):
+    aln = new_alignment.make_aligned_seqs(
+        {"s1": "ACGG", "s2": "ACGG", "s3": "-CGG"},
+        moltype=moltype,
+    )
+    # default behaviour
+    iupac = aln.iupac_consensus()
+    assert iupac == "?CGG"
+
+    # allow_gaps
+    iupac = aln.iupac_consensus(allow_gap=False)
+    assert iupac == "ACGG"
+
+
+def test_alignment_to_pretty():
+    """produce correct pretty print formatted text"""
+    seqs = {"seq1": "ACGAANGA", "seq2": "-CGAACGA", "seq3": "ATGAACGA"}
+    expect = ["seq1    ACGAANGA", "seq2    -....C..", "seq3    .T...C.."]
+
+    aln = new_alignment.make_aligned_seqs(seqs, moltype="dna")
+    got = aln.to_pretty(name_order=["seq1", "seq2", "seq3"])
+    assert got == "\n".join(expect)
+
+    got = aln.to_pretty(name_order=["seq1", "seq2", "seq3"], wrap=4)
+    expect = [
+        "seq1    ACGA",
+        "seq2    -...",
+        "seq3    .T..",
+        "",
+        "seq1    ANGA",
+        "seq2    .C..",
+        "seq3    .C..",
+    ]
+    assert got == "\n".join(expect)
+
+
+def test_alignment_to_html():
+    """produce correct html formatted text"""
+    seqs = {"seq1": "ACG", "seq2": "-CT"}
+
+    aln = new_alignment.make_aligned_seqs(seqs, moltype="dna")
+    got = aln.to_html(ref_name="longest")  # name_order=['seq1', 'seq2'])
+    # ensure balanced tags are in the txt
+    for tag in ["<style>", "</style>", "<div", "</div>", "<table>", "</table>"]:
+        assert tag in got
+
+    ref_row = (
+        '<tr><td class="label">seq1</td>'
+        '<td><span class="A_dna">A</span>'
+        '<span class="C_dna">C</span>'
+        '<span class="G_dna">G</span></td></tr>'
+    )
+    other_row = (
+        '<tr><td class="label">seq2</td>'
+        '<td><span class="ambig_dna">-</span>'
+        '<span class="C_dna">.</span>'
+        '<span class="T_dna">T</span></td></tr>'
+    )
+
+    assert ref_row in got
+    assert other_row in got
+    assert got.find(ref_row) < got.find(other_row)
+
+    # using different ref sequence
+    ref_row = (
+        '<tr><td class="label">seq2</td>'
+        '<td><span class="terminal_ambig_dna">-</span>'
+        '<span class="C_dna">C</span>'
+        '<span class="T_dna">T</span></td></tr>'
+    )
+    other_row = (
+        '<tr><td class="label">seq1</td>'
+        '<td><span class="A_dna">A</span>'
+        '<span class="C_dna">.</span>'
+        '<span class="G_dna">G</span></td></tr>'
+    )
+    got = aln.to_html(ref_name="seq2")
+    # order now changes
+    assert got.find(ref_row) < got.find(other_row)
+
+
+def test_alignment_to_html_text_moltype():
+    """exercising producing html for text moltype"""
+    seqs = {"seq1": "ACG", "seq2": "-CT"}
+
+    aln = new_alignment.make_aligned_seqs(seqs, moltype="text")
+    got = aln.to_html(ref_name="longest")
+    ref_row = (
+        '<tr><td class="label">seq1</td>'
+        '<td><span class="A_text">A</span>'
+        '<span class="C_text">C</span>'
+        '<span class="G_text">G</span></td></tr>'
+    )
+    other_row = (
+        '<tr><td class="label">seq2</td>'
+        '<td><span class="ambig_text">-</span>'
+        '<span class="C_text">.</span>'
+        '<span class="T_text">T</span></td></tr>'
+    )
+
+    assert ref_row in got
+    assert other_row in got
+
+
+def test_alignment_repr():
+    data = {
+        "ENSMUSG00000056468": "GCCAGGGGGAAAA",
+        "ENSMUSG00000039616": "GCCCTTCAAATTT",
+    }
+    seqs = new_alignment.make_aligned_seqs(data, moltype="dna")
+    assert (
+        repr(seqs)
+        == "2 x 13 dna alignment: ENSMUSG00000056468[GCCAGGGGGA...], ENSMUSG00000039616[GCCCTTCAAA...]"
+    )
+
+    data = {
+        "ENSMUSG00000039616": "GCCCTTCAAATTT",
+        "ENSMUSG00000056468": "GCCAGGGGGAAAA",
+    }
+    seqs = new_alignment.make_aligned_seqs(data, moltype="dna")
+
+    assert (
+        repr(seqs)
+        == "2 x 13 dna alignment: ENSMUSG00000039616[GCCCTTCAAA...], ENSMUSG00000056468[GCCAGGGGGA...]"
+    )
+
+    data = {
+        "a": "TCGAT",
+    }
+    seqs = new_alignment.make_aligned_seqs(data, moltype="dna")
+    assert repr(seqs) == "1 x 5 dna alignment: a[TCGAT]"
+
+    data = {
+        "a": "TCGAT" * 2,
+    }
+    seqs = new_alignment.make_aligned_seqs(data, moltype="dna")
+    assert repr(seqs) == "1 x 10 dna alignment: a[TCGATTCGAT]"
+
+    data = {
+        "a": "A" * 11,
+        "b": "B" * 11,
+        "c": "C" * 11,
+        "d": "D" * 11,
+        "e": "E" * 11,
+    }
+    seqs = new_alignment.make_aligned_seqs(data, moltype="text")
+    assert (
+        repr(seqs)
+        == "5 x 11 text alignment: a[AAAAAAAAAA...], b[BBBBBBBBBB...], c[CCCCCCCCCC...], ..."
+    )
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_alignment_getitem_slice(aligned_dict, seqid):
+    """slicing an alignment should propogate the slice to aligned instances"""
+    start = 1
+    stop = 5
+    step = 3
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
+    sliced_aln = aln[start:stop:step]
+    got = sliced_aln.get_seq(seqid)
+    expect = aligned_dict[seqid][start:stop:step].replace("-", "")
+    assert got == expect
+
+
+@pytest.mark.parametrize("index", (0, 1, 2, 3))
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_alignment_getitem_int(aligned_dict, index, seqid):
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
+    got = aln[index].get_gapped_seq(seqid)
+    expect = aligned_dict[seqid][index]
+    assert got == expect
+
+
+def test_alignment_getitem_raises(aligned_dict):
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
+    with pytest.raises(NotImplementedError):
+        _ = aln[1.0]
+
+
+@pytest.mark.parametrize("start", range(6))
+@pytest.mark.parametrize("stop", range(6))
+@pytest.mark.parametrize("step", range(1, 3))
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_alignment_slice_pos_step_gapped(aligned_dict, start, stop, step, seqid):
+    """slicing an alignment should propogate the slice to aligned instances"""
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
+    sliced_aln = aln[start:stop:step]
+    got = sliced_aln.seqs[seqid].gapped_seq
+    expect = aligned_dict[seqid][start:stop:step]
+    assert got == expect
+
+
+@pytest.mark.parametrize("start", range(6))
+@pytest.mark.parametrize("stop", range(6))
+@pytest.mark.parametrize("step", range(1, 3))
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_alignment_slice_pos_step_ungapped(aligned_dict, start, stop, step, seqid):
+    """slicing an alignment should propogate the slice to aligned instances"""
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
+    sliced_aln = aln[start:stop:step]
+    got = sliced_aln.seqs[seqid].seq
+    expect = aligned_dict[seqid][start:stop:step].replace("-", "")
+    assert got == expect
+
+
+@pytest.mark.parametrize("start", range(6))
+@pytest.mark.parametrize("stop", range(6))
+@pytest.mark.parametrize("step", range(-3, 0))
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_alignment_slice_neg_step_gapped(aligned_dict, start, stop, step, seqid):
+    """slicing an alignment should propogate the slice to aligned instances"""
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
+    sliced_aln = aln[start:stop:step]
+    got = sliced_aln.seqs[seqid].data.gapped_str_value
+    expect = aligned_dict[seqid][start:stop:step]
+    assert got == expect
+
+
+@pytest.mark.parametrize("start", range(6))
+@pytest.mark.parametrize("stop", range(6))
+@pytest.mark.parametrize("step", range(-3, 0))
+@pytest.mark.parametrize("seqid", ("seq1", "seq2", "seq3", "seq4"))
+def test_alignment_slice_neg_step_ungapped(aligned_dict, start, stop, step, seqid):
+    """slicing an alignment should propogate the slice to aligned instances"""
+    aln = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
+    dna = aln.moltype
+    sliced_aln = aln[start:stop:step]
+    got = sliced_aln.seqs[seqid].seq
+    expect = dna.complement(aligned_dict[seqid][start:stop:step].replace("-", ""))
+    assert got == expect
+
+
+def test_slice_align():
+    """slicing alignment should work correctly"""
+    data = {"seq1": "ACGACGACG", "seq2": "ACGACGACG", "seq3": "ACGACGACG"}
+    alignment = new_alignment.make_aligned_seqs(data, moltype="dna")
+    sub_align = alignment[2:5]
+    assert isinstance(sub_align, new_alignment.Alignment)
+    expect = {"seq1": "GAC", "seq2": "GAC", "seq3": "GAC"}
+    assert sub_align.to_dict() == expect
+    # slice third positions
+    sub_align = alignment[2::3]
+    expect = {"seq1": "GGG", "seq2": "GGG", "seq3": "GGG"}
+    assert sub_align.to_dict() == expect
+
+
+def test_slice_align_info():
+    """slicing alignment preserves info attribute"""
+    data = {"seq1": "ACGACGACG", "seq2": "ACGACGACG", "seq3": "ACGACGACG"}
+    alignment = new_alignment.make_aligned_seqs(
+        data, info={"key": "value"}, moltype="dna"
+    )
+    sub_align = alignment[2:5]
+    assert len(sub_align) == 3
+    assert sub_align.info["key"] == "value"
+
+
+@pytest.mark.parametrize("method", ("ic_score", "cogent3_score", "sp_score"))
+def test_alignment_quality_methods(method):
+    data = {
+        "DogFaced": "TG----AATATGT------GAAAGAG",
+        "FreeTaile": "TTGAAGAATATGT------GAAAGAG",
+        "LittleBro": "CTGAAGAACCTGTGAAAGTGAAAGAG",
+    }
+    expected_score = dict(
+        cogent3_score=-123.0, ic_score=32.93032499, sp_score=-25.25687109
+    )[method]
+    aln = new_alignment.make_aligned_seqs(
+        data,
+        moltype="dna",
+        info=dict(align_params=dict(lnL=-123.0)),
+    )
+    app = get_app(method)
+    score = app(aln)
+    assert numpy.allclose(score, expected_score)
+
+
+def test_alignment_quality():
+    """check alignment method correctly invokes underlying app"""
+    aln = new_alignment.make_aligned_seqs(
+        ["AAAC", "ACGC", "AGCC", "A-TC"], moltype="dna"
+    )
+    got = aln.alignment_quality(equifreq_mprobs=False)
+    expect = (
+        2 * numpy.log2(1 / 0.4)
+        + numpy.log2(1 / (4 * 0.4))
+        + (1 / 2) * numpy.log2(1 / (8 / 15))
+        + (1 / 4) * numpy.log2(1 / (4 / 15))
+    )
+    assert numpy.all(got == expect)
+
+
+def test_variable_positions():
+    """correctly identify variable positions"""
+    new_seqs = {"A": "-CG-C", "B": "ACAA?", "C": "GCGAC"}
+    aln = new_alignment.make_aligned_seqs(new_seqs, moltype="dna")
+    assert aln.variable_positions(include_gap_motif=True) == (0, 2, 3, 4)
+    assert aln.variable_positions(include_gap_motif=False) == (0, 2)
+    new_seqs = {"A": "GCGAC", "B": "GCGAC", "C": "GCGAC"}
+    aln = new_alignment.make_aligned_seqs(new_seqs, moltype="dna")
+    assert aln.variable_positions(include_gap_motif=True) == ()
+    assert aln.variable_positions(include_gap_motif=False) == ()
+    new_seqs = {"A": "-CG?C-", "B": "ACAAYA", "C": "GCGACA"}
+    aln = new_alignment.make_aligned_seqs(new_seqs, moltype="dna")
+    assert aln.variable_positions(include_gap_motif=False, include_ambiguity=True) == (
+        0,
+        2,
+        3,
+        4,
+    )
+    assert aln.variable_positions(include_gap_motif=True, include_ambiguity=True) == (
+        0,
+        2,
+        3,
+        4,
+        5,
+    )
+
+
+def test_variable_positions_motif_length():
+    """correctly identify variable positions"""
+    #                 *  - ?
+    new_seqs = {"A": "-CG-CA", "B": "ACGACA", "C": "GCGACY"}
+    aln = new_alignment.make_aligned_seqs(new_seqs, moltype="dna")
+    # only the first dinucleotide is variable if gaps and ambigs disallowed
+    assert aln.variable_positions(
+        motif_length=2, include_gap_motif=False, include_ambiguity=False
+    ) == (0, 1)
+    # if gaps are allowed, the second dinucleotide is also variable
+    assert aln.variable_positions(
+        motif_length=2, include_gap_motif=True, include_ambiguity=False
+    ) == (0, 1, 2, 3)
+    # if no gaps but ambig allowed, the third dinucleotide is also variable
+    assert aln.variable_positions(
+        motif_length=2, include_gap_motif=False, include_ambiguity=True
+    ) == (0, 1, 4, 5)
+    # if gaps and ambig allowed all are variable
+    assert aln.variable_positions(
+        motif_length=2, include_gap_motif=True, include_ambiguity=True
+    ) == (0, 1, 2, 3, 4, 5)
+    # variable remains modulo motif_length
+    new_seqs = {"A": "-CG-CAA", "B": "ACGACAC", "C": "GCGACYG"}
+    aln = new_alignment.make_aligned_seqs(new_seqs, moltype="dna")
+    assert aln.variable_positions(
+        motif_length=2, include_gap_motif=True, include_ambiguity=True
+    ) == (0, 1, 2, 3, 4, 5)
+
+
+def test_alignment_counts_per_pos():
+    """correctly count motifs"""
+    exp = numpy.array(
+        [
+            [1, 1, 1, 0],
+            [0, 2, 0, 1],
+            [0, 0, 3, 0],
+            [1, 1, 0, 1],
+            [0, 0, 3, 0],
+            [1, 1, 0, 1],
+        ]
+    )
+
+    exp_gap = numpy.array(
+        [
+            [1, 1, 0, 1, 0],
+            [0, 2, 0, 0, 1],
+            [0, 0, 3, 0, 0],
+            [0, 2, 0, 1, 0],
+            [0, 1, 2, 0, 0],
+            [0, 2, 0, 1, 0],
+        ]
+    )
+
+    aln = new_alignment.make_aligned_seqs(
+        {"s1": "TCAGAG", "s2": "CCACAC", "s3": "AGATAT"}, moltype="dna"
+    )
+    obs = aln.counts_per_pos()
+    assert numpy.array_equal(obs.array, exp)
+    assert numpy.array_equal(obs.motifs, tuple(aln.moltype.alphabet))
+    obs = aln.counts_per_pos(motif_length=2)
+    assert numpy.array_equal(obs[0, "TC"], 1)
+    assert numpy.array_equal(obs[1, "AC"], 1)
+    assert numpy.array_equal(obs[2, "AC"], 1)
+    aln = new_alignment.make_aligned_seqs(
+        {"s1": "TCAGAG", "s2": "CCACAC", "s4": "G-ACCC"}, moltype="dna"
+    )
+    obs = aln.counts_per_pos(allow_gap=True)
+    assert numpy.array_equal(obs.array, exp_gap)
+    aln = new_alignment.make_aligned_seqs(
+        {"s1": "-RAT", "s2": "ACCT", "s3": "GTGT"}, moltype="dna"
+    )
+    c = aln.counts_per_pos(include_ambiguity=False, allow_gap=True)
+    assert numpy.array_equal(set(c.motifs), set("ACGT-"))
+
+
+def test_alignment_get_gap_array():
+    data = {
+        "DogFaced": "TG-",
+        "FreeTaile": "T-G",
+        "LittleBro": "---",
+        "BigBro": "GTT",
+        "MiddleBro": "-T-",
+    }
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    got = aln.get_gap_array()
+    expect = numpy.array(
+        [
+            [False, False, True],
+            [False, True, False],
+            [True, True, True],
+            [False, False, False],
+            [True, False, True],
+        ],
+        dtype=bool,
+    )
+    assert numpy.allclose(got, expect)
+
+    # if we slice the alignment, the gap array should reflect the slice
+    sliced = aln[:2]
+    got = sliced.get_gap_array()
+    expect = expect[:, :2]
+    assert numpy.allclose(got, expect)
+
+
+def test_get_position_indices():
+    """get_position_indices should return names of cols where f(col)"""
+
+    def gap_1st(x):
+        return x[0] == "-"
+
+    def gap_2nd(x):
+        return x[1] == "-"
+
+    def gap_3rd(x):
+        return x[2] == "-"
+
+    def is_list(x):
+        return isinstance(x, list)
+
+    gaps = new_alignment.make_aligned_seqs(
+        {"a": "AAAAAAA", "b": "A--A-AA", "c": "AA-----"}, moltype="dna"
+    )
+
+    assert gaps.get_position_indices(gap_1st) == []
+    assert gaps.get_position_indices(gap_2nd) == [1, 2, 4]
+    assert gaps.get_position_indices(gap_3rd) == [2, 3, 4, 5, 6]
+    assert gaps.get_position_indices(is_list) == [0, 1, 2, 3, 4, 5, 6]
+    # should be able to negate
+    assert gaps.get_position_indices(gap_2nd, negate=True) == [0, 3, 5, 6]
+    assert gaps.get_position_indices(gap_1st, negate=True) == [0, 1, 2, 3, 4, 5, 6]
+    assert gaps.get_position_indices(is_list, negate=True) == []
+
+
+def test_iupac_consensus_rna():
+    """Alignment iupac_consensus should use RNA IUPAC symbols correctly"""
+    aln = new_alignment.make_aligned_seqs(
+        {
+            "seq1": "UCAGN-UCAGN-UCAGN-UCAGAGCAUN-",
+            "seq2": "UUCCAAGGNN--UUCCAAGGNNAGCAG--",
+            "seq3": "UUCCAAGGNN--UUCCAAGGNNAGCUA--",
+            "seq4": "UUUUCCCCAAAAGGGGNNNN--AGCUA--",
+            "seq5": "UUUUCCCCAAAAGGGGNNNN--AGCUA--",
+        },
+        moltype="rna",
+    )
+
+    # following IUPAC consensus calculated by hand
+    assert aln.iupac_consensus() == "UYHBN?BSNN??KBVSN?NN??AGCWD?-"
+
+
+def test_iupac_consensus_dna():
+    """Alignment iupac_consensus should use DNA IUPAC symbols correctly"""
+    aln = new_alignment.make_aligned_seqs(
+        {
+            "seq1": "TCAGN-TCAGN-TCAGN-TCAGAGCATN-",
+            "seq2": "TTCCAAGGNN--TTCCAAGGNNAGCAG--",
+            "seq3": "TTCCAAGGNN--TTCCAAGGNNAGCTA--",
+            "seq4": "TTTTCCCCAAAAGGGGNNNN--AGCTA--",
+            "seq5": "TTTTCCCCAAAAGGGGNNNN--AGCTA--",
+        },
+        moltype="dna",
+    )
+    # following IUPAC consensus calculated by hand
+    assert aln.iupac_consensus() == "TYHBN?BSNN??KBVSN?NN??AGCWD?-"
+
+
+def test_iupac_consensus_protein():
+    """Alignment iupac_consensus should use protein IUPAC symbols correctly"""
+    aln = new_alignment.make_aligned_seqs(
+        {
+            "seq1": "ACDEFGHIKLMNPQRSTUVWY-",
+            "seq2": "ACDEFGHIKLMNPQRSUUVWF-",
+            "seq3": "ACDEFGHIKLMNPERSKUVWC-",
+            "seq4": "ACNEFGHIKLMNPQRS-UVWP-",
+        },
+        moltype="protein",
+    )
+    # following IUPAC consensus calculated by hand
+    # Test all uppper
+    assert aln.iupac_consensus() == "ACBEFGHIKLMNPZRS?UVWX-"
+
+
+def test_majority_consensus():
+    """Alignment.majority_consensus should return commonest symbol per column"""
+    # Check the exact strings expected from string transform
+    aln = new_alignment.make_aligned_seqs(
+        {
+            "seq1": "ACG",
+            "seq2": "ACG",
+            "seq3": "TTT",
+        },
+        moltype="dna",
+    )
+    assert aln.majority_consensus() == "ACG"
+
+
+def test_probs_per_pos():
+    """Alignment.probs_per_pos should find Pr(symbol) in each
+    column"""
+    # 4 seqs (easy to calculate probabilities)
+    align = new_alignment.make_aligned_seqs(
+        {"seq1": "AAA", "seq2": "ACA", "seq3": "GGG", "seq4": "GUC"}, moltype="rna"
+    )
+    got = align.probs_per_pos()
+    # check that the column probs match the counts we expect
+    expect = [
+        {"A": 0.5, "G": 0.5},
+        {"A": 0.25, "C": 0.25, "G": 0.25, "U": 0.25},
+        {"A": 0.5, "G": 0.25, "C": 0.25},
+    ]
+    for pos, probs in enumerate(expect):
+        for char, prob in probs.items():
+            assert numpy.allclose(got[pos, char], prob)
+
+
+def test_entropy_per_pos():
+    """SequenceCollection.entropy_per_pos should match hand-calculated values"""
+    aln = new_alignment.make_aligned_seqs({"seq1": "ATA", "seq2": "AAA"}, moltype="dna")
+    obs = aln.entropy_per_pos()
+    assert numpy.allclose(obs, [0, 1, 0])
+    # check what happens with only one input sequence
+    aln = new_alignment.make_aligned_seqs({"seq1": "TGC"}, moltype="dna")
+    obs = aln.entropy_per_pos()
+    assert numpy.allclose(obs, [0, 0, 0])
+
+
+@pytest.mark.parametrize(
+    "coll_maker", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_entropy_excluding_unobserved(coll_maker):
+    """omitting unobserved motifs should not affect entropy calculation"""
+    a = coll_maker(dict(a="ACAGGG", b="AGACCC", c="GGCCTA"), moltype="dna")
+    entropy_excluded = a.entropy_per_seq(exclude_unobserved=True)
+    entropy_unexcluded = a.entropy_per_seq(exclude_unobserved=False)
+    assert numpy.allclose(entropy_excluded, entropy_unexcluded)
+
+
+def test_seq_entropy_just_gaps():
+    """get_seq_entropy should get entropy of each seq"""
+    aln = new_alignment.make_aligned_seqs(dict(a="A---", b="----"), moltype="dna")
+    got = aln.entropy_per_seq()
+    expect = numpy.array([0, numpy.nan], dtype=numpy.float64)
+
+    assert numpy.allclose(got, expect, equal_nan=True)
+
+    aln = new_alignment.make_aligned_seqs(dict(a="----", b="----"), moltype="dna")
+    entropy = aln.entropy_per_seq()
+    assert entropy is None
+
+
+def test_get_gap_array():
+    aln = new_alignment.make_aligned_seqs(
+        {"seq1": "A-GN", "seq2": "TG--", "seq3": "----"}, moltype="dna"
+    )
+    got = aln.get_gap_array()
+    expect = numpy.array(
+        [
+            [False, True, False, True],
+            [False, False, True, True],
+            [True, True, True, True],
+        ]
+    )
+    assert numpy.allclose(got, expect)
+
+    got = aln.get_gap_array(include_ambiguity=False)
+    expect = numpy.array(
+        [
+            [False, True, False, False],
+            [False, False, True, True],
+            [True, True, True, True],
+        ]
+    )
+
+
+def test_count_gaps_per_pos():
+    """correctly compute the number of gaps"""
+    data = {"a": "AAAA---GGT", "b": "CCC--GG?GT"}
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    # per position
+    got = aln.count_gaps_per_pos(include_ambiguity=False)
+    assert numpy.array_equal(got.array, [0, 0, 0, 1, 2, 1, 1, 0, 0, 0])
+    got = aln.count_gaps_per_pos(include_ambiguity=True)
+    assert numpy.array_equal(got.array, [0, 0, 0, 1, 2, 1, 1, 1, 0, 0])
+
+
+def test_count_gaps_per_seq():
+    """correctly compute the number of gaps"""
+    data = {"a": "AAAA---GGT", "b": "CCC--GG?GT"}
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    got = aln.count_gaps_per_seq(include_ambiguity=False)
+    assert numpy.array_equal(got.array, [3, 2])
+    assert numpy.array_equal(got["b"], 2)
+    got = aln.count_gaps_per_seq(include_ambiguity=True)
+    assert numpy.array_equal(got.array, [3, 3])
+    assert numpy.array_equal(got["b"], 3)
+    # per seq, unique
+    got = aln.count_gaps_per_seq(include_ambiguity=False, unique=True)
+    assert numpy.array_equal(got.array, [1, 2])
+    got = aln.count_gaps_per_seq(include_ambiguity=True, unique=True)
+    assert numpy.array_equal(got.array, [2, 2])
+
+    data = {"a": "AAAGGG", "b": "------", "c": "------"}
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    got = aln.count_gaps_per_seq(include_ambiguity=False, unique=True)
+    assert numpy.array_equal(got.array, [6, 0, 0])
+    assert numpy.array_equal(got["a"], 6)
+    assert numpy.array_equal(got["b"], 0)
+
+    # per_seq, induced_by
+    data = {"a": "--ACGT---GTAC", "b": "--ACGTA--GT--", "c": "--ACGTA-AGT--"}
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    got = aln.count_gaps_per_seq(unique=False, induced_by=True)
+    assert numpy.array_equal(got.array, [2, 1, 2])
+    assert numpy.array_equal(got["b"], 1)
+
+
+def test_omit_bad_seqs():
+    """omit_bad_seqs should return alignment w/o seqs causing most gaps"""
+    data = {
+        "s1": "---ACC---TT-",
+        "s2": "---ACC---TT-",
+        "s3": "---ACC---TT-",
+        "s4": "--AACCG-GTT-",
+        "s5": "--AACCGGGTTT",
+        "s6": "AGAACCGGGTT-",
+    }
+
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    # with defaults, excludes s6
+    expect = data.copy()
+    del expect["s6"]
+    result = aln.omit_bad_seqs()
+    assert result.to_dict() == expect
+    # with quantile 0.5, just s1, s2, s3
+    expect = data.copy()
+    for key in ("s6", "s5"):
+        del expect[key]
+    result = aln.omit_bad_seqs(0.5)
+    assert result.to_dict() == expect
+
+
+def test_matching_ref():
+    """Alignment.matching_ref returns new aln with well-aln to temp"""
+    data = {
+        "s1": "UC-----CU---C",
+        "s2": "UC------U---C",
+        "s3": "UUCCUUCUU-UUC",
+        "s4": "UU-UUUU-UUUUC",
+        "s5": "-------------",
+    }
+
+    aln = new_alignment.make_aligned_seqs(data, moltype="rna")
+    result = aln.matching_ref("s3", 0.9, 5)
+    assert result.to_dict() == {"s3": "UUCCUUCUU-UUC", "s4": "UU-UUUU-UUUUC"}
+    result2 = aln.matching_ref("s4", 0.9, 4)
+    assert result2.to_dict() == {"s3": "UUCCUUCUU-UUC", "s4": "UU-UUUU-UUUUC"}
+    result3 = aln.matching_ref("s1", 0.9, 4)
+    assert result3.to_dict() == {
+        "s2": "UC------U---C",
+        "s1": "UC-----CU---C",
+        "s5": "-------------",
+    }
+
+    result4 = aln.matching_ref("s3", 0.5, 13)
+    assert result4.to_dict() == {"s3": "UUCCUUCUU-UUC", "s4": "UU-UUUU-UUUUC"}
+
+
+def test_sliding_windows():
+    """sliding_windows should return slices of alignments."""
+    alignment = new_alignment.make_aligned_seqs(
+        {"seq1": "ACGTACGT", "seq2": "ACGTACGT", "seq3": "ACGTACGT"}, moltype="dna"
+    )
+    result = []
+
+    for bit in alignment.sliding_windows(5, 2):
+        result += [bit]
+    assert result[0].to_dict() == {"seq3": "ACGTA", "seq2": "ACGTA", "seq1": "ACGTA"}
+    assert result[1].to_dict() == {"seq3": "GTACG", "seq2": "GTACG", "seq1": "GTACG"}
+
+    result = []
+    for bit in alignment.sliding_windows(5, 1):
+        result += [bit]
+    assert result[0].to_dict() == {"seq3": "ACGTA", "seq2": "ACGTA", "seq1": "ACGTA"}
+    assert result[1].to_dict() == {"seq3": "CGTAC", "seq2": "CGTAC", "seq1": "CGTAC"}
+    assert result[2].to_dict() == {"seq3": "GTACG", "seq2": "GTACG", "seq1": "GTACG"}
+    assert result[3].to_dict() == {"seq3": "TACGT", "seq2": "TACGT", "seq1": "TACGT"}
+
+
+def test_alignment_get_feature():
+    aln = new_alignment.make_aligned_seqs(
+        {"x": "-AAAAAAAAA", "y": "TTTT--CCCT"}, moltype="dna"
+    )
+
+    db = aln.annotation_db
+    db.add_feature(seqid="y", biotype="exon", name="A", spans=[(5, 8)])
+    feat = list(aln.get_features(seqid="y", biotype="exon", on_alignment=False))[0]
+    assert feat.get_slice().to_dict() == dict(x="AAA", y="CCT")
+
+
+def test_alignment_distance_matrix():
+    """Alignment distance_matrix should produce correct scores"""
+    data = dict([("s1", "ACGTACGTA"), ("s2", "GTGTACGTA")])
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    dists = aln.distance_matrix(calc="hamming", show_progress=False)
+    assert dists == {("s1", "s2"): 2.0, ("s2", "s1"): 2.0}
+    # and for protein
+    aa = aln.get_translation()
+    dists = aa.distance_matrix(calc="hamming")
+    assert dists == {("s1", "s2"): 1.0, ("s2", "s1"): 1.0}
+
+    # when there are invalid data
+    data = dict(
+        seq1="AGGGGGGGGGGCCCCCCCCCCCCCCCCCGGGGGGGGGGGGGGGCGGTTTTTTTTTTTTTTTTTT",
+        seq2="TAAAAAAAAAAGGGGGGGGGGGGGGGGGGTTTTTTTTTTTTTTTTTTCCCCCCCCCCCCCCCCC",
+        seq3="TACAAAAAAAAGGGGCGGGGGGGGGGGGGTTTTTTTTTTTTTTTTTTCCCCCCCCCCCCCCCCC",
+    )
+
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    with pytest.raises(ArithmeticError):
+        # default settings cause an exception
+        _ = aln.distance_matrix(calc="paralinear")
+    # but setting drop_invalid=False allows calc
+    dists = aln.distance_matrix(calc="paralinear", drop_invalid=True)
+    assert dists is None
+
+
+def test_alignment_sample_with_replacement():
+    # test with replacement -- just verify that it rnus
+    alignment = new_alignment.make_aligned_seqs(
+        {"seq1": "GATC", "seq2": "GATC"}, moltype="dna"
+    )
+    sample = alignment.sample(n=100, with_replacement=True)
+    assert len(sample) == 100
+    # ensure that sampling with replacement works on single col alignment
+    alignment1 = new_alignment.make_aligned_seqs(
+        {"seq1": "A", "seq2": "A"}, moltype="dna"
+    )
+    result = alignment1.sample(with_replacement=True)
+    assert len(result) == 1
+
+
+def test_alignment_sample_tuples():
+    ##### test with motif size != 1 #####
+    alignment = new_alignment.make_aligned_seqs(
+        {
+            "seq1": "AACCDDEEFFGGHHIIKKLLMMNNPP",
+            "seq2": "AACCDDEEFFGGHHIIKKLLMMNNPP",
+        },
+        moltype="protein",
+    )
+    # ensure length correct
+    sample = alignment.sample(n=10, motif_length=2)
+    assert len(sample), 20
+    # test columns alignment preserved
+    seqs = list(sample.to_dict().values())
+    assert seqs[0] == seqs[1]
+    # ensure each char occurs twice as sampling dinucs without replacement
+    for char in seqs[0]:
+        assert seqs[0].count(char) == 2
+
+    assert all(len(set(seqs[0][i : i + 2])) == 1 for i in range(0, len(seqs[0]), 2))
+
+
+def test_alignment_sample_sliced():
+    """sample should only return characters from current view"""
+    # not a deterministic test, but has a small false positive rate of 1/1001
+    data = {"seq1": "A" * 1000 + "C"}
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    sliced = aln[-1:]
+    got = sliced.sample(n=1)
+    assert got.to_dict() == {"seq1": "C"}
+
+    rc = aln.rc()
+    sliced = rc[:1]
+    got = sliced.sample(n=1)
+    assert got.to_dict() == {"seq1": "G"}
+
+
+def test_alignment_take_positions():
+    """SequenceCollection take_positions should return new alignment w/ specified pos"""
+    gaps = new_alignment.make_aligned_seqs(
+        {"a": "AAAAAAA", "b": "A--A-AA", "c": "AA-----"}, moltype="dna"
+    )
+    assert gaps.take_positions([5, 4, 0]).to_dict() == {
+        "a": "AAA",
+        "b": "A-A",
+        "c": "--A",
+    }
+
+    # should be able to negate
+    assert gaps.take_positions([5, 4, 0], negate=True).to_dict() == {
+        "a": "AAAA",
+        "b": "--AA",
+        "c": "A---",
+    }
+
+
+def test_alignment_take_positions_sliced():
+    #       012345
+    #       -TAA--
+    #       TTAGGG
+    # slice: ***
+    # pos:    **
+
+    data = {
+        "seq1": "-TAA--",
+        "seq2": "TTAGGG",
+    }
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    sliced = aln[1:4]
+    got = sliced.take_positions([1, 2]).to_dict()
+    expect = {"seq1": "AA", "seq2": "AG"}
+    assert got == expect
+
+    rev = aln.rc()
+    got = rev.take_positions([1, 2]).to_dict()
+    expect = {"seq1": "-T", "seq2": "CC"}
+    assert got == expect
+
+
+def test_alignment_take_positions_info():
+    aln = new_alignment.make_aligned_seqs(
+        {"a": "AAAAAAA", "b": "A--A-AA", "c": "AA-----"},
+        moltype="dna",
+        info={"key": "value"},
+    )
+    tps = aln.take_positions([5, 4, 0])
+    assert tps.info["key"] == "value"
+
+
+def test_take_positions_if():
+    """take_positions_if should return cols where f(col) is True"""
+
+    gaps = new_alignment.make_aligned_seqs(
+        {"a": "AAAAAAA", "b": "A--A-AA", "c": "AA-----"}, moltype="dna"
+    )
+
+    def gap_1st(x):
+        return x[0] == "-"
+
+    def gap_2nd(x):
+        return x[1] == "-"
+
+    def gap_3rd(x):
+        return x[2] == "-"
+
+    def is_list(x):
+        return isinstance(x, list)
+
+    got = gaps.take_positions_if(gap_1st).to_dict()
+    assert got == {"a": "", "b": "", "c": ""}
+
+    got = gaps.take_positions_if(gap_2nd).to_dict()
+    assert got == {"a": "AAA", "b": "---", "c": "A--"}
+
+    got = gaps.take_positions_if(gap_3rd).to_dict()
+    assert got == {"a": "AAAAA", "b": "-A-AA", "c": "-----"}
+
+    got = gaps.take_positions_if(is_list).to_dict()
+    assert got == gaps.to_dict()
+
+    # should be able to negate
+    got = gaps.take_positions_if(gap_2nd, negate=True).to_dict()
+    assert got == {"a": "AAAA", "b": "AAAA", "c": "A---"}
+
+    got = gaps.take_positions_if(gap_3rd, negate=True).to_dict()
+    assert got == {"a": "AA", "b": "A-", "c": "AA"}
+
+
+def _make_and_filter(raw, expected, motif_length, drop_remainder):
+    # a simple filter func
+    aln = new_alignment.make_aligned_seqs(raw, moltype="dna", info={"key": "value"})
+
+    def func(x):
+        return "-" not in "".join([str(s) for s in x])
+
+    result = aln.filtered(
+        func,
+        motif_length=motif_length,
+        warn=False,
+        drop_remainder=drop_remainder,
+    )
+    assert result.to_dict() == expected
+    assert result.info["key"] == "value"
+
+
+def test_filtered():
+    """filtered should return new alignment with positions consistent with
+    provided callback function"""
+    # a simple filter option
+    raw = {"a": "ACGACGACG", "b": "CCC---CCC", "c": "AAAA--AAA"}
+    _make_and_filter(raw, {"a": "ACGACG", "b": "CCCCCC", "c": "AAAAAA"}, 1, True)
+    # check with motif_length = 2
+    _make_and_filter(raw, {"a": "ACAC", "b": "CCCC", "c": "AAAA"}, 2, True)
+    # check with motif_length = 3
+    _make_and_filter(raw, {"a": "ACGACG", "b": "CCCCCC", "c": "AAAAAA"}, 3, True)
+
+
+def test_filtered_drop_remainder():
+    """filter allows dropping"""
+    raw = {"a": "ACGACGACG", "b": "CCC---CCC", "c": "AAAA--AAA"}
+    aln = new_alignment.make_aligned_seqs(raw, moltype="dna")
+
+    def func(x):
+        return "-" not in "".join([str(s) for s in x])
+
+    got = aln.filtered(func, motif_length=1, warn=False)
+    assert len(got) == 6
+    # raises an assertion if the length is not modulo
+
+    with pytest.raises(ValueError):
+        # because alignment not modulo 2
+        got = aln.filtered(func, motif_length=2, drop_remainder=False)
+    got = aln.filtered(func, motif_length=2, drop_remainder=True, warn=False)
+    assert len(got) == 4
+
+
+def test_no_degenerates():
+    """no_degenerates correctly excludes columns containing IUPAC ambiguity codes"""
+    data = {
+        "s1": "AAA CCC GGG TTT".replace(" ", ""),
+        "s2": "CCC GGG T-T AAA".replace(" ", ""),
+        "s3": "GGR YTT AAA CCC".replace(" ", ""),
+    }
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+
+    # motif length of 1, defaults - no gaps allowed
+    result = aln.no_degenerates().to_dict()
+    expect = {
+        "s1": "AA CC GG TTT".replace(" ", ""),
+        "s2": "CC GG TT AAA".replace(" ", ""),
+        "s3": "GG TT AA CCC".replace(" ", ""),
+    }
+    assert result == expect
+
+    # allow gaps
+    result = aln.no_degenerates(allow_gap=True).to_dict()
+    expect = {
+        "s1": "AA CC GGG TTT".replace(" ", ""),
+        "s2": "CC GG T-T AAA".replace(" ", ""),
+        "s3": "GG TT AAA CCC".replace(" ", ""),
+    }
+    assert result == expect
+
+    # motif length of 3, defaults - no gaps allowed
+    result = aln.no_degenerates(motif_length=3, allow_gap=False).to_dict()
+    expect = {
+        "s1": "TTT".replace(" ", ""),
+        "s2": "AAA".replace(" ", ""),
+        "s3": "CCC".replace(" ", ""),
+    }
+    assert result == expect
+
+    # allow gaps
+    result = aln.no_degenerates(motif_length=3, allow_gap=True).to_dict()
+    expect = {
+        "s1": "GGG TTT".replace(" ", ""),
+        "s2": "T-T AAA".replace(" ", ""),
+        "s3": "AAA CCC".replace(" ", ""),
+    }
+    assert result == expect
+
+    # for length non-divisible by motif_length
+    data = {
+        "s1": "AAA CCC GGG TTT T".replace(" ", ""),
+        "s2": "CCC GGG T-T AAA A".replace(" ", ""),
+        "s3": "GGR YTT AAA CCC C".replace(" ", ""),
+    }
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    result = aln.no_degenerates(motif_length=3, allow_gap=False).to_dict()
+    expect = {
+        "s1": "TTT".replace(" ", ""),
+        "s2": "AAA".replace(" ", ""),
+        "s3": "CCC".replace(" ", ""),
+    }
+
+
+def test_no_degenerates_bad_moltype_raises():
+    """no_degenerates should raise an error if moltype has no degenerate symbols"""
+    aln = new_alignment.make_aligned_seqs({"s1": "ACGT", "s2": "ACGT"}, moltype="text")
+    with pytest.raises(new_moltype.MolTypeError):
+        _ = aln.no_degenerates()
+
+
+def test_omit_gap_pos_motif_length():
+    """consistency with different motif_length values"""
+    data = {
+        "seq1": "CAGGTCGACCTCGGC---------CACGAC",
+        "seq2": "CAGATCGACCTCGGC---------CACGAC",
+        "seq3": "CAGATCGACCTCGGT---------CACGAT",
+        "seq4": "CAGATCGACCTCGGCGAACACGGCCATGAT",
+        "seq5": "CCGATCGACATGGGC---------CACGAT",
+        "seq6": "GCC---------------------------",
+    }
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    got1 = aln.omit_gap_pos(motif_length=1)
+    got3 = aln.omit_gap_pos(motif_length=3)
+    assert len(got3) == len(got1)
+    assert got3.to_dict() == got1.to_dict()
+
+
+def test_omit_gap_pos():
+    """Alignment omit_gap_pos should return alignment w/o positions of gaps"""
+    aln = new_alignment.make_aligned_seqs(
+        {"a": "--A-BC-", "b": "-CB-A--", "c": "--D-EF-"}, moltype="protein"
+    )
+    # first, check behavior when we're just acting on the cols (and not
+    # trying to delete the naughty seqs).
+
+    # default should strip out cols that are 100% gaps
+    result = aln.omit_gap_pos()
+    assert result.to_dict() == {"a": "-ABC", "b": "CBA-", "c": "-DEF"}
+    # if allowed_gap_frac is 1, shouldn't delete anything
+    assert aln.omit_gap_pos(1).to_dict() == {
+        "a": "--A-BC-",
+        "b": "-CB-A--",
+        "c": "--D-EF-",
+    }
+
+    # if allowed_gap_frac is 0, should strip out any cols containing gaps
+    assert aln.omit_gap_pos(0).to_dict() == {"a": "AB", "b": "BA", "c": "DE"}
+    # intermediate numbers should work as expected
+    assert aln.omit_gap_pos(0.4).to_dict() == {"a": "ABC", "b": "BA-", "c": "DEF"}
+    assert aln.omit_gap_pos(0.7).to_dict() == {"a": "-ABC", "b": "CBA-", "c": "-DEF"}
+
+    # when we increase the number of sequences to 6, more differences
+    # start to appear.
+    new_aln = aln.add_seqs({"d": "-------", "e": "XYZXYZX", "f": "AB-CDEF"})
+
+    # if no gaps are allowed, we get None
+    got = new_aln.omit_gap_pos(0)
+    assert not len(got)
+
+
+def test_omit_gap_pos_no_gap_moltype():
+    """if moltype does not support gap, just returns self"""
+    aln = new_alignment.make_aligned_seqs(
+        {"a": "--A-BC-", "b": "-CB-A--", "c": "--D-EF-"}, moltype="bytes"
+    )
+    got = aln.omit_gap_pos()
+    assert got is aln
+
+
+@pytest.fixture(scope="session")
+def brca1_data():
+    return load_aligned_seqs("data/brca1.fasta", moltype="dna", new_type=True).to_dict()
+
+
+@pytest.mark.parametrize("calc", ("hamming", None))
+def test_alignment_quick_tree(calc, brca1_data):
+    """quick tree method returns tree"""
+    aln = new_alignment.make_aligned_seqs(brca1_data, moltype="dna")[:100]
+    aln = aln.take_seqs(["Human", "Rhesus", "HowlerMon", "Galago", "Mouse"])
+    kwargs = dict(show_progress=False)
+    if calc:
+        kwargs["calc"] = calc
+
+    # bootstrap
+    tree = aln.quick_tree(bootstrap=2, **kwargs)
+    assert set(tree.get_tip_names()) == set(aln.names)
+    types = {
+        type(float(edge.params["support"]))
+        for edge in tree.preorder()
+        if not edge.is_root()
+    }
+    assert types == {float}
+
+
+@pytest.fixture
+def alignment():
+    data = {
+        "seq1": "A-GT",
+        #         012
+        "seq2": "-GGT",
+    }
+    return new_alignment.make_aligned_seqs(data, moltype="dna")
+
+
+def test_aligned_view_parent_coords(alignment):
+    seqid = "seq2"
+    a1 = alignment.seqs[seqid]
+    got = a1.parent_coordinates()
+    assert got == (seqid, 0, 4, 1)
+
+    a2 = alignment[2:]
+    a1_2 = a2.seqs[seqid]
+    assert a1_2.parent_coordinates() == (seqid, 2, 4, 1)
+
+    # now getting the sequence coordinates
+    s2 = a2.get_seq(seqid)
+    expect = s2.parent_coordinates()
+    got = a1_2.parent_coordinates(seq_coords=True)
+    assert got == expect
+
+
+def test_aligned_view_parent_coords_reversed(alignment):
+    seqid = "seq2"
+    a2 = alignment[2:].rc()
+    a1_2 = a2.seqs[seqid]
+    assert a1_2.parent_coordinates() == (seqid, 2, 4, -1)
+
+    s2 = a2.get_seq(seqid)
+    expect = s2.parent_coordinates()
+    got = a1_2.parent_coordinates(seq_coords=True)
+    assert got == expect
+
+
+@pytest.mark.parametrize("rced", [True, False])
+def test_get_seq_from_slice(alignment, rced):
+    seqid = "seq2"
+    raw = str(alignment.seqs[seqid])
+    dna = alignment.moltype
+    expect = dna.rc(raw[2:]) if rced else raw[2:]
+
+    a2 = alignment[2:].rc() if rced else alignment[2:]
+    seq = a2.get_seq(seqid)
+    got = str(seq)
+    assert got == expect
+
+
+@pytest.mark.parametrize("seqid", ["seq1", "seq2"])
+def test_alignment_indexing_string(alignment, seqid):
+    # when indexing with a string, should return an Aligned instance
+    got = alignment.seqs[seqid]
+    assert isinstance(got, new_alignment.Aligned)
+    assert str(got) == alignment.to_dict()[seqid]
+
+
+@pytest.mark.parametrize("rc", [True, False])
+@pytest.mark.parametrize(
+    "func", (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs)
+)
+def test_alignment_offset_propagation(aligned_dict, func, rc):
+    # providing an offset should set the offset on precisely the specified seq
+    aln = func(aligned_dict, moltype="dna", offset={"seq1": 10})
+    seq = aln.get_seq("seq1").rc() if rc else aln.get_seq("seq1")
+    assert seq._seq.offset == 10
+    assert seq.annotation_offset == 10
+
+    seq = aln.get_seq("seq2").rc() if rc else aln.get_seq("seq2")
+    assert seq._seq.offset == 0
+    assert seq.annotation_offset == 0
+
+
+def test_alignment_offset_sliced(aligned_dict):
+    aln = new_alignment.make_aligned_seqs(
+        aligned_dict, moltype="dna", offset={"seq1": 10}
+    )
+    sliced = aln[2:]
+    seq = sliced.get_seq("seq1")
+    assert seq._seq.offset == 10
+    assert seq.annotation_offset == 12
+
+
+@pytest.mark.parametrize("name", ("s1", "s2", "s3"))
+def test_get_seq_with_sliced_aln(name):
+    seqs = {
+        "s1": "GTTGAAGTAGTAGAAGTTCCAAATAATGAA",
+        "s2": "GTG------GTAGAAGTTCCAAATAATGAA",
+        "s3": "GCTGAAGTAGTGGAAGTTGCAAAT---GAA",
+    }
+    aln = new_alignment.make_aligned_seqs(seqs, moltype="dna")
+    start, stop = 1, 5
+    a1 = aln[start:stop]
+
+    seq = a1.get_seq(name)
+    assert isinstance(seq, new_sequence.Sequence), seq
+
+    got = str(seq)
+    expect = seqs[name][start:stop].replace("-", "")
+    assert got == expect, (got, expect)
+
+
+@pytest.mark.parametrize("name", ("s1", "s2", "s3"))
+def test_get_seq_with_sliced_rced_aln(name):
+    seqs = {
+        "s1": "GTTGAAGTAGTAGAAGTTCCAAATAATGAA",
+        "s2": "GTG------GTAGAAGTTCCAAATAATGAA",
+        "s3": "GCTGAAGTAGTGGAAGTTGCAAAT---GAA",
+    }
+    aln = new_alignment.make_aligned_seqs(seqs, moltype="dna")
+    start, stop = 1, 5
+    a1 = aln[start:stop]
+    a1 = a1.rc()
+    got = str(a1.get_seq(name))
+
+    dna = new_moltype.get_moltype("dna")
+    expect = dna.complement(seqs[name][start:stop].replace("-", ""))[::-1]
+    assert got == expect, (got, expect)
+
+
+@pytest.mark.parametrize("name", ("s1", "s2", "s3"))
+def test_get_seq_with_sliced_aln_multiple_spans(name):
+    seqs = {  # the sliced seq has:
+        "s1": "GTTGA--TAGTAGAAGTTCCAAATAATGAA",  # span gap span
+        "s2": "G----TT------AAGTTCCAAATAATGAA",  # gap span gap
+        "s3": "G--GA--TA--GGAAGTTGCAAAT---GAA",  # gap span gap span gap
+    }
+    aln = new_alignment.make_aligned_seqs(seqs, moltype="dna")
+    start, stop = 1, 10
+    a1 = aln[start:stop]
+    seq = a1.get_seq(name)
+    assert isinstance(seq, new_sequence.Sequence), seq
+
+    expect = seqs[name][start:stop].replace("-", "")
+    got = str(seq)
+    assert got == expect, (got, expect)
+
+
+@pytest.mark.parametrize("name", ("s1", "s2", "s3"))
+def test_get_seq_with_sliced_rced_aln_multiple_spans(name):
+    seqs = {  # the sliced seq has:
+        "s1": "GTTGA--TAGTAGAAGTTCCAAATAATGAA",  # span gap span
+        "s2": "G----TT------AAGTTCCAAATAATGAA",  # gap span gap
+        "s3": "G--GA--TA--GGAAGTTGCAAAT---GAA",  # gap span gap span gap
+    }
+    aln = new_alignment.make_aligned_seqs(seqs, moltype="dna")
+    start, stop = 1, 10
+    a1 = aln[start:stop]
+    a1 = a1.rc()
+    got = str(a1.get_seq(name))
+    dna = new_moltype.get_moltype("dna")
+    expect = dna.complement(seqs[name][start:stop].replace("-", ""))[::-1]
+    assert got == expect, (got, expect)
+
+
+@pytest.mark.parametrize("name", ("s1", "s2", "s3"))
+def test_get_gapped_seq_with_sliced_aln(name):
+    seqs = {
+        "s1": "G-TG---TAGTAGAAGTTCCAAATAATGAA",
+        "s2": "GTG------GTAGAAGTTCCAAATAATGAA",
+        "s3": "GC--AAGTAGTGGAAGTTGCAAAT---GAA",
+    }
+    aln = new_alignment.make_aligned_seqs(seqs, moltype="dna")
+    start, stop = 1, 10
+    a1 = aln[start:stop]
+
+    seq = a1.get_gapped_seq(name)
+    assert isinstance(seq, new_sequence.Sequence), seq
+
+    got = str(seq)
+    expect = seqs[name][start:stop]
+    assert got == expect, (got, expect)
+
+
+@pytest.mark.parametrize("name", ("s1", "s2", "s3"))
+def test_aln_rev_slice(name):
+    seqs = {
+        "s1": "AAGGTTCC",
+        "s2": "AAGGTTCC",
+        "s3": "AAGGTTCC",
+    }
+
+    aln = new_alignment.make_aligned_seqs(seqs, moltype="dna")
+    got = aln[5:1]
+    assert not got
+
+    seq = got.get_gapped_seq(name)
+    assert not str(seq)
+
+    seq = got.get_seq(name)
+    assert not str(seq)
+
+
+## Tests of _IndexableSeqs
+
+
+@pytest.fixture
+def names_seqs():
+    names = ["seq1", "seq2", "seq3"]
+    seqs = ["GGGTAC", "GTTTGC", "ACGTAC"]
+    return names, seqs
+
+
+@pytest.mark.parametrize(
+    "func", (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs)
+)
+@pytest.mark.parametrize("index", (True, False))
+def test_indexing_seqs_prop(names_seqs, func, index):
+    names, seqs = names_seqs
+    raw = dict(zip(names, seqs))
+    obj = func(raw, moltype="dna")
+    got = obj.seqs[0 if index else "seq1"]
+    assert str(got) == raw["seq1"]
+
+
+def test_sequence_collection_indexing_seqs_repr(names_seqs):
+    names, seqs = names_seqs
+    raw = dict(zip(names, seqs))
+    obj = new_alignment.make_unaligned_seqs(raw, moltype="dna")
+    got = repr(obj.seqs)
+    class_name = obj.seqs[0].__class__.__name__
+    expect = f"({class_name}({seqs[0]}), + {len(names)-1} seqs)"
+    assert got == expect
+
+
+def test_alignment_indexing_seqs_repr(names_seqs):
+    names, seqs = names_seqs
+    raw = dict(zip(names, seqs))
+    obj = new_alignment.make_aligned_seqs(raw, moltype="dna")
+    got = repr(obj.seqs)
+    class_name = obj.seqs[0].__class__.__name__
+    expect = f"({class_name}(map=[]/6, data={seqs[0]}), + {len(names)-1} seqs)"
+    assert got == expect
+
+
+@pytest.mark.parametrize(
+    "func", (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs)
+)
+def test_indexing_seqs_iter(names_seqs, func):
+    names, seqs = names_seqs
+    raw = dict(zip(names, seqs))
+    obj = func(raw, moltype="dna")
+    # exercise __iter__
+    got = list(map(str, obj.seqs))
+    expect = list(seqs)
+    assert got == expect
+
+
+@pytest.mark.parametrize(
+    "gapped_seq",
+    (
+        "ACGGCTA",  # No gaps
+        "AC--GT-AGC",  # With gaps
+        "------",  # All gaps
+        # Edge cases
+        "-A-C-G-T",
+        "A-C-G-T-",
+    ),
+)
+def test_gapped_seq_round_trip(gapped_seq):
+    gapped_seq = new_moltype.DNA.gapped_alphabet.to_indices(gapped_seq)
+    # split into components
+    ungapped_seq, gaps = new_alignment.decompose_gapped_seq_array(gapped_seq, 4)
+
+    # Recreate the gapped sequence from the ungapped sequence and gaps
+    recreated_gapped_seq = new_alignment.compose_gapped_seq(ungapped_seq, gaps, 4)
+
+    # Test the output of gapped_seq_from_components against the original sequence
+    numpy.testing.assert_array_equal(recreated_gapped_seq, gapped_seq)
+
+
+def test_asd_get_gapped_seq(aligned_dict, dna_alphabet):
+    a_slice = slice(None, 4, 2)
+    seqid = "seq4"
+    orig_seq = aligned_dict[seqid][a_slice]
+    orig_array = dna_alphabet.to_indices(orig_seq)
+    orig_revd = orig_array[::-1]
+
+    asd = new_alignment.AlignedSeqsData.from_seqs(
+        data=aligned_dict, alphabet=new_moltype.DNA.most_degen_alphabet()
+    )
+    view = asd.get_view(seqid)
+    fwd = view[a_slice]
+    rev = fwd[::-1]
+    fwd_gapped = fwd.gapped_array_value
+    numpy.testing.assert_array_equal(fwd_gapped, orig_array)
+    rev_gapped = rev.gapped_array_value
+    numpy.testing.assert_array_equal(rev_gapped, orig_revd)
+
+
+def test_make_gap_filter():
+    """make_gap_filter returns f(seq) -> True if aligned ok w/ query"""
+    RNA = new_moltype.get_moltype("rna")
+    s1 = RNA.make_seq(seq="UC-----CU---C")
+    s3 = RNA.make_seq(seq="UUCCUUCUU-UUC")
+    s4 = RNA.make_seq(seq="UU-UUUU-UUUUC")
+    # check that the behavior is ok for gap runs
+    f1 = new_alignment.make_gap_filter(s1, 0.9, 5)
+    f3 = new_alignment.make_gap_filter(s3, 0.9, 5)
+    # Should return False since s1 has gap run >= 5 with respect to s3
+    assert f3(s1) == False
+    # Should return False since s3 has an insertion run >= 5 to s1
+    assert f1(s3) == False
+    # Should retun True since s4 does not have a long enough gap or ins run
+    assert f3(s4) == True
+    f3 = new_alignment.make_gap_filter(s3, 0.9, 6)
+    assert f3(s1) == True
+
+    # Check that behavior is ok for gap_fractions
+    f1 = new_alignment.make_gap_filter(s1, 0.5, 6)
+    f3 = new_alignment.make_gap_filter(s3, 0.5, 6)
+    # Should return False since 0.53% of positions are diff for gaps
+    assert f3(s1) == False
+    assert f1(s3) == False
+    assert f3(s4) == True
+
+
+@pytest.fixture(scope="session")
+def codon_and_aa_alns():
+    import cogent3
+
+    data = dict(s1="ATG --- --- GAT --- AAA", s2="ATG CAA TCG AAT GAA ATA")
+    dna = cogent3.make_aligned_seqs(
+        {n: s.replace(" ", "") for n, s in data.items()}, moltype="dna", new_type=True
+    )
+    aa = dna.get_translation()
+    return dna, aa
+
+
+def test_alignment_apply_scaled_gaps_aa_to_codon(codon_and_aa_alns):
+    codon, aa = codon_and_aa_alns
+    ungapped = codon.degap()
+    scaled = aa.apply_scaled_gaps(ungapped, aa_to_codon=True)
+    assert scaled.to_dict() == codon.to_dict()
+
+
+def test_alignment_apply_scaled_gaps_codon_to_aa(codon_and_aa_alns):
+    codon, aa = codon_and_aa_alns
+    ungapped = aa.degap()
+    scaled = codon.apply_scaled_gaps(ungapped, aa_to_codon=False)
+    assert scaled.to_dict() == aa.to_dict()
+
+
+def test_alignment_apply_scaled_gaps_invalid_seqlen(codon_and_aa_alns):
+    codon, aa = codon_and_aa_alns
+    codon = codon[:-2]
+    ungapped = codon.degap()
+    with pytest.raises(ValueError):
+        aa.apply_scaled_gaps(ungapped, aa_to_codon=True)
+
+
+def test_alignment_apply_scaled_gaps_aa2codon_invalid_moltype(codon_and_aa_alns):
+    codon, aa = codon_and_aa_alns
+    codon = codon.to_moltype("text")
+    ungapped = codon.degap()
+    with pytest.raises(ValueError):
+        aa.apply_scaled_gaps(ungapped, aa_to_codon=True)
+
+
+def test_alignment_apply_scaled_gaps_codon2aa_invalid_moltype(codon_and_aa_alns):
+    codon, aa = codon_and_aa_alns
+    ungapped = aa.degap()
+    codon = codon.to_moltype("text")
+    with pytest.raises(ValueError):
+        codon.apply_scaled_gaps(ungapped, aa_to_codon=False)
+
+
+def test_alignment_copy(simple_aln):
+    got = simple_aln.copy()
+    # mutable data structures should be different IDs
+    assert got._name_map is not simple_aln._name_map
+    assert got.info is not simple_aln.info
+    assert got.annotation_db is not simple_aln.annotation_db
+    # immutable data structures should be the same object
+    assert got._slice_record is simple_aln._slice_record
+    assert got._seqs_data is simple_aln._seqs_data
+    # sliced data should have same underlying length
+    sl = simple_aln[:2].copy()
+    assert sl._seqs_data.align_len == simple_aln._seqs_data.align_len
+
+    # renamed seqs should be propagated
+    renamed = simple_aln.rename_seqs(renamer=lambda x: x.upper())
+    copied = renamed.copy()
+    assert set(renamed.names) != set(simple_aln.names)
+    assert set(copied.names) == set(renamed.names)
+    # and can get a sequence with a new name
+    assert str(copied.seqs["A"]) == str(renamed.seqs["A"])
+
+
+def test_alignment_deepcopy(simple_aln):
+    got = simple_aln.deepcopy()
+    # all data structures should be different IDs
+    assert got._name_map is not simple_aln._name_map
+    assert got.info is not simple_aln.info
+    assert got.annotation_db is not simple_aln.annotation_db
+    assert got._slice_record is not simple_aln._slice_record
+    assert got._seqs_data is not simple_aln._seqs_data
+
+    # sliced data should have different underlying data length
+    sl = simple_aln[:2].deepcopy()
+    assert sl._seqs_data.align_len == 2 != simple_aln._seqs_data.align_len
+
+    # renamed seqs should be propagated
+    renamed = simple_aln.rename_seqs(renamer=lambda x: x.upper())
+    copied = renamed.deepcopy()
+    assert set(renamed.names) != set(simple_aln.names)
+    assert set(copied.names) == set(renamed.names)
+    # and can get a sequence with a new name
+    assert str(copied.seqs["A"]) == str(renamed.seqs["A"])
+
+
+@pytest.mark.parametrize(
+    "mk_cls",
+    [
+        new_alignment.make_unaligned_seqs,
+        new_alignment.make_aligned_seqs,
+    ],
+)
+def test_collections_equal(aligned_dict, mk_cls):
+    coll1 = mk_cls(aligned_dict, moltype="dna")
+    coll2 = mk_cls(aligned_dict, moltype="dna")
+    assert coll1 is not coll2
+    assert coll1 == coll2
+
+
+@pytest.mark.parametrize(
+    "mk_cls",
+    [
+        new_alignment.make_unaligned_seqs,
+        new_alignment.make_aligned_seqs,
+    ],
+)
+@pytest.mark.parametrize(
+    "kwargs",
+    [dict(moltype="protein"), dict(is_reversed=True), dict(name_map=dict(A="a")), {}],
+)
+def test_collections_not_equal(aligned_dict, mk_cls, kwargs):
+    coll1 = mk_cls(aligned_dict, moltype="dna")
+    aligned_dict = aligned_dict if kwargs else {**aligned_dict, **{"seq1": "TTTTTT"}}
+    kwargs = {**dict(moltype="dna"), **kwargs}
+    coll2 = mk_cls(aligned_dict, **kwargs)
+    assert coll1 != coll2
+
+
+def test_alignment_not_equal_sliced(aligned_dict):
+    coll1 = new_alignment.make_aligned_seqs(aligned_dict, moltype="dna")
+    coll2 = coll1[:2]
+    assert coll1 != coll2
+
+
+@pytest.mark.parametrize(
+    "type1,type2",
+    [
+        (new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs),
+        (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs),
+    ],
+)
+def test_alignment_not_equal_types(aligned_dict, type1, type2):
+    coll1 = type1(aligned_dict, moltype="dna")
+    coll2 = type2(aligned_dict, moltype="dna")
+    assert coll1 != coll2
+
+
+def test_alignment_to_rich_dict_sliced():
+    # slices are realised
+    data = {
+        "seq1": "ATG",
+        "seq2": "TGA",
+    }
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    sliced = aln[1:]
+    assert sliced._slice_record.parent_len == 3
+    rd = sliced.to_rich_dict()
+    got = deserialise_object(rd)
+    assert got.to_dict() == {"seq1": "TG", "seq2": "GA"}
+    assert got._slice_record.parent_len == 2
+
+
+def test_alignment_to_rich_dict_round_trip():
+    data = {
+        "seq1": "ATG",
+        "seq2": "TGA",
+    }
+    aln = new_alignment.make_aligned_seqs(data, moltype="dna")
+    rd = aln.to_rich_dict()
+    got = deserialise_object(rd)
+    assert isinstance(got, new_alignment.Alignment)
+    assert got.to_dict() == aln.to_dict()
+    assert got is not aln
+
+
+@pytest.mark.parametrize(
+    "mk_cls", [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs]
+)
+def test_alignment_to_rich_dict_round_trip_rc(mk_cls):
+    data = {
+        "seq1": "ATG",
+        "seq2": "TGA",
+    }
+    aln = mk_cls(data, moltype="dna").rc()
+    rd = aln.to_rich_dict()
+    got = deserialise_object(rd)
+    assert got.to_dict() == aln.to_dict()
+    assert got._is_reversed == False
+
+
+@pytest.mark.parametrize(
+    "mk_cls", [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs]
+)
+def test_alignment_to_rich_dict_round_trip_renamed(mk_cls):
+    # name_map is preserved
+    data = {
+        "seq1": "ATG",
+        "seq2": "TGA",
+    }
+    aln = mk_cls(data, moltype="dna")
+    renamed_aln = aln.rename_seqs(renamer=lambda x: x.upper())
+    rd = renamed_aln.to_rich_dict()
+    got = deserialise_object(rd)
+    assert got._name_map == {"SEQ1": "seq1", "SEQ2": "seq2"}
+    assert got.to_dict() == renamed_aln.to_dict()
+    assert got is not renamed_aln
+
+
+@pytest.mark.parametrize(
+    "mk_cls", [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs]
+)
+def test_alignment_to_rich_dict_round_trip_offset(mk_cls):
+    # offset is not preserved
+    data = {
+        "seq1": "ATG",
+        "seq2": "TGA",
+    }
+    offsets = {"seq1": 10, "seq2": 20}
+    aln = mk_cls(data, moltype="dna", offset=offsets)
+    rd = aln.to_rich_dict()
+    got = deserialise_object(rd)._seqs_data.offset
+    expect = {"seq1": 0, "seq2": 0}
+    assert got == expect
+
+
+@pytest.mark.parametrize(
+    "mk_cls", [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs]
+)
+def test_alignment_to_rich_dict_round_trip_info(mk_cls):
+    # info is preserved
+    data = {
+        "seq1": "ATG",
+        "seq2": "TGA",
+    }
+    aln = mk_cls(data, moltype="dna", info={"key": "value"})
+    rd = aln.to_rich_dict()
+    got = deserialise_object(rd)
+    assert got.info["key"] == "value"
+    assert got.to_dict() == aln.to_dict()
+    assert got is not aln
+
+
+@pytest.mark.parametrize(
+    "mk_cls", [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs]
+)
+def test_alignment_to_rich_dict_round_trip_annotation_db(gff_db, mk_cls):
+    # serialisation will drop the annotation_db
+    data = {
+        "seq1": "ATG",
+        "seq2": "TGA",
+    }
+    aln = mk_cls(data, moltype="dna")
+    aln.annotation_db = gff_db
+    assert len(aln.annotation_db) > 0
+    rd = aln.to_rich_dict()
+    got = deserialise_object(rd)
+    assert len(got.annotation_db) == 0
+
+
+def test_deserialise_alignment():
+    data = {
+        "init_args": {
+            "moltype": "dna",
+            "name_map": {"new_seq1": "seq1", "new_seq2": "seq2"},
+            "info": {},
+        },
+        "type": "cogent3.core.new_alignment.Alignment",
+        "version": "2023.10",
+        "seqs": {"seq1": "ATCG", "seq2": "TAGC"},
+    }
+
+    aln = deserialise_object(data)
+
+    assert isinstance(aln, new_alignment.Alignment)
+    assert aln.names == ["new_seq1", "new_seq2"]
+    assert str(aln.get_seq("new_seq1")) == "ATCG"
+    assert str(aln.get_seq("new_seq2")) == "TAGC"
diff --git a/tests/test_core/test_new_aln_annotation.py b/tests/test_core/test_new_aln_annotation.py
new file mode 100644
index 000000000..fa17e3b54
--- /dev/null
+++ b/tests/test_core/test_new_aln_annotation.py
@@ -0,0 +1,695 @@
+import os
+
+import pytest
+
+from cogent3 import load_seq
+from cogent3.core import new_alignment, new_genetic_code, new_moltype
+from cogent3.core.annotation_db import GffAnnotationDb, load_annotations
+
+DNA = new_moltype.get_moltype("dna")
+
+
+@pytest.fixture(scope="function")
+def gff_db(DATA_DIR):
+    return load_annotations(path=DATA_DIR / "simple.gff")
+
+
+def makeSampleSequence(name, with_gaps=False):
+    raw_seq = "AACCCAAAATTTTTTGGGGGGGGGGCCCC"
+    cds = (15, 25)
+    utr = (12, 15)
+    if with_gaps:
+        raw_seq = f"{raw_seq[:5]}-----{raw_seq[10:-2]}--"
+    # name is required for creating annotations
+    seq = DNA.make_seq(seq=raw_seq, name=name)
+    seq.add_feature(biotype="CDS", name="CDS", spans=[cds])
+    seq.add_feature(biotype="5'UTR", name="5' UTR", spans=[utr])
+    return seq
+
+
+def makeSampleAlignment():
+    seq1 = makeSampleSequence("FAKE01")
+    seq2 = makeSampleSequence("FAKE02", with_gaps=True)
+    seqs = {seq1.name: seq1, seq2.name: seq2}
+    aln = new_alignment.make_aligned_seqs(seqs, moltype="dna")
+    aln.add_feature(
+        biotype="misc_feature", name="misc", spans=[(12, 25)], on_alignment=True
+    )
+    aln.add_feature(biotype="CDS", name="blue", spans=[(15, 25)], on_alignment=True)
+    aln.add_feature(biotype="5'UTR", name="red", spans=[(2, 4)], on_alignment=True)
+    aln.add_feature(biotype="LTR", name="fake", spans=[(2, 15)], on_alignment=True)
+    return aln
+
+
+@pytest.fixture()
+def ann_aln():
+    # synthetic annotated alignment
+    return makeSampleAlignment()
+
+
+@pytest.fixture()
+def ann_seq():
+    return makeSampleSequence("seq1")
+
+
+def _make_seq(name):
+    raw_seq = "AACCCAAAATTTTTTGGGGGGGGGGCCCC"
+    cds = (15, 25)
+    utr = (12, 15)
+    seq = new_moltype.DNA.make_seq(seq=raw_seq, name=name)
+    seq.add_feature(biotype="CDS", name="CDS", spans=[cds])
+    seq.add_feature(biotype="5'UTR", name="5' UTR", spans=[utr])
+    return seq
+
+
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_init_seqs_have_annotations(mk_cls):
+    """annotations on input seqs correctly merged and propagated"""
+    data = {"seq1": _make_seq("seq1"), "seq2": _make_seq("seq2")}
+    seq_coll = mk_cls(data, moltype="dna")
+    coll_db = seq_coll.annotation_db
+    assert len(coll_db) == 4
+    for name in seq_coll.names:
+        seq = seq_coll.get_seq(name)
+        db = seq.annotation_db
+        assert db is coll_db
+
+
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_constructing_collections(mk_cls):
+    seq1 = makeSampleSequence("FAKE01")
+    seq2 = makeSampleSequence("FAKE02", with_gaps=True)
+    seqs = {"FAKE01": seq1, "FAKE02": seq2}
+    expect = sum(s.annotation_db.num_matches() for s in seqs.values())
+    coll = mk_cls(seqs, moltype="dna")
+    got = coll.annotation_db.num_matches()
+
+    assert got == expect
+
+
+@pytest.mark.parametrize(
+    "mk_cls", [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs]
+)
+def test_init_annotated_seqs(mk_cls):
+    """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)])
+    coll = mk_cls({"seq1": seq}, moltype="dna")
+    features = list(coll.get_features(biotype="exon"))
+    assert len(features) == 1
+
+
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_sequence_collection_add_feature(mk_cls):
+    seqs = mk_cls({"seq1": "AAAAAA", "seq2": "TTTTTT", "seq3": "ATTCCC"}, moltype="dna")
+    _ = seqs.add_feature(seqid="seq1", biotype="xyz", name="abc", spans=[(1, 2)])
+    assert seqs.annotation_db.num_matches() == 1
+
+    # if seqid is not in seqs, raise error
+    with pytest.raises(ValueError):
+        _ = seqs.add_feature(seqid="bad_seq", biotype="xyz", name="abc", spans=[(1, 2)])
+
+
+@pytest.mark.parametrize(
+    "mk_cls", [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs]
+)
+def test_sequence_collection_get_annotations_from_any_seq(mk_cls):
+    """get_annotations_from_any_seq returns correct annotations"""
+    data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
+    seqs = mk_cls(data, moltype="dna")
+    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)])
+    db.add_feature(seqid="seq3", biotype="exon", name="annotation3", spans=[(3, 6)])
+    seqs.annotation_db = db
+    got = list(seqs.get_features())
+    assert len(got) == 3
+    assert "biotype='exon', name='annotation1', map=[3:8]/9" in str(got[0])
+    assert "biotype='exon', name='annotation2', map=[1:2]/9" in str(got[1])
+    assert "biotype='exon', name='annotation3', map=[3:6]/9" in str(got[2])
+
+    got = list(seqs.get_features(name="annotation1"))
+    assert len(got) == 1
+    assert "biotype='exon', name='annotation1', map=[3:8]/9" in str(got[0])
+
+    got = list(seqs.get_features(biotype="exon", name="annotation2"))
+    assert len(got) == 1
+    assert "biotype='exon', name='annotation2', map=[1:2]/9" in str(got[0])
+
+    got = list(seqs.get_features(name="annotation3"))
+    assert len(got) == 1
+    assert "biotype='exon', name='annotation3', map=[3:6]/9" in str(got[0])
+
+
+@pytest.mark.parametrize("rc", (False, True))
+def test_alignment_get_slice(rc):
+    """get_slice should return the same as slicing the sequence directly"""
+    seq = DNA.make_seq(seq="ATTGTACGCCCCTGA", name="test_seq")
+    feature_data = {
+        "seqid": "test_seq",
+        "biotype": "CDS",
+        "name": "fake",
+        "spans": [
+            (5, 10),
+        ],
+        "strand": "+",
+    }
+    aln = new_alignment.make_aligned_seqs([seq], moltype="dna")
+    aln = aln.rc() if rc else aln
+    feature = aln.make_feature(feature=feature_data)
+    got_aln = feature.get_slice()
+    got_seq = got_aln.get_seq("test_seq")
+    assert str(got_seq) == str(seq[5:10])
+
+
+def test_align_get_features():
+    #                    0123456789   the positions
+    seq1 = DNA.make_seq(seq="ACG--ACCGT", name="seq1")
+    seq2 = DNA.make_seq(seq="ACGGGCCCGT", name="seq2")
+    #                      *****      the CDS feature
+    seq2.add_feature(biotype="CDS", name="fake01", spans=[(2, 7)], strand="+")
+    aln = new_alignment.make_aligned_seqs([seq1, seq2], moltype="dna")
+    feat = list(aln.get_features(biotype="CDS"))[0]
+    sl = aln[feat]
+    # slice is correct length
+    assert len(sl) == (7 - 2)
+    # returns correct value
+    assert sl.to_dict() == dict(seq1="G--AC", seq2="GGGCC")
+
+
+@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")
+    seq = load_seq(fasta_path, moltype="dna", new_type=True, annotation_path=gff3_path)
+    return new_alignment.make_unaligned_seqs({seq.name: seq}, moltype="dna")
+
+
+def test_seqcoll_query(seqcoll_db):
+    """querying from a SequenceCollection produces features bound to their seqs"""
+    matches = list(seqcoll_db.get_features(seqid="I"))
+    # 11 features
+    assert len(matches) == 11
+    matches = list(seqcoll_db.get_features(seqid="I", biotype="gene"))
+    assert len(matches) == 1
+    matches = list(matches[0].get_children(biotype="mRNA"))
+    assert len(matches) == 1
+    matches = list(matches[0].get_children(biotype="exon"))
+    assert len(matches) == 3
+
+
+def test_feature_projection_ungapped(ann_aln):
+    # projection onto ungapped sequence
+    expecteds = {"FAKE01": "CCCAAAATTTTTT", "FAKE02": "CCC-----TTTTT"}
+    aln_ltr = list(ann_aln.get_features(biotype="LTR"))[0]
+    seq_name = "FAKE01"
+    expected = expecteds[seq_name]
+    num = ann_aln.annotation_db.num_matches()
+    seq_ltr = ann_aln.get_projected_feature(seqid=seq_name, feature=aln_ltr)
+    assert ann_aln.annotation_db.num_matches() == num + 1
+    got = seq_ltr.get_slice()
+    assert str(got) == expected
+    assert seq_ltr.seqid == seq_name
+    assert seq_ltr.parent == ann_aln.get_seq(seq_name)
+
+
+def test_feature_projection_gapped(ann_aln):
+    # projection onto gapped sequence
+    expecteds = {"FAKE01": "CCCAAAATTTTTT", "FAKE02": "CCC-----TTTTT"}
+    aln_ltr = list(ann_aln.get_features(biotype="LTR"))[0]
+    seq_name = "FAKE02"
+    expected = expecteds[seq_name]
+    seq_ltr = ann_aln.get_projected_feature(seqid=seq_name, feature=aln_ltr)
+
+    with pytest.raises(ValueError):
+        seq_ltr.get_slice(complete=True)
+
+    # to get the annotation on the seq coord
+    seq_ltr = seq_ltr.without_lost_spans()
+    expected = expected.replace("-", "")
+    assert str(seq_ltr.get_slice()) == expected
+    assert seq_ltr.seqid == seq_name
+    assert seq_ltr.parent == ann_aln.get_seq(seq_name)
+
+
+def test_get_feature():
+    aln = new_alignment.make_aligned_seqs(
+        {"x": "-AAAAAAAAA", "y": "TTTT--CCCT"}, moltype="dna"
+    )
+
+    db = aln.annotation_db
+    db.add_feature(seqid="y", biotype="exon", name="A", spans=[(5, 8)])
+    feat = list(aln.get_features(seqid="y", biotype="exon", on_alignment=False))[0]
+    sliced = feat.get_slice()
+    assert sliced.to_dict() == dict(x="AAA", y="CCT")
+
+
+def test_aln_feature_to_dict():
+    seqs = [
+        makeSampleSequence("s1", with_gaps=False),
+        makeSampleSequence("s2", with_gaps=True),
+    ]
+    aln = new_alignment.make_aligned_seqs(seqs, moltype="dna")
+    feature_data = {
+        "biotype": "CDS",
+        "name": "fake",
+        "spans": [
+            (5, 10),
+        ],
+        "strand": "+",
+        "seqid": None,
+        "on_alignment": True,
+    }
+    # copy this now because modified by the method
+    expect = {k: v for k, v in feature_data.items() if k != "on_alignment"}
+    f = aln.make_feature(feature=feature_data)
+    d = f.to_dict()
+    assert d == expect
+
+
+@pytest.mark.parametrize("rev", (False, True))
+@pytest.mark.parametrize(
+    "make_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_features_survives_aligned_seq_rename(rev, make_cls):
+    segments = ["A" * 10, "C" * 10, "T" * 5, "C" * 5, "A" * 5]
+
+    seqs = make_cls({"original": "".join(segments)}, moltype="dna")
+    seqs.annotation_db.add_feature(
+        seqid="original", biotype="gene", name="gene1", spans=[(10, 20), (25, 30)]
+    )
+    seqs.annotation_db.add_feature(
+        seqid="original", biotype="domain", name="domain1", spans=[(20, 25)], strand="-"
+    )
+    seqs = seqs.rename_seqs(lambda x: "newname")
+    assert seqs.names == ["newname"]
+    seqs = seqs.rc() if rev else seqs
+    # quite different behaviour from Alignment and SequenceCollection
+    # so we convert to string to make comparison simpler
+    got = list(seqs.get_features(name="gene1"))[0]
+    sliced = str(got.get_slice()).splitlines()[-1]
+    assert sliced == "C" * 15
+
+
+def test_alignment_annotations():
+    """test that annotations to alignment and its' sequences"""
+
+    aln = makeSampleAlignment()
+    aln_expecteds = {
+        "misc_feature": {"FAKE01": "TTTGGGGGGGGGG", "FAKE02": "TTTGGGGGGGGGG"},
+        "CDS": {"FAKE01": "GGGGGGGGGG", "FAKE02": "GGGGGGGGGG"},
+        "5'UTR": {"FAKE01": "CC", "FAKE02": "CC"},
+        "LTR": {"FAKE01": "CCCAAAATTTTTT", "FAKE02": "CCC-----TTTTT"},
+    }
+    seq_expecteds = {
+        "CDS": {"FAKE01": "GGGGGGGGGG", "FAKE02": "GGGGGGGGGG"},
+        "5'UTR": {"FAKE01": "TTT", "FAKE02": "TTT"},
+    }
+    for annot_type in ["misc_feature", "CDS", "5'UTR", "LTR"]:
+        observed = (
+            list(aln.get_features(biotype=annot_type, on_alignment=True))[0]
+            .get_slice()
+            .to_dict()
+        )
+        expected = aln_expecteds[annot_type]
+        assert observed == expected, (annot_type, expected, observed)
+        if annot_type in ["misc_feature", "LTR"]:
+            continue  # because seqs haven't been annotated with it
+
+        observed = list(aln.get_features(seqid=aln.names, biotype=annot_type))[0]
+        observed = observed.get_slice().to_dict()
+        expected = seq_expecteds[annot_type]
+        assert observed == expected
+
+
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_add_to_seq_updates_coll(mk_cls):
+    """annotating a seq updates the db of the propagated"""
+    data = {
+        "x": "AACCCAAAATTTTTTGGGGGGGGGGCCCC",
+        "y": "AACCCAAAATTTTTTGGGGGGGGGGCCCC",
+    }
+    seq_coll = mk_cls(
+        data,
+        moltype="dna",
+    )
+    x = seq_coll.get_seq("x")
+    assert len(seq_coll.annotation_db) == len(x.annotation_db) == 0
+    x.add_feature(biotype="exon", name="E1", spans=[(3, 8)])
+    assert len(seq_coll.annotation_db) == len(x.annotation_db) == 1
+
+
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_annotation_db_assign_none(mk_cls):
+    """assigning None to annotation_db breaks conection"""
+    data = {"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"}
+    seq_coll = mk_cls(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
+
+
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_annotation_db_assign_same(gff_db, mk_cls):
+    """assigning the same annotation_db"""
+    data = {"test_seq": "ACGT", "test_seq2": "CGTT"}
+    seq_coll = mk_cls(data, moltype="dna", annotation_db=gff_db)
+    seq_coll.annotation_db = gff_db
+    assert seq_coll.annotation_db is gff_db
+
+
+@pytest.mark.parametrize("rved", (False, True))
+@pytest.mark.parametrize("annot_type", ("LTR", "misc_feature", "CDS", "5'UTR"))
+def test_region_union_on_alignment(annot_type, rved):
+    # >FAKE01
+    # AACCCAAAATTTTTTGGGGGGGGGGCCCC
+    # >FAKE02
+    # AACCC-----TTTTTGGGGGGGGGGCC--
+    #   **  5'UTR
+    #   ************* LTR
+    #                **********  CDS
+    #             *************  misc_feature
+
+    aln = makeSampleAlignment()
+    aln_expecteds = {
+        "misc_feature": {"FAKE01": "TTTGGGGGGGGGG", "FAKE02": "TTTGGGGGGGGGG"},
+        "CDS": {"FAKE01": "GGGGGGGGGG", "FAKE02": "GGGGGGGGGG"},
+        "5'UTR": {"FAKE01": "CC", "FAKE02": "CC"},
+        "LTR": {"FAKE01": "CCCAAAATTTTTT", "FAKE02": "CCC-----TTTTT"},
+    }
+    newaln = aln.rc() if rved else aln
+    feature_list = list(newaln.get_features(biotype=annot_type, on_alignment=True))
+    new = feature_list[0]
+    new = new.get_slice()
+    got = new.to_dict()
+    expected = aln_expecteds[annot_type]
+    assert expected == got, (annot_type, expected, got)
+
+
+def test_annotate_matches_to():
+    """Aligned.annotate_matches_to correctly delegates to sequence"""
+
+    aln = new_alignment.make_aligned_seqs(dict(x="TTCCACTTCCGCTT"), moltype="dna")
+    aln.annotation_db = GffAnnotationDb()
+    seq = aln.seqs["x"]
+    pattern = "CCRC"
+    annot = seq.annotate_matches_to(
+        pattern=pattern, biotype="domain", name="fred", allow_multiple=True
+    )
+    got = [str(a.get_slice()) for a in annot]
+    matches = ["CCAC", "CCGC"]
+    assert got == matches
+    annot = seq.annotate_matches_to(
+        pattern=pattern, biotype="domain", name="fred", allow_multiple=False
+    )
+    got = [a.get_slice() for a in annot]
+    assert got == matches[:1]
+
+    # handles regex from aa
+    aln = new_alignment.make_aligned_seqs(dict(x="TTCCACTTCCGCTT"), moltype="dna")
+    aln.annotation_db = GffAnnotationDb()
+    gc = new_genetic_code.get_code(1)
+    aa_regex = gc.to_regex("FHF")
+    aln.seqs["x"].annotate_matches_to(aa_regex, "domain", "test", allow_multiple=False)
+    a = list(aln.get_features(seqid="x"))[0]
+    assert str(aln[a].seqs["x"]) == "TTCCACTTC"
+
+
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_features_invalid_seqid(mk_cls):
+    segments = ["A" * 10, "C" * 10, "T" * 5, "C" * 5, "A" * 5]
+
+    seqs = mk_cls({"original": "".join(segments)}, moltype="dna")
+    seqs.annotation_db.add_feature(
+        seqid="original", biotype="domain", name="domain1", spans=[(20, 25)], strand="-"
+    )
+    with pytest.raises(ValueError):
+        # seqid does not exist
+        list(seqs.get_features(name="gene1", seqid="blah"))
+
+
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_copy_annotations(gff_db, mk_cls):
+    """copy_annotations copies records from annotation db"""
+    data = {"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"}
+    seq_coll = mk_cls(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()
+    seq_coll.copy_annotations(gff_db)
+    assert seq_coll.annotation_db.num_matches() == expect
+
+    # copy annotations with no current annotations
+    seq_coll = mk_cls(data, moltype="rna")
+    seq_coll.copy_annotations(gff_db)
+    assert seq_coll.annotation_db.num_matches() == gff_db.num_matches()
+
+
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_copy_annotations_same_annotations(gff_db, mk_cls):
+    data = {"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"}
+    seq_coll = mk_cls(data, moltype="rna")
+    seq_coll = mk_cls(data, moltype="rna")
+
+    # copy annotations with the same annotation_db
+    seq_coll.annotation_db = gff_db
+    seq_coll.copy_annotations(gff_db)
+
+    assert seq_coll.annotation_db.num_matches() == gff_db.num_matches()
+
+
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_copy_annotations_none_matching(gff_db, mk_cls):
+    """copy annotations should old copy annotations for matching seqids"""
+    data = {"name1": "ACGU", "name2": "CGUA", "name_3": "CCGU"}
+    seq_coll = mk_cls(data, moltype="rna")
+    seq_coll.add_feature(seqid="name1", biotype="xyz", name="abc", spans=[(1, 2)])
+    seq_coll.add_feature(seqid="name2", biotype="xyzzz", name="abc", spans=[(1, 2)])
+    expect = seq_coll.annotation_db.num_matches()
+    assert gff_db.num_matches() > 0
+    seq_coll.copy_annotations(gff_db)
+    assert seq_coll.annotation_db.num_matches() == expect
+
+
+@pytest.mark.parametrize(
+    "mk_cls", (new_alignment.make_aligned_seqs, new_alignment.make_unaligned_seqs)
+)
+def test_copy_annotations_no_db(gff_db, mk_cls):
+    data = {"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"}
+    seq_coll = mk_cls(data, moltype="rna")
+
+    seq_coll.copy_annotations(gff_db)
+    assert seq_coll.annotation_db.num_matches() == gff_db.num_matches()
+
+
+def test_project_features_onto_specified_seqid():
+    db = GffAnnotationDb()
+    aln = new_alignment.make_aligned_seqs(
+        {"x": "-AAAAAAAAA", "y": "TTTT--TTTT"}, moltype="dna"
+    )
+    db.add_feature(seqid="x", biotype="exon", name="fred", spans=[(3, 8)])
+    aln.annotation_db = db
+
+    exons = aln.get_projected_features(seqid="y", biotype="exon")
+    assert len(exons) == 1
+    assert str(aln.get_seq("y")[exons[0].map.without_gaps()]), "TTT"
+    assert "biotype='exon', name='fred', map=[-2-, 4:7]/8" in str(exons[0])
+
+
+def test_project_features_no_features_for_specified_seqid():
+    db = GffAnnotationDb()
+    aln = new_alignment.make_aligned_seqs(
+        {"seq1": "ATGCGT", "seq2": "AT--GT"}, moltype="dna"
+    )
+    db.add_feature(seqid="seq1", biotype="gene", name="gene1", spans=[(0, 6)])
+    aln.annotation_db = db
+
+    projected_features = aln.get_projected_features(seqid="seq2", biotype="exon")
+
+    assert len(projected_features) == 0
+
+
+def test_annotated_region_masks():
+    """masking a sequence with specific features"""
+    # ported from test_features.py
+    # refactor: break into multiple tests
+
+    # Annotated regions can be masked (observed sequence characters
+    # replaced by another), either through the sequence on which they
+    # reside or by projection from the alignment. Note that mask_char must
+    # be a valid character for the sequence MolType. Either the features
+    # (multiple can be named), or their shadow, can be masked.
+
+    # We create an alignment with a sequence that has two different annotation types.
+    orig_data = {"x": "C-CCCAAAAAGGGAA", "y": "-T----TTTTG-GTT"}
+    db = GffAnnotationDb()
+    db.add_feature(seqid="x", biotype="exon", name="norwegian", spans=[(0, 4)])
+    db.add_feature(
+        biotype="repeat",
+        name="blue",
+        spans=[(9, 12)],
+        seqid="x",
+    )
+    db.add_feature(seqid="y", biotype="repeat", name="frog", spans=[(5, 7)])
+    aln = new_alignment.make_aligned_seqs(orig_data, moltype="dna")
+    aln.annotation_db = db
+
+    assert aln.to_dict() == {"x": "C-CCCAAAAAGGGAA", "y": "-T----TTTTG-GTT"}
+    x = aln.get_seq("x")
+    y = aln.get_seq("y")
+    exon = list(x.get_features(biotype="exon"))[0]
+    assert str(exon.get_slice()) == "CCCC"
+    repeat_x = list(x.get_features(biotype="repeat"))[0]
+    assert str(repeat_x.get_slice()) == "GGG"
+    repeat_y = list(y.get_features(biotype="repeat"))[0]
+    assert str(repeat_y.get_slice()) == "GG"
+
+    # Each sequence should correctly mask either the single feature,
+    # it's shadow, or the multiple features, or shadow.
+
+    assert (
+        str(aln.get_seq("x").with_masked_annotations("exon", mask_char="?"))
+        == "????AAAAAGGGAA"
+    )
+    assert (
+        str(
+            aln.get_seq("x").with_masked_annotations("exon", mask_char="?", shadow=True)
+        )
+        == "CCCC??????????"
+    )
+    assert (
+        str(aln.get_seq("x").with_masked_annotations(["exon", "repeat"], mask_char="?"))
+        == "????AAAAA???AA"
+    )
+    assert (
+        str(
+            aln.get_seq("x").with_masked_annotations(
+                ["exon", "repeat"], mask_char="?", shadow=True
+            )
+        )
+        == "CCCC?????GGG??"
+    )
+    assert (
+        str(aln.get_seq("y").with_masked_annotations("exon", mask_char="?"))
+        == "TTTTTGGTT"
+    )
+    assert (
+        str(aln.get_seq("y").with_masked_annotations("repeat", mask_char="?"))
+        == "TTTTT??TT"
+    )
+    assert (
+        str(
+            aln.get_seq("y").with_masked_annotations(
+                "repeat", mask_char="?", shadow=True
+            )
+        )
+        == "?????GG??"
+    )
+
+    # The same methods can be applied to annotated Alignment's.
+
+    assert aln.with_masked_annotations("exon", mask_char="?").to_dict() == {
+        "x": "?-???AAAAAGGGAA",
+        "y": "-T----TTTTG-GTT",
+    }
+    assert aln.with_masked_annotations(
+        "exon", mask_char="?", shadow=True
+    ).to_dict() == {"x": "C-CCC??????????", "y": "-?----?????-???"}
+    assert aln.with_masked_annotations("repeat", mask_char="?").to_dict() == {
+        "x": "C-CCCAAAAA???AA",
+        "y": "-T----TTTT?-?TT",
+    }
+    assert aln.with_masked_annotations(
+        "repeat", mask_char="?", shadow=True
+    ).to_dict() == {"x": "?-????????GGG??", "y": "-?----????G-G??"}
+    assert aln.with_masked_annotations(["repeat", "exon"], mask_char="?").to_dict() == {
+        "x": "?-???AAAAA???AA",
+        "y": "-T----TTTT?-?TT",
+    }
+    assert aln.with_masked_annotations(["repeat", "exon"], shadow=True).to_dict() == {
+        "x": "C-CCC?????GGG??",
+        "y": "-?----????G-G??",
+    }
+
+
+def test_masking_strand_agnostic_aln():
+    # ported from test_features.py
+    db = GffAnnotationDb()
+    db.add_feature(
+        seqid="x", biotype="CDS", name="gene", spans=[(2, 6), (10, 15), (25, 35)]
+    )
+    # Annotations should be correctly masked,
+    # whether the sequence has been reverse complemented or not.
+    # We use the plus/minus strand CDS containing sequences created above.
+
+    aln = new_alignment.make_aligned_seqs(
+        {
+            "x": "AAGGGGAAAACCCCCAAAAAAAAAATTTTTTTTTTAAA",
+            "y": "AAGGGGAAAACCCCCGGGGGGGGGGTTTTTTTTTTAAA",
+        },
+        moltype="dna",
+        annotation_db=db,
+    )
+    masked = aln.with_masked_annotations("CDS")
+    assert masked.to_dict() == {
+        "x": "AA????AAAA?????AAAAAAAAAA??????????AAA",
+        "y": str(aln.seqs["y"]),
+    }
+    rc = aln.rc()
+    masked = rc.with_masked_annotations("CDS")
+    assert masked.to_dict() == {
+        "x": "TTT??????????TTTTTTTTTT?????TTTT????TT",
+        "y": str(rc.seqs["y"]),
+    }
+
+
+def test_nested_annotated_region_masks():
+    """masking a sequence with specific features when nested annotations"""
+    # ported from test_features.py
+    db = GffAnnotationDb()
+    db.add_feature(seqid="x", biotype="gene", name="norwegian", spans=[(0, 4)])
+    db.add_feature(seqid="x", biotype="repeat", name="blue", spans=[(1, 3)])
+    db.add_feature(seqid="y", biotype="repeat", name="frog", spans=[(1, 4)])
+    aln = new_alignment.make_aligned_seqs(
+        [["x", "C-GGCAAAAATTTAA"], ["y", "-T----TTTTG-GTT"]],
+        annotation_db=db,
+        moltype="text",
+    )
+    gene = list(aln.get_seq("x").get_features(biotype="gene"))[0]
+    assert str(gene.get_slice()) == "CGGC"
+
+    # evaluate the sequence directly
+    masked = str(aln.get_seq("x").with_masked_annotations("repeat", mask_char="?"))
+    assert masked == "C??CAAAAATTTAA"
+
+    exon = list(aln.get_seq("y").get_features(biotype="repeat", name="frog"))[0]
+    assert str(exon.get_slice()) == "TTT"
+    # evaluate the sequence directly
+    masked = str(aln.get_seq("y").with_masked_annotations("repeat", mask_char="?"))
+    assert masked == "T???TGGTT"
+    masked = aln.with_masked_annotations("gene", mask_char="?")
+    got = masked.to_dict()
+    assert got["x"] == "?-???AAAAATTTAA"
+    assert got["y"] == "-T----TTTTG-GTT"
diff --git a/tests/test_core/test_new_alphabet.py b/tests/test_core/test_new_alphabet.py
index 7af05f64d..2ec9986e2 100644
--- a/tests/test_core/test_new_alphabet.py
+++ b/tests/test_core/test_new_alphabet.py
@@ -550,7 +550,7 @@ def test_deserialise_alphas(alpha):
 @pytest.fixture
 def calpha():
     gc = new_genetic_code.get_code(1)
-    return new_alphabet.CodonAlphabet(
+    return new_alphabet.SenseCodonAlphabet(
         words=gc.sense_codons, monomers=gc.moltype.alphabet
     )
 
@@ -570,7 +570,7 @@ def test_to_indices(calpha, seq):
 
 
 def test_codon_alphabet_invalid_codon(calpha):
-    with pytest.raises(ValueError):
+    with pytest.raises(new_alphabet.AlphabetError):
         calpha.to_index("TGA")
 
 
@@ -592,16 +592,44 @@ def test_codon_alphabet_not_is_valid(seq, calpha):
     assert not calpha.is_valid(seq)
 
 
+@pytest.mark.parametrize("as_array", (False, True))
+def test_codon_alphabet_to_indices(calpha, as_array):
+    seq = calpha.monomers.to_indices("GGGAAG") if as_array else "GGGAAG"
+    got = calpha.to_indices(seq)
+    expect = numpy.array(
+        [calpha.to_index("GGG"), calpha.to_index("AAG")], dtype=numpy.uint8
+    )
+    assert_allclose(got, expect)
+    assert isinstance(got, numpy.ndarray)
+    assert got.dtype == numpy.uint8
+
+
 def test_codon_alphabet_with_gap_motif(calpha):
     with_gap = calpha.with_gap_motif()
     assert len(with_gap) == len(calpha) + 1
+    assert with_gap.gap_char == "---"
+    # on the standard genetic code, gap index is 61
+
+
+def test_codon_alphabet_with_gap_motif_translation(calpha):
+    with_gap = calpha.with_gap_motif()
+    seq = "ATG---TAC"
+    translated = with_gap.to_indices(seq)
+    expected = [with_gap.to_index("ATG"), with_gap.gap_index, with_gap.to_index("TAC")]
+    assert_allclose(translated, expected)
+
+
+def test_codon_alphabet_missing(calpha):
+    # not provided on codon alphabet
+    assert calpha.missing_char is None
+    assert calpha.missing_index is None
 
 
 def test_codon_alphabet_serlialise_round_trip(calpha):
     from cogent3.util.deserialise import deserialise_object
 
     got = deserialise_object(calpha.to_json())
-    assert isinstance(got, new_alphabet.CodonAlphabet)
+    assert isinstance(got, new_alphabet.SenseCodonAlphabet)
     assert list(got) == list(calpha)
     assert calpha.to_index("TTC") == 1
 
@@ -622,3 +650,27 @@ def test_alpha_no_moltype():
 
 def test_codon_alphabet_moltype(calpha):
     assert calpha.moltype is new_moltype.DNA
+
+
+@pytest.mark.parametrize(
+    "seq",
+    ("GGTAC", "GGTAC".encode("utf8"), numpy.array([3, 3, 0, 2, 1], dtype=numpy.uint8)),
+)
+def test_char_alphabet_to_indices_types(seq):
+    dna = new_moltype.get_moltype("dna")
+    alpha = dna.alphabet
+    got = alpha.to_indices(seq)
+    expect = numpy.array([3, 3, 0, 2, 1], dtype=numpy.uint8)
+    assert_allclose(got, expect)
+
+
+@pytest.mark.parametrize(
+    "seq",
+    ("GGTAC", "GGTAC".encode("utf8"), numpy.array([3, 3, 0, 2, 1], dtype=numpy.uint8)),
+)
+def test_char_alphabet_from_indices_types(seq):
+    dna = new_moltype.get_moltype("dna")
+    alpha = dna.alphabet
+    got = alpha.from_indices(seq)
+    expect = "GGTAC"
+    assert got == expect
diff --git a/tests/test_core/test_new_genetic_code.py b/tests/test_core/test_new_genetic_code.py
index 9bab8be5b..cc946db1a 100644
--- a/tests/test_core/test_new_genetic_code.py
+++ b/tests/test_core/test_new_genetic_code.py
@@ -1,6 +1,6 @@
 import pytest
 
-from cogent3.core import new_genetic_code
+from cogent3.core import new_alphabet, new_genetic_code
 
 
 @pytest.mark.parametrize("gc", (1, "Standard", new_genetic_code.DEFAULT))
@@ -44,6 +44,12 @@ def test_sense_codons():
     assert len(code.sense_codons) == 61
 
 
+@pytest.mark.parametrize("invalid", ("AT", "TAAA", 23))
+def test_invalid_index(invalid):
+    with pytest.raises(new_genetic_code.InvalidCodonError):
+        new_genetic_code.DEFAULT[invalid]
+
+
 def test_translate():
     code = new_genetic_code.DEFAULT
     assert code.translate("ATGGGG") == code["ATG"] + code["GGG"]
@@ -56,6 +62,17 @@ def test_translate_rc():
     assert code.translate(rc, rc=True) == code["ATG"] + code["GGG"]
 
 
+def test_translate_incomplete_ok():
+    code = new_genetic_code.DEFAULT
+    assert code.translate("ATGG--", incomplete_ok=True) == code["ATG"] + "-"
+
+
+def test_translate_incomplete_not_ok():
+    code = new_genetic_code.DEFAULT
+    with pytest.raises(new_alphabet.AlphabetError):
+        assert code.translate("ATGG--", incomplete_ok=False)
+
+
 def test_to_table():
     code = new_genetic_code.DEFAULT
     table = code.to_table()
@@ -148,6 +165,13 @@ def test_get_alphabet_with_stop():
     assert len(alpha_gap) == 65
 
 
+@pytest.mark.parametrize("repr_method", ("__str__", "__repr__", "_repr_html_"))
+def test_code_repr(repr_method):
+    gc = new_genetic_code.get_code(1)
+    got = getattr(gc, repr_method)()
+    assert isinstance(got, str)
+
+
 def test_to_regex():
     """creates a regex from aa seq to match a DNA sequence"""
     import re
diff --git a/tests/test_core/test_new_moltype.py b/tests/test_core/test_new_moltype.py
index b311525e8..d7e44c6b9 100644
--- a/tests/test_core/test_new_moltype.py
+++ b/tests/test_core/test_new_moltype.py
@@ -143,6 +143,20 @@ def test_is_degenerate_invalid():
         new_moltype.RNA.is_degenerate(list("GAG"))
 
 
+@pytest.mark.parametrize("data_type", (str, bytes, numpy.ndarray))
+@pytest.mark.parametrize(
+    "seq",
+    (
+        "",
+        "QWERTYUIOPASDFGHJKLZXCVBNM",
+    ),
+)
+def test_text_moltype_is_not_degenerate(seq, data_type):
+    """Text moltype should not be degenerate"""
+    seq = make_typed(seq, data_type, new_moltype.ASCII)
+    assert not new_moltype.ASCII.is_degenerate(seq)
+
+
 @pytest.mark.parametrize("data_type", (str, bytes, numpy.ndarray))
 @pytest.mark.parametrize(
     "seq",
@@ -606,3 +620,15 @@ def test_to_regex():
     # raises an exception if a string is already a regex, or invalid
     with pytest.raises(ValueError):
         new_moltype.DNA.to_regex("(?:GAT|GAC)(?:GGT|GGC|GGA|GGG)(?:GAT|GAC)(?:CAA|CAG)")
+
+
+@pytest.mark.parametrize("moltype", ("dna", "rna"))
+def test_is_nucleic(moltype):
+    mt = new_moltype.get_moltype(moltype)
+    assert mt.is_nucleic
+
+
+@pytest.mark.parametrize("moltype", ("protein", "text", "bytes", "protein_with_stop"))
+def test_not_is_nucleic(moltype):
+    mt = new_moltype.get_moltype(moltype)
+    assert not mt.is_nucleic
diff --git a/tests/test_core/test_new_seq_annotation.py b/tests/test_core/test_new_seq_annotation.py
new file mode 100644
index 000000000..186305fae
--- /dev/null
+++ b/tests/test_core/test_new_seq_annotation.py
@@ -0,0 +1,152 @@
+import pytest
+
+from cogent3 import load_seq
+from cogent3.core import new_moltype
+
+DNA = new_moltype.get_moltype("dna")
+ASCII = new_moltype.get_moltype("text")
+
+
+def makeSampleSequence(name, with_gaps=False):
+    raw_seq = "AACCCAAAATTTTTTGGGGGGGGGGCCCC"
+    cds = (15, 25)
+    utr = (12, 15)
+    if with_gaps:
+        raw_seq = f"{raw_seq[:5]}-----{raw_seq[10:-2]}--"
+    # name is required for creating annotations
+    seq = DNA.make_seq(seq=raw_seq, name=name)
+    seq.add_feature(biotype="CDS", name="CDS", spans=[cds])
+    seq.add_feature(biotype="5'UTR", name="5' UTR", spans=[utr])
+    return seq
+
+
+@pytest.fixture()
+def ann_seq():
+    return makeSampleSequence("seq1")
+
+
+def test_seq_feature_to_dict():
+    """create the attributes necessary to write into the user table"""
+    seq = DNA.make_seq(seq="ATTGTACGCCCCTGA", name="test_seq")
+    feature_data = {
+        "biotype": "CDS",
+        "name": "fake",
+        "spans": [
+            (5, 10),
+        ],
+        "strand": "+",
+        "seqid": "test_seq",
+        "on_alignment": False,
+    }
+    expect = {k: v for k, v in feature_data.items() if k != "on_alignment"}
+    f = seq.make_feature(feature_data)
+    got = f.to_dict()
+    assert got == expect
+    c = seq.add_feature(**got)
+    assert c.to_dict() == expect
+    assert str(f.get_slice()) == str(c.get_slice())
+
+
+@pytest.mark.parametrize("rev", (False, True))
+def test_features_survives_seq_rename(rev):
+    segments = ["A" * 10, "C" * 10, "T" * 5, "C" * 5, "A" * 5]
+
+    seq = DNA.make_seq(seq="".join(segments), name="original")
+    gene = seq.add_feature(biotype="gene", name="gene1", spans=[(10, 20), (25, 30)])
+    gene_expect = str(seq[10:20]) + str(seq[25:30])
+    assert str(gene.get_slice()) == gene_expect
+    domain = seq.add_feature(
+        biotype="domain", name="domain1", spans=[(20, 25)], strand="-"
+    )
+    domain_expect = str(seq[20:25].rc())
+    domain_got = domain.get_slice()
+    assert str(domain_got) == domain_expect
+    sliced = seq[5:-3]
+    sliced.name = "sliced"
+    sliced = sliced.rc() if rev else sliced
+
+    got = list(sliced.get_features(name="gene1"))[0]
+    got = got.get_slice()
+    assert str(got) == gene_expect
+
+    got = list(sliced.get_features(name="domain1"))[0]
+    got = got.get_slice()
+    assert str(got) == domain_expect
+
+
+def test_annotate_matches_to():
+    """annotate_matches_to attaches annotations correctly to a Sequence"""
+    seq = DNA.make_seq(seq="TTCCACTTCCGCTT", name="x")
+    pattern = "CCRC"
+    annot = seq.annotate_matches_to(
+        pattern=pattern, biotype="domain", name="fred", allow_multiple=True
+    )
+    assert [a.get_slice() for a in annot] == ["CCAC", "CCGC"]
+
+    annot = seq.annotate_matches_to(
+        pattern=pattern, biotype="domain", name="fred", allow_multiple=False
+    )
+    assert len(annot) == 1
+
+    fred = annot[0].get_slice()
+    assert str(fred) == "CCAC"
+
+    # For Sequence objects of a non-IUPAC MolType, annotate_matches_to
+    # should return an empty annotation.
+    seq = ASCII.make_seq(seq="TTCCACTTCCGCTT")
+    annot = seq.annotate_matches_to(
+        pattern=pattern, biotype="domain", name="fred", allow_multiple=False
+    )
+    assert annot == []
+
+
+@pytest.mark.parametrize("annot_type", ("CDS", "5'UTR"))
+def test_slice_seq_with_full_annotations(ann_seq, annot_type):
+    # this slice contains both features intact
+    newseq = ann_seq[10:]
+    orig = list(ann_seq.get_features(biotype=annot_type))[0]
+    new = list(newseq.get_features(biotype=annot_type))[0]
+    assert orig.name == new.name
+    assert len(orig) == len(new)
+    assert str(newseq[new]) == str(ann_seq[orig]), annot_type
+
+
+@pytest.mark.parametrize("annot_type,num", (("CDS", 0), ("5'UTR", 1)))
+def test_slice_seq_with_partial_end(ann_seq, annot_type, num):
+    # this slice contains both features intact
+    newseq = ann_seq[:14]
+    # only UTR is present
+    new = list(newseq.get_features(biotype=annot_type, allow_partial=True))
+    assert len(new) == num, annot_type
+    if num:
+        feat = new[0]
+        # length of the feature is the same as the original
+        assert len(feat) == len(list(ann_seq.get_features(biotype=annot_type))[0])
+        gapless = feat.without_lost_spans()
+        # the sliced feature without gaps is shorter
+        assert len(gapless) < len(feat)
+
+
+@pytest.mark.parametrize("annot_type,num", (("CDS", 1), ("5'UTR", 0)))
+def test_slice_seq_with_partial_start(ann_seq, annot_type, num):
+    # this slice contains both features intact
+    newseq = ann_seq[18:]
+    # only UTR is present
+    new = list(newseq.get_features(biotype=annot_type, allow_partial=True))
+    assert len(new) == num, annot_type
+    if num:
+        feat = new[0]
+        # length of the feature is the same as the original
+        assert len(feat) == len(list(ann_seq.get_features(biotype=annot_type))[0])
+        gapless = feat.without_lost_spans()
+        # the sliced feature without gaps is shorter
+        assert len(gapless) < len(feat)
+
+
+def test_gbdb_get_children_get_parent(DATA_DIR):
+    seq = load_seq(DATA_DIR / "annotated_seq.gb", new_type=True)
+    seq = seq[2900:6000]
+    (orig,) = list(seq.get_features(biotype="gene", name="CNA00110"))
+    (child,) = list(orig.get_children("CDS"))
+    parent, *_ = list(child.get_parent())
+    assert parent == orig
diff --git a/tests/test_core/test_new_sequence.py b/tests/test_core/test_new_sequence.py
index ca414a010..7a6883a35 100644
--- a/tests/test_core/test_new_sequence.py
+++ b/tests/test_core/test_new_sequence.py
@@ -9,12 +9,8 @@
 
 import cogent3
 from cogent3._version import __version__
-from cogent3.core import (
-    new_alphabet,
-    new_genetic_code,
-    new_moltype,
-    new_sequence,
-)
+from cogent3.core import annotation_db as anndb_module
+from cogent3.core import new_alphabet, new_genetic_code, new_moltype, new_sequence
 from cogent3.util.deserialise import deserialise_object
 from cogent3.util.misc import get_object_provenance
 
@@ -37,7 +33,9 @@ def bytes_alphabet():
 @pytest.fixture(scope="function")
 def integer_seq(bytes_alphabet):
     """Used for slicing tests"""
-    return new_sequence.SeqView(seq="0123456789", alphabet=bytes_alphabet)
+    return new_sequence.SeqView(
+        parent="0123456789", parent_len=10, alphabet=bytes_alphabet
+    )
 
 
 @pytest.mark.parametrize("name", ("dna", "rna", "protein", "protein_with_stop", "text"))
@@ -872,14 +870,56 @@ def test_strand_symmetry():
 
 def test_is_annotated():
     """is_annotated operates correctly"""
-    s = new_moltype.DNA.make_seq(seq="ACGGCTGAAGCGCTCCGGGTTTAAAACG")
-    if hasattr(s, "annotation_db"):
-        assert not s.is_annotated()
-        _ = s.add_feature(biotype="gene", name="blah", spans=[(0, 10)])
-        assert s.is_annotated()
-    else:
-        with pytest.raises(AttributeError):
-            s.is_annotated()
+    s = new_moltype.DNA.make_seq(seq="ACGGCTGAAGCGCTCCGGGTTTAAAACG", name="s1")
+    _ = s.add_feature(biotype="gene", name="blah", spans=[(0, 10)])
+    assert s.is_annotated()
+
+
+@pytest.mark.parametrize("biotype", ("gene", "exon", ("gene", "exon")))
+def test_is_annotated_biotype(biotype):
+    """is_annotated operates correctly"""
+    s = new_moltype.DNA.make_seq(seq="ACGGCTGAAGCGCTCCGGGTTTAAAACG", name="s1")
+    _ = s.add_feature(biotype="gene", name="blah", spans=[(0, 10)])
+    _ = s.add_feature(biotype="exon", name="blah", spans=[(0, 10)])
+    assert s.is_annotated(biotype=biotype)
+
+
+def test_annotation_defaults():
+    s = new_moltype.DNA.make_seq(seq="AC", name="s1")
+    assert s.annotation_db is None
+    s._init_annotation_db()
+    assert isinstance(s.annotation_db, anndb_module.SupportsFeatures)
+
+
+def test_init_with_annotationdb():
+    anndb = anndb_module.GffAnnotationDb()
+    s = new_moltype.DNA.make_seq(seq="AC", name="s1", annotation_db=anndb)
+    assert isinstance(s.annotation_db, anndb_module.GffAnnotationDb)
+    assert s.annotation_db is anndb
+
+
+def test_init_with_annotation_offset():
+    s = new_moltype.DNA.make_seq(seq="AC", name="s1", annotation_offset=2)
+    assert s.annotation_offset == 2
+
+
+def test_not_is_annotated():
+    """is_annotated operates correctly"""
+    s = new_moltype.DNA.make_seq(seq="ACGGCTGAAGCGCTCCGGGTTTAAAACG", name="s1")
+    assert not s.is_annotated()
+    # annotation on different seq
+    s._init_annotation_db()
+    s.annotation_db.add_feature(
+        seqid="s2", biotype="gene", name="blah", spans=[(0, 10)]
+    )
+    assert not s.is_annotated()
+    # annotation wrong biotype
+    s.annotation_db.add_feature(
+        seqid="s1", biotype="exon", name="blah", spans=[(0, 10)]
+    )
+    assert not s.is_annotated(biotype="gene")
+    s.annotation_db = None
+    assert not s.is_annotated()
 
 
 def test_to_html():
@@ -1003,8 +1043,14 @@ def test_to_phylip():
 def test_seqview_initialisation(start, stop, step, bytes_alphabet):
     """Initialising a SeqView should work with range of provided values"""
     seq_data = "0123456789"
+    slice_record = new_sequence.SliceRecord(
+        start=start, stop=stop, step=step, parent_len=len(seq_data)
+    )
     got = new_sequence.SeqView(
-        seq=seq_data, start=start, stop=stop, step=step, alphabet=bytes_alphabet
+        parent=seq_data,
+        parent_len=len(seq_data),
+        slice_record=slice_record,
+        alphabet=bytes_alphabet,
     )
     expected = seq_data[start:stop:step]
     assert got.str_value == expected
@@ -1014,7 +1060,9 @@ def test_seqview_initialisation(start, stop, step, bytes_alphabet):
 def test_seqview_index(index, bytes_alphabet):
     """SeqView with default values can be sliced with a single index, when within the length of the sequence"""
     seq_data = "0123456789"
-    sv = new_sequence.SeqView(seq=seq_data, alphabet=bytes_alphabet)
+    sv = new_sequence.SeqView(
+        parent=seq_data, parent_len=len(seq_data), alphabet=bytes_alphabet
+    )
     got = sv[index]
     expected = seq_data[index]
     assert got.str_value == expected
@@ -1023,18 +1071,23 @@ def test_seqview_index(index, bytes_alphabet):
 
 def test_seqview_index_null(ascii_alphabet):
     "Indexing a SeqView of length 0 should return an IndexError"
-    sv = new_sequence.SeqView(seq="", alphabet=ascii_alphabet)
+    sv = new_sequence.SeqView(parent="", parent_len=0, alphabet=ascii_alphabet)
     with pytest.raises(IndexError):
         _ = sv[0]
 
 
 def test_seqview_step_0(bytes_alphabet):
     "Initialising or slicing a SeqView with a step of 0 should return an IndexError"
-    sv = new_sequence.SeqView(seq="0123456789", alphabet=bytes_alphabet)
+    sv = new_sequence.SeqView(
+        parent="0123456789", parent_len=10, alphabet=bytes_alphabet
+    )
     with pytest.raises(ValueError):
         _ = sv[::0]
     with pytest.raises(ValueError):
-        _ = new_sequence.SeqView(seq="0123456789", alphabet=bytes_alphabet, step=0)
+        sr = new_sequence.SliceRecord(step=0, parent_len=10)
+        _ = new_sequence.SeqView(
+            parent="0123456789", parent_len=10, alphabet=bytes_alphabet, slice_record=sr
+        )
 
 
 @pytest.mark.parametrize("start", (0, 2, 4))
@@ -1045,7 +1098,10 @@ def test_seqview_invalid_index(start, bytes_alphabet):
     pos_boundary_index = length
     neg_boundary_index = -length - 1
 
-    sv = new_sequence.SeqView(seq=seq, start=start, alphabet=bytes_alphabet)
+    sr = new_sequence.SliceRecord(start=start, parent_len=len(seq))
+    sv = new_sequence.SeqView(
+        parent=seq, parent_len=len(seq), slice_record=sr, alphabet=bytes_alphabet
+    )
     with pytest.raises(IndexError):
         _ = sv[pos_boundary_index]
     with pytest.raises(IndexError):
@@ -1061,7 +1117,10 @@ def test_seqview_invalid_index_positive_step_gt_1(start, bytes_alphabet):
     neg_boundary_index = -length - 1
     pos_boundary_index = length
 
-    sv = new_sequence.SeqView(seq=seq, start=start, step=step, alphabet=bytes_alphabet)
+    sr = new_sequence.SliceRecord(start=start, step=step, parent_len=len(seq))
+    sv = new_sequence.SeqView(
+        parent=seq, parent_len=len(seq), slice_record=sr, alphabet=bytes_alphabet
+    )
     with pytest.raises(IndexError):
         _ = sv[pos_boundary_index]
     with pytest.raises(IndexError):
@@ -1078,8 +1137,11 @@ def test_seqview_invalid_index_reverse_step(stop, bytes_alphabet):
     neg_boundary_index = -length - 1
     pos_boundary_index = length
 
+    sr = new_sequence.SliceRecord(
+        start=start, stop=stop, step=step, parent_len=len(seq)
+    )
     sv = new_sequence.SeqView(
-        seq=seq, start=start, stop=stop, step=step, alphabet=bytes_alphabet
+        parent=seq, parent_len=len(seq), slice_record=sr, alphabet=bytes_alphabet
     )
     with pytest.raises(IndexError):
         _ = sv[pos_boundary_index]
@@ -1097,8 +1159,11 @@ def test_seqview_invalid_index_reverse_step_gt_1(stop, bytes_alphabet):
     neg_boundary_index = -length - 1
     pos_boundary_index = length
 
+    sr = new_sequence.SliceRecord(
+        start=start, stop=stop, step=step, parent_len=len(seq)
+    )
     sv = new_sequence.SeqView(
-        seq=seq, start=start, stop=stop, step=step, alphabet=bytes_alphabet
+        parent=seq, parent_len=len(seq), slice_record=sr, alphabet=bytes_alphabet
     )
     with pytest.raises(IndexError):
         _ = sv[pos_boundary_index]
@@ -1107,7 +1172,7 @@ def test_seqview_invalid_index_reverse_step_gt_1(stop, bytes_alphabet):
 
 
 def test_seqview_slice_null(ascii_alphabet):
-    sv = new_sequence.SeqView(seq="", alphabet=ascii_alphabet)
+    sv = new_sequence.SeqView(parent="", parent_len=0, alphabet=ascii_alphabet)
     assert len(sv) == 0
     got = sv[2:]
     assert len(got) == 0
@@ -1118,11 +1183,13 @@ def test_seqview_start_out_of_bounds(bytes_alphabet):
     seq = "0123456789"
     init_start, init_stop, init_step = 2, 10, 1
     boundary = abs((init_start - init_stop) // init_step)
+    sr = new_sequence.SliceRecord(
+        start=init_start, stop=init_stop, step=init_step, parent_len=len(seq)
+    )
     sv = new_sequence.SeqView(
-        seq=seq,
-        start=init_start,
-        stop=init_stop,
-        step=init_step,
+        parent=seq,
+        parent_len=len(seq),
+        slice_record=sr,
         alphabet=bytes_alphabet,
     )
     got = sv[boundary::].str_value
@@ -1134,11 +1201,13 @@ def test_seqview_start_out_of_bounds_step_gt_1(bytes_alphabet):
     seq = "0123456789"
     init_start, init_stop, init_step = 2, 10, 2
     boundary = abs((init_start - init_stop) // init_step)
+    sr = new_sequence.SliceRecord(
+        start=init_start, stop=init_stop, step=init_step, parent_len=len(seq)
+    )
     sv = new_sequence.SeqView(
-        seq=seq,
-        start=init_start,
-        stop=init_stop,
-        step=init_step,
+        parent=seq,
+        parent_len=len(seq),
+        slice_record=sr,
         alphabet=bytes_alphabet,
     )
     got = sv[boundary::].str_value
@@ -1152,11 +1221,13 @@ def test_seqview_start_out_of_bounds_reverse_step(bytes_alphabet):
     boundary_pos = abs((init_start - init_stop) // init_step)
     boundary_neg = -abs((init_start - init_stop) // init_step) - 1
 
+    sr = new_sequence.SliceRecord(
+        start=init_start, stop=init_stop, step=init_step, parent_len=len(seq)
+    )
     sv = new_sequence.SeqView(
-        seq=seq,
-        start=init_start,
-        stop=init_stop,
-        step=init_step,
+        parent=seq,
+        parent_len=len(seq),
+        slice_record=sr,
         alphabet=bytes_alphabet,
     )
 
@@ -1177,7 +1248,9 @@ def test_seqview_start_out_of_bounds_reverse_step(bytes_alphabet):
 def test_seqview_defaults(simple_slices, bytes_alphabet):
     """SeqView should accept slices with all combinations of default parameters"""
     seq = "0123456789"
-    got = new_sequence.SeqView(seq=seq, alphabet=bytes_alphabet)[simple_slices]
+    got = new_sequence.SeqView(
+        parent=seq, parent_len=len(seq), alphabet=bytes_alphabet
+    )[simple_slices]
     expected = seq[simple_slices]
     assert got.str_value == expected
 
@@ -1197,7 +1270,7 @@ def test_seqview_defaults(simple_slices, bytes_alphabet):
 def test_seqview_sliced_index(index, simple_slices, bytes_alphabet):
     """SeqView that has been sliced with default parameters, can then be indexed"""
     seq = "0123456789"
-    sv = new_sequence.SeqView(seq=seq, alphabet=bytes_alphabet)
+    sv = new_sequence.SeqView(parent=seq, parent_len=len(seq), alphabet=bytes_alphabet)
     got = sv[simple_slices][index]
     expected = seq[simple_slices][index]
     assert got.str_value == expected
@@ -1208,7 +1281,10 @@ def test_seqview_sliced_index(index, simple_slices, bytes_alphabet):
 def test_seqview_reverse_slice(first_step, second_step, bytes_alphabet):
     """subsequent slices may reverse the previous slice"""
     seq = "0123456789"
-    sv = new_sequence.SeqView(seq=seq, step=first_step, alphabet=bytes_alphabet)
+    sr = new_sequence.SliceRecord(step=first_step, parent_len=len(seq))
+    sv = new_sequence.SeqView(
+        parent=seq, parent_len=len(seq), slice_record=sr, alphabet=bytes_alphabet
+    )
     got = sv[::second_step]
     expected = seq[::first_step][::second_step]
     assert got.str_value == expected
@@ -1226,12 +1302,24 @@ def test_seqview_rev_sliced_index(index, start, stop, step, seq, bytes_alphabet)
         expected = seq_data[start:stop:step][index]
     except IndexError:
         with pytest.raises(IndexError):
+            sr = new_sequence.SliceRecord(
+                start=start, stop=stop, step=step, parent_len=len(seq_data)
+            )
             _ = new_sequence.SeqView(
-                seq=seq_data, start=start, stop=stop, step=step, alphabet=bytes_alphabet
+                parent=seq_data,
+                parent_len=len(seq_data),
+                slice_record=sr,
+                alphabet=bytes_alphabet,
             )[index].str_value
     else:  # if no index error, SeqView should match python slicing
+        sr = new_sequence.SliceRecord(
+            start=start, stop=stop, step=step, parent_len=len(seq_data)
+        )
         got = new_sequence.SeqView(
-            seq=seq_data, start=start, stop=stop, step=step, alphabet=bytes_alphabet
+            parent=seq_data,
+            parent_len=len(seq_data),
+            slice_record=sr,
+            alphabet=bytes_alphabet,
         )[index].str_value
         assert got == expected
 
@@ -1242,8 +1330,11 @@ def test_seqview_rev_sliced_index(index, start, stop, step, seq, bytes_alphabet)
 @pytest.mark.parametrize("step", (1, 2, -1, -2))
 def test_seqview_init_with_negatives(seq, start, stop, step, bytes_alphabet):
     "SeqView initialisation should handle any combination of positive and negative slices"
+    sr = new_sequence.SliceRecord(
+        start=start, stop=stop, step=step, parent_len=len(seq)
+    )
     got = new_sequence.SeqView(
-        seq=seq, start=start, stop=stop, step=step, alphabet=bytes_alphabet
+        parent=seq, parent_len=len(seq), slice_record=sr, alphabet=bytes_alphabet
     )
     expected = seq[start:stop:step]
     assert got.str_value == expected
@@ -1255,7 +1346,7 @@ def test_seqview_init_with_negatives(seq, start, stop, step, bytes_alphabet):
 @pytest.mark.parametrize("step", (1, 2, -1, -2))
 def test_seqview_slice_with_negatives(seq, start, stop, step, bytes_alphabet):
     """SeqView should handle any combination of positive and negative slices"""
-    sv = new_sequence.SeqView(seq=seq, alphabet=bytes_alphabet)
+    sv = new_sequence.SeqView(parent=seq, parent_len=len(seq), alphabet=bytes_alphabet)
     got = sv[start:stop:step]
     expected = seq[start:stop:step]
     assert got.str_value == expected
@@ -1272,7 +1363,7 @@ def test_subsequent_slice_forward(
 ):
     """SeqView should handle subsequent forward slice"""
     seq = "0123456789"
-    sv = new_sequence.SeqView(seq=seq, alphabet=bytes_alphabet)
+    sv = new_sequence.SeqView(parent=seq, parent_len=len(seq), alphabet=bytes_alphabet)
     got = sv[start:stop:step][start_2:stop_2:step_2]
     expected = seq[start:stop:step][start_2:stop_2:step_2]
     assert got.str_value == expected
@@ -1395,7 +1486,9 @@ def test_subsequent_slice_neg_stop(slice_1, slice_2, ascii_alphabet):
     subsequent slices may overlap or be within previous slices
     """
     seq_data = "abcdefghijk"
-    sv = new_sequence.SeqView(seq=seq_data, alphabet=ascii_alphabet)
+    sv = new_sequence.SeqView(
+        parent=seq_data, parent_len=len(seq_data), alphabet=ascii_alphabet
+    )
     assert sv[slice_1][slice_2].str_value == seq_data[slice_1][slice_2]
 
 
@@ -1478,7 +1571,9 @@ def test_subsequent_slice_neg_start(slice_1, slice_2, ascii_alphabet):
     subsequent slices may or may not overlap or be within previous slices
     """
     seq_data = "abcdefghijk"
-    sv = new_sequence.SeqView(seq=seq_data, alphabet=ascii_alphabet)
+    sv = new_sequence.SeqView(
+        parent=seq_data, parent_len=len(seq_data), alphabet=ascii_alphabet
+    )
     assert sv[slice_1][slice_2].str_value == seq_data[slice_1][slice_2]
 
 
@@ -1517,7 +1612,9 @@ def test_subsequent_slice_neg_step(slice_1, slice_2, ascii_alphabet):
     subsequent slices may overlap or be within previous slices
     """
     seq_data = "0123456789"
-    sv = new_sequence.SeqView(seq=seq_data, alphabet=ascii_alphabet)
+    sv = new_sequence.SeqView(
+        parent=seq_data, parent_len=len(seq_data), alphabet=ascii_alphabet
+    )
     assert sv[slice_1][slice_2].str_value == seq_data[slice_1][slice_2]
 
 
@@ -1533,7 +1630,9 @@ def test_subsequent_slice_neg_step(slice_1, slice_2, ascii_alphabet):
 def test_subslice_3(sub_slices_triple, ascii_alphabet):
     """SeqView should handle three subsequent slices"""
     seq_data = "abcdefghijk"
-    sv = new_sequence.SeqView(seq=seq_data, alphabet=ascii_alphabet)
+    sv = new_sequence.SeqView(
+        parent=seq_data, parent_len=len(seq_data), alphabet=ascii_alphabet
+    )
     slice_1, slice_2, slice_3 = sub_slices_triple
     assert (
         sv[slice_1][slice_2][slice_3].str_value == seq_data[slice_1][slice_2][slice_3]
@@ -1553,7 +1652,7 @@ def test_triple_slice(
     integer_seq, start, stop, step, start_2, stop_2, step_2, start_3, stop_3, step_3
 ):
     """SeqView should handle subsequent forward slice"""
-    seq = integer_seq.seq
+    seq = integer_seq.parent
     got = integer_seq[start:stop:step][start_2:stop_2:step_2][start_3:stop_3:step_3]
     expected = seq[start:stop:step][start_2:stop_2:step_2][start_3:stop_3:step_3]
 
@@ -1565,39 +1664,56 @@ def test_seqview_repr():
     alpha = new_moltype.DNA.most_degen_alphabet()
     # Short sequence, defaults
     seq = "ACGT"
-    view = new_sequence.SeqView(seq=seq, alphabet=alpha)
-    expected = (
-        "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=0, seqid=None, seq_len=4)"
-    )
+    view = new_sequence.SeqView(parent=seq, parent_len=len(seq), alphabet=alpha)
+    expected = f"SeqView(seqid=None, parent='ACGT', slice_record={view.slice_record!r})"
     assert repr(view) == expected
 
     # Long sequence
     seq = "ACGT" * 10
-    view = new_sequence.SeqView(seq=seq, alphabet=alpha)
-    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=0, stop=40, step=1, offset=0, seqid=None, seq_len=40)"
+    view = new_sequence.SeqView(parent=seq, parent_len=len(seq), alphabet=alpha)
+    expected = f"SeqView(seqid=None, parent='ACGTACGTAC...TACGT', slice_record={view.slice_record!r})"
     assert repr(view) == expected
 
-    # Non-zero start, stop, and step values
+    # Non-zero slice record
     seq = "ACGT" * 10
-    view = new_sequence.SeqView(seq=seq, start=5, stop=35, step=2, alphabet=alpha)
-    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=5, stop=35, step=2, offset=0, seqid=None, seq_len=40)"
+    sr = new_sequence.SliceRecord(start=5, stop=35, step=2, parent_len=len(seq))
+    view = new_sequence.SeqView(
+        parent=seq, parent_len=len(seq), alphabet=alpha, slice_record=sr
+    )
+    expected = f"SeqView(seqid=None, parent='ACGTACGTAC...TACGT', slice_record={view.slice_record!r})"
     assert repr(view) == expected
 
-    # offset
+    # slice record with an offset
     seq = "ACGT"
-    view = new_sequence.SeqView(seq=seq, offset=5, alphabet=alpha)
-    expected = (
-        "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=5, seqid=None, seq_len=4)"
+    sr = new_sequence.SliceRecord(offset=5, parent_len=len(seq))
+    view = new_sequence.SeqView(
+        parent=seq, parent_len=len(seq), alphabet=alpha, slice_record=sr
     )
+    expected = f"SeqView(seqid=None, parent='ACGT', slice_record={view.slice_record!r})"
     assert repr(view) == expected
 
     # seqid
     seq = "ACGT"
-    view = new_sequence.SeqView(seq=seq, seqid="seq1", alphabet=alpha)
-    expected = "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=0, seqid='seq1', seq_len=4)"
+    view = new_sequence.SeqView(
+        parent=seq, parent_len=len(seq), seqid="seq1", alphabet=alpha
+    )
+    expected = (
+        f"SeqView(seqid='seq1', parent='ACGT', slice_record={view.slice_record!r})"
+    )
     assert repr(view) == expected
 
 
+def test_slice_record_repr():
+    # defaults
+    sr = new_sequence.SliceRecord(parent_len=20)
+    expected = "SliceRecord(start=0, stop=20, step=1, parent_len=20, offset=0)"
+    assert repr(sr) == expected
+
+    sr = new_sequence.SliceRecord(start=1, stop=10, step=2, parent_len=20, offset=5)
+    expected = "SliceRecord(start=1, stop=10, step=2, parent_len=20, offset=5)"
+    assert repr(sr) == expected
+
+
 @pytest.mark.parametrize("k", range(1, 7))
 def test_iter_kmers_returns_generator(k):
     orig = "TCAGGA"
@@ -1902,44 +2018,44 @@ def test_annotation_from_slice_with_stride():
 
 def test_absolute_position_base_cases(one_seq):
     """with no offset or view, the absolute index should remain unchanged"""
-    got = one_seq._seq.absolute_position(5)
+    got = one_seq._seq.slice_record.absolute_position(5)
     assert got == 5
 
     # an index outside the range of the sequence should raise an IndexError
     with pytest.raises(IndexError):
-        one_seq._seq.absolute_position(20)
+        one_seq._seq.slice_record.absolute_position(20)
 
     with pytest.raises(IndexError):
-        one_seq._seq.absolute_position(-20)
+        one_seq._seq.slice_record.absolute_position(-20)
 
 
 def test_absolute_position_positive(one_seq):
     # with an offset, the abs index should be offset + index
-    one_seq._seq.offset = 2
-    got = one_seq._seq.absolute_position(2)
+    one_seq._seq = one_seq._seq.with_offset(2)
+    view = one_seq._seq
+    got = view.slice_record.absolute_position(2)
     assert got == 2 + 2
 
     # with an offset and start, the abs index should be offset + start + index
-    view = one_seq[2::]
-    view._seq.offset = 2  # todo: do we want the annotation_offset to be preserved when slicing? I think yes
-    got = view._seq.absolute_position(2)
+    seq = one_seq[2::]
+    view = seq._seq  # todo: do we want the annotation_offset to be preserved when slicing? I think yes
+    got = view.slice_record.absolute_position(2)
     assert got == 2 + 2 + 2
 
     # with an offset, start and step, the abs index should be offset + start + index * step
-    view = one_seq[2::2]
-    view._seq.offset = 2
-    got = view._seq.absolute_position(2)
+    seq = one_seq[2::2]
+    got = seq._seq.slice_record.absolute_position(2)
     assert got == 2 + 2 + 2 * 2
 
 
 def test_relative_position_base_cases(one_seq):
     """with no offset or view, the absolute index should remain unchanged"""
-    got = one_seq._seq.relative_position(5)
+    got = one_seq._seq.slice_record.relative_position(5)
     assert got == 5
 
     # a -ve index  should raise an IndexError
     with pytest.raises(IndexError):
-        one_seq._seq.relative_position(-5)
+        one_seq._seq.slice_record.relative_position(-5)
 
 
 def test_relative_position(integer_seq):
@@ -1948,11 +2064,11 @@ def test_relative_position(integer_seq):
 
     view = integer_seq[1:9:]
     # view = "12345678"
-    got = view.relative_position(0)
+    got = view.slice_record.relative_position(0)
     # precedes the view, so should return -1
     assert got == -1
     # exceeds the view, but still returns a value
-    got = view.relative_position(10)
+    got = view.slice_record.relative_position(10)
     assert got == 9
 
 
@@ -1964,20 +2080,20 @@ def test_relative_position_step_GT_one(integer_seq):
     # precedes the view, with step > 1
     view = integer_seq[2:7:2]
     # view = "246", precedes the view by 1 step
-    got = view.relative_position(0)
+    got = view.slice_record.relative_position(0)
     assert got == -1
     # precedes the view by 0.5 step, default behaviour is to round up to 0
-    got = view.relative_position(1)
+    got = view.slice_record.relative_position(1)
     assert got == 0
     # exceeds the view by two steps, len(view) + 2 = 4
-    got = view.relative_position(10)
+    got = view.slice_record.relative_position(10)
     assert got == 4
 
 
 @pytest.mark.parametrize("sliced", (False, True))
 @pytest.mark.parametrize("rev", (False, True))
 def test_seqview_copy(sliced, rev, integer_seq):
-    raw_data = integer_seq.seq
+    raw_data = integer_seq.parent
     integer_seq = integer_seq[::-1] if rev else integer_seq
     raw_data = raw_data[::-1] if rev else raw_data
 
@@ -1987,8 +2103,9 @@ def test_seqview_copy(sliced, rev, integer_seq):
     copied = sv.copy(sliced=sliced)
 
     assert copied.str_value == raw_data[slice_start:slice_end]
-    assert copied.is_reversed == integer_seq.is_reversed
-    assert sliced and copied.seq is not sv.seq or copied.seq is integer_seq.seq
+    assert (
+        sliced and copied.parent is not sv.parent or copied.parent is integer_seq.parent
+    )
 
 
 def test_relative_position_with_remainder(integer_seq):
@@ -1996,12 +2113,12 @@ def test_relative_position_with_remainder(integer_seq):
     a position that is 'stepped over'"""
     view = integer_seq[1:9:2]
     # view = "1357"
-    got = view.relative_position(2)
+    got = view.slice_record.relative_position(2)
     # 2 is stepped over in the view, so we return the index of 3 (which is 1)
     assert got == 1
 
     # setting the arg stop=True will adjust to the largest number, smaller than the given abs value, that is in the view
-    got = view.relative_position(8, stop=True)
+    got = view.slice_record.relative_position(8, stop=True)
     # 8 is excluded from the view, so we return the index of 7 (which is 3)
     assert got == 3
 
@@ -2013,10 +2130,10 @@ def test_relative_position_with_remainder(integer_seq):
 @pytest.mark.parametrize("step", (None, 1, 2))
 def test_absolute_relative_roundtrip(one_seq, value, offset, start, stop, step):
     # a round trip from relative to absolute then from absolute to relative, should return the same value we began with
-    view = one_seq[start:stop:step]
-    view._seq.offset = offset or 0
-    abs_val = view._seq.absolute_position(value)
-    rel_val = view._seq.relative_position(abs_val)
+    seq = one_seq[start:stop:step]
+    seq._seq.with_offset(offset or 0)
+    abs_val = seq._seq.slice_record.absolute_position(value)
+    rel_val = seq._seq.slice_record.relative_position(abs_val)
     assert rel_val == value
 
 
@@ -2030,10 +2147,10 @@ def test_absolute_relative_roundtrip_reverse(
 ):
     # a round trip from relative to absolute then from absolute to relative, should return the same value we began with
     view = integer_seq[start:stop:step]
-    view.offset = offset or 0
-    abs_val = view.absolute_position(value)
-    rel_val = view.relative_position(abs_val)
-    assert view.offset == (offset or 0)
+    view.slice_record.offset = offset or 0
+    abs_val = view.slice_record.absolute_position(value)
+    rel_val = view.slice_record.relative_position(abs_val)
+    assert view.slice_record.offset == (offset or 0)
     assert (view[rel_val]).str_value == view[value].str_value
 
 
@@ -2098,17 +2215,14 @@ def test_to_moltype_rna():
 
 
 def test_to_rich_dict():
-    """Sequence to_dict works"""
+    """Sequence.to_rich_dict works"""
     dna = new_moltype.DNA
-    r = dna.make_seq(seq="AAGGCC", name="seq1")
+    s = "AAGGCC"
+    r = dna.make_seq(seq=s, name="seq1")
     got = r.to_rich_dict()
-    seq = new_sequence.SeqView(
-        seq="AAGGCC", seqid="seq1", alphabet=dna.most_degen_alphabet()
-    ).to_rich_dict()
-
     expect = {
         "name": "seq1",
-        "seq": seq,
+        "seq": s,
         "moltype": r.moltype.label,
         "info": None,
         "type": get_object_provenance(r),
@@ -2124,13 +2238,10 @@ def test_sequence_to_json():
     dna = new_moltype.DNA
     r = dna.make_seq(seq="AAGGCC", name="seq1")
     got = json.loads(r.to_json())
-    seq = new_sequence.SeqView(
-        seq="AAGGCC", seqid="seq1", alphabet=dna.most_degen_alphabet()
-    ).to_rich_dict()
-
+    data = "AAGGCC"
     expect = {
         "name": "seq1",
-        "seq": seq,
+        "seq": data,
         "moltype": dna.label,
         "info": None,
         "type": get_object_provenance(r),
@@ -2141,30 +2252,6 @@ def test_sequence_to_json():
     assert got == expect
 
 
-@pytest.mark.parametrize("coord", ("start", "stop"))
-def test_seqview_to_rich_dict(coord, dna_alphabet):
-    parent = "ACCCCGGAAAATTTTTTTTTAAGGGGGAAAAAAAAACCCCCCC"
-    sv = new_sequence.SeqView(seq=parent, alphabet=dna_alphabet)
-    plus = sv.to_rich_dict()
-    minus = sv[::-1].to_rich_dict()
-    plus = plus.pop("init_args")
-    minus = minus.pop("init_args")
-    assert plus.pop("seq") == minus.pop("seq")
-    assert plus["step"] == -minus["step"]
-    assert coord not in plus
-    assert coord not in minus
-
-
-@pytest.mark.parametrize("reverse", (False, True))
-def test_sliced_seqview_rich_dict(reverse, dna_alphabet):
-    parent = "ACCCCGGAAAATTTTTTTTTAAGGGGGAAAAAAAAACCCCCCC"
-    sl = slice(2, 13)
-    sv = new_sequence.SeqView(seq=parent, alphabet=dna_alphabet)[sl]
-    sv = sv[::-1] if reverse else sv
-    rd = sv.to_rich_dict()
-    assert rd["init_args"]["seq"] == parent[sl]
-
-
 @pytest.mark.parametrize(
     "sl",
     (
@@ -2179,13 +2266,18 @@ def test_parent_start_stop(sl, offset, ascii_alphabet):
     data = "0123456789"
     # check our slice matches the expectation for rest of test
     expect = "234" if sl.step > 0 else "432"
-    sv = new_sequence.SeqView(seq=data, alphabet=ascii_alphabet)
-    sv.offset = offset
+    sv = new_sequence.SeqView(
+        parent=data, parent_len=len(data), alphabet=ascii_alphabet, offset=offset
+    )
     sv = sv[sl]
+    assert sv.offset == offset
     assert sv.str_value == expect
     # now check that start / stop are always the same
     # irrespective of step sign
-    assert (sv.parent_start, sv.parent_stop) == (2 + offset, 5 + offset)
+    assert (sv.slice_record.parent_start, sv.slice_record.parent_stop) == (
+        2 + offset,
+        5 + offset,
+    )
 
 
 @pytest.mark.parametrize(
@@ -2199,12 +2291,14 @@ def test_parent_start_stop_limits(sl, ascii_alphabet):
     data = "0123456789"
     # check our slice matches the expectation for rest of test
     expect = data[sl]
-    sv = new_sequence.SeqView(seq=data, alphabet=ascii_alphabet)
+    sv = new_sequence.SeqView(
+        parent=data, parent_len=len(data), alphabet=ascii_alphabet
+    )
     sv = sv[sl]
     assert sv.str_value == expect
     # now check that start / stop are always the same
     # irrespective of step sign
-    assert (sv.parent_start, sv.parent_stop) == (0, 10)
+    assert (sv.slice_record.parent_start, sv.slice_record.parent_stop) == (0, 10)
 
 
 @pytest.mark.parametrize("rev", (False, True))
@@ -2212,12 +2306,14 @@ def test_parent_start_stop_empty(rev, ascii_alphabet):
     data = "0123456789"
     # check our slice matches the expectation for rest of test
     expect = ""
-    sv = new_sequence.SeqView(seq=data, alphabet=ascii_alphabet)
+    sv = new_sequence.SeqView(
+        parent=data, parent_len=len(data), alphabet=ascii_alphabet
+    )
     sv = sv[0 : 0 : -1 if rev else 1]
     assert sv.str_value == expect
     # now check that start / stop are always the same
     # irrespective of step sign
-    assert (sv.parent_start, sv.parent_stop) == (0, 0)
+    assert (sv.slice_record.parent_start, sv.slice_record.parent_stop) == (0, 0)
 
 
 @pytest.mark.parametrize("rev", (False, True))
@@ -2228,12 +2324,17 @@ def test_parent_start_stop_singletons(index, rev, ascii_alphabet):
     sl = slice(start, stop, -1 if rev else 1)
     # check our slice matches the expectation for rest of test
     expect = data[sl]
-    sv = new_sequence.SeqView(seq=data, alphabet=ascii_alphabet)
+    sv = new_sequence.SeqView(
+        parent=data, parent_len=len(data), alphabet=ascii_alphabet
+    )
     sv = sv[sl]
     assert sv.str_value == expect
     # now check that start / stop are always the same
     # irrespective of step sign
-    assert (sv.parent_start, sv.parent_stop) == (index, index + 1)
+    assert (sv.slice_record.parent_start, sv.slice_record.parent_stop) == (
+        index,
+        index + 1,
+    )
 
 
 def test_get_drawable(DATA_DIR):
@@ -2405,7 +2506,7 @@ def test_coerce_to_seqview_already_seqview(dna_alphabet):
     seq = "AC--GGTGGGAC"
     seqid = "seq1"
     got = new_sequence._coerce_to_seqview(
-        new_sequence.SeqView(seq=seq, alphabet=dna_alphabet),
+        new_sequence.SeqView(parent=seq, parent_len=len(seq), alphabet=dna_alphabet),
         seqid,
         alphabet=dna_alphabet,
         offset=0,
@@ -2415,25 +2516,23 @@ def test_coerce_to_seqview_already_seqview(dna_alphabet):
 
 
 def test_seqview_seqid(dna_alphabet):
-    sv = new_sequence.SeqView(seq="ACGGTGGGAC", alphabet=dna_alphabet)
+    parent = "ACGGTGGGAC"
+    sv = new_sequence.SeqView(
+        parent=parent, parent_len=len(parent), alphabet=dna_alphabet
+    )
     assert sv.seqid is None
 
-    sv = new_sequence.SeqView(seq="ACGGTGGGAC", seqid="seq1", alphabet=dna_alphabet)
+    sv = new_sequence.SeqView(
+        parent=parent, parent_len=len(parent), seqid="seq1", alphabet=dna_alphabet
+    )
     assert sv.seqid == "seq1"
 
 
-def test_seqview_to_rich_dict_seqid(dna_alphabet):
-    sv = new_sequence.SeqView(seq="ACGGTGGGAC", seqid="seq1", alphabet=dna_alphabet)
-    rd = sv.to_rich_dict()
-    assert rd["init_args"]["seqid"] == "seq1"
-
-    sv = new_sequence.SeqView(seq="ACGGTGGGAC", alphabet=dna_alphabet)
-    rd = sv.to_rich_dict()
-    assert rd["init_args"]["seqid"] is None
-
-
 def test_seqview_slice_propagates_seqid(dna_alphabet):
-    sv = new_sequence.SeqView(seq="ACGGTGGGAC", seqid="seq1", alphabet=dna_alphabet)
+    parent = "ACGGTGGGAC"
+    sv = new_sequence.SeqView(
+        parent=parent, parent_len=len(parent), seqid="seq1", alphabet=dna_alphabet
+    )
     sliced_sv = sv[1:8:2]
     assert sliced_sv.seqid == "seq1"
 
@@ -2457,9 +2556,13 @@ def test_sequences_propogates_seqid():
 
 def test_sequences_propogates_seqid_seqview(dna_alphabet):
     # creating a Sequence with a seqview does not change the seqid of the SeqView.
+    parent = "ACGGTGGGAC"
     seq = new_moltype.DNA.make_seq(
         seq=new_sequence.SeqView(
-            seq="ACGGTGGGAC", seqid="parent_name", alphabet=dna_alphabet
+            parent=parent,
+            parent_len=len(parent),
+            seqid="parent_name",
+            alphabet=dna_alphabet,
         ),
         name="seq_name",
     )
@@ -2468,7 +2571,9 @@ def test_sequences_propogates_seqid_seqview(dna_alphabet):
 
     # creating a Sequence with an unnamed seqview does not name the SeqView.
     seq = new_moltype.DNA.make_seq(
-        seq=new_sequence.SeqView(seq="ACGGTGGGAC", alphabet=dna_alphabet),
+        seq=new_sequence.SeqView(
+            parent=parent, parent_len=len(parent), alphabet=dna_alphabet
+        ),
         name="seq_name",
     )
     assert seq.name == "seq_name"
@@ -2481,9 +2586,9 @@ def test_make_seq_assigns_to_seqview():
 
 
 def test_empty_seqview_translate_position(dna_alphabet):
-    sv = new_sequence.SeqView(seq="", alphabet=dna_alphabet)
-    assert sv.absolute_position(0) == 0
-    assert sv.relative_position(0) == 0
+    sv = new_sequence.SeqView(parent="", parent_len=0, alphabet=dna_alphabet)
+    assert sv.slice_record.absolute_position(0) == 0
+    assert sv.slice_record.relative_position(0) == 0
 
 
 @pytest.mark.parametrize("start", (None, 0, 1, 10, -1, -10))
@@ -2493,35 +2598,121 @@ def test_empty_seqview_translate_position(dna_alphabet):
 def test_seqview_seq_len_init(start, stop, step, length, dna_alphabet):
     # seq_len is length of seq when None
     seq_data = "A" * length
+    sr = new_sequence.SliceRecord(start=start, stop=stop, step=step, parent_len=length)
     sv = new_sequence.SeqView(
-        seq=seq_data, start=start, stop=stop, step=step, alphabet=dna_alphabet
+        parent=seq_data,
+        parent_len=len(seq_data),
+        slice_record=sr,
+        alphabet=dna_alphabet,
     )
     expect = len(seq_data)
     # Check property and slot
-    assert sv.seq_len == expect
-    assert sv._seq_len == expect
+    assert sv.parent_len == expect
+    assert sv._parent_len == expect
 
 
-@pytest.mark.parametrize("seq, seq_len", [("A", 0), ("", 1), ("A", 2)])
-def test_seqview_seq_len_mismatch(seq, seq_len, dna_alphabet):
-    # If provided, seq_len must match len(seq)
-    with pytest.raises(AssertionError):
-        new_sequence.SeqView(seq=seq, seq_len=seq_len, alphabet=dna_alphabet)
+@pytest.mark.parametrize("step", (1, 3))
+@pytest.mark.parametrize("rev", (False, True))
+def test_seqview_plus_attrs(rev, step):
+    # always equal values from plus strand
+    start = 2
+    stop = 12
+    sr = new_sequence.SliceRecord(start=start, stop=stop, step=step, parent_len=30)
+    sr = sr[::-1] if rev else sr
+    assert (sr.plus_start, sr.plus_stop, sr.plus_step) == (start, stop, step)
+
+
+@pytest.mark.parametrize("rev", (False, True))
+def test_seqview_attrs_non_modulo(rev):
+    # for when the stop value does not align with the step, the plus_[stop/start]
+    # is adjusted
+
+    # start = 1, stop = 10, step = 2
+    #   [  -->  )
+    # 0123456789
+    #   * * * *
+    # we expect the plus_stop to be 9, as this is the index immediately following
+    # the last selected index
+    sr = new_sequence.SliceRecord(start=2, stop=10, step=2, parent_len=10)
+    sr = sr[::-1] if rev else sr
+    assert sr.plus_start == 2
+    assert sr.plus_stop == 9
+
+    # start = -4, stop = -10, step = -2
+    # ( <-- ]
+    # 0123456789
+    #   * * *
+    # although a stop of -10 would be a start of 1 for step == 1, we expect
+    # in this case the plus_start to be 2
+    sr = new_sequence.SliceRecord(start=-4, stop=-10, step=-2, parent_len=10)
+    sr = sr[::-1] if rev else sr
+    assert sr.plus_start == 2
+    assert sr.plus_stop == 7
+
+    # start = 1, stop = 7, step = 3
+    #  [ --> )
+    # 0123456789
+    #  *  *
+    sr = new_sequence.SliceRecord(start=1, stop=7, step=3, parent_len=10)
+    sr = sr[::-1] if rev else sr
+    assert sr.plus_start == 1
+    assert sr.plus_stop == 5
+
+    # start = -3, stop = -9, step = -3
+    #  (     ]
+    # 0123456789
+    #     *  *
+    sr = new_sequence.SliceRecord(start=-3, stop=-9, step=-3, parent_len=10)
+    sr = sr[::-1] if rev else sr
+    assert sr.plus_start == 4
+    assert sr.plus_stop == 8
+
+    sr = new_sequence.SliceRecord(start=0, stop=0, step=1, parent_len=10)
+    sr = sr[::-1] if rev else sr
+    assert sr.plus_start == 0
+    assert sr.plus_stop == 0
+
+
+@pytest.mark.parametrize("rev", (False, True))
+def test_seqview_attrs_zero_slice(rev):
+    sr = new_sequence.SliceRecord(start=0, stop=0, step=1, parent_len=0)
+    sr = sr[::-1] if rev else sr
+    assert sr.plus_start == 0
+    assert sr.plus_stop == 0
 
 
 def test_seqview_copy_propagates_seq_len(dna_alphabet):
     seq = "ACGGTGGGAC"
-    sv = new_sequence.SeqView(seq=seq, alphabet=dna_alphabet)
+    sv = new_sequence.SeqView(parent=seq, parent_len=len(seq), alphabet=dna_alphabet)
     copied = sv.copy()
-    assert copied.seq_len == len(seq)
+    assert copied.parent_len == len(seq)
 
 
 def test_seqview_seq_len_modified_seq(dna_alphabet):
     seq = "ACGGTGGGAC"
-    sv = new_sequence.SeqView(seq=seq, alphabet=dna_alphabet)
+    sv = new_sequence.SeqView(parent=seq, parent_len=len(seq), alphabet=dna_alphabet)
+
+    sv.parent = "ATGC"  # this should not modify seq_len
+    assert sv.parent_len == len(seq)
+
+
+@pytest.mark.parametrize("offset", [0, 4])
+def test_seqview_with_offset(offset, dna_alphabet):
+    seq = "ACGGTGGGAC"
+    sv = new_sequence.SeqView(parent=seq, parent_len=len(seq), alphabet=dna_alphabet)
+    got = sv.with_offset(offset)
+    assert got is not sv
+    assert got.offset == offset
 
-    sv.seq = "ATGC"  # this should not modify seq_len
-    assert sv.seq_len == len(seq)
+
+@pytest.mark.parametrize("offset", [0, 4])
+def test_seqview_with_offset_fails(offset, dna_alphabet):
+    seq = "ACGGTGGGAC"
+    sv = new_sequence.SeqView(
+        parent=seq, parent_len=len(seq), alphabet=dna_alphabet, offset=1
+    )
+    with pytest.raises(ValueError):
+        _ = sv.with_offset(offset)
 
 
 def test_sequence_str_bytes_array():
@@ -2534,6 +2725,23 @@ def test_sequence_str_bytes_array():
     )
 
 
+def test_sequence_to_array_method():
+    data = "ACGGTGGGAC"
+    the_slice = slice(2, 6)
+    dna = new_moltype.DNA
+    expect_plus = dna.alphabet.to_indices(data[the_slice])
+    expect_minus = dna.alphabet.to_indices(dna.rc(data[the_slice]))
+    seq = new_moltype.DNA.make_seq(seq=data)[the_slice]
+    # no transforms applied, so both options give same plus strand array
+    assert numpy.array_equal(seq.to_array(apply_transforms=True), expect_plus)
+    assert numpy.array_equal(seq.to_array(apply_transforms=False), expect_plus)
+    rc = seq.rc()
+    # transform applied, equals minus strand
+    assert numpy.array_equal(rc.to_array(apply_transforms=True), expect_minus)
+    # transform applied, not doing it returns the plus strand
+    assert numpy.array_equal(rc.to_array(apply_transforms=False), expect_plus)
+
+
 @pytest.mark.parametrize("seq,rc", (("ATGTTT", False), ("AAACAT", True)))
 def test_translation(seq, rc):
     seq = new_moltype.DNA.make_seq(seq=seq)
@@ -2599,7 +2807,9 @@ def test_load_invalid_moltype(aa_moltype):
 
 @pytest.fixture(params=(new_moltype.DNA.alphabet, new_moltype.DNA.gapped_alphabet))
 def seqview(request):
-    return new_sequence.SeqView(seq="ACGT", alphabet=request.param, seqid="seq1")
+    return new_sequence.SeqView(
+        parent="ACGT", parent_len=4, alphabet=request.param, seqid="seq1"
+    )
 
 
 @pytest.mark.parametrize(
@@ -2621,7 +2831,19 @@ def test_make_seq_general_alpha_incompatible(seqview):
 
 def test_make_seq_wrong_order_alpha():
     sv = new_sequence.SeqView(
-        seq="ACGT", alphabet=new_moltype.DNA.gapped_alphabet, seqid="seq1"
+        parent="ACGT",
+        parent_len=4,
+        alphabet=new_moltype.DNA.gapped_alphabet,
+        seqid="seq1",
     )
     with pytest.raises(new_alphabet.AlphabetError):
         new_sequence._coerce_to_seqview(sv, sv.seqid, new_moltype.DNA.degen_alphabet, 0)
+
+
+@pytest.mark.parametrize(
+    "raw_seq",
+    ("GGTAC", "GGTAC".encode("utf8"), numpy.array([3, 3, 0, 2, 1], dtype=numpy.uint8)),
+)
+def test_make_seq_from_types(raw_seq):
+    seq = new_moltype.DNA.make_seq(seq=raw_seq)
+    assert str(seq) == "GGTAC"
diff --git a/tests/test_draw/test_draw_integration.py b/tests/test_draw/test_draw_integration.py
index ccacdf408..5a3ca7843 100644
--- a/tests/test_draw/test_draw_integration.py
+++ b/tests/test_draw/test_draw_integration.py
@@ -250,7 +250,7 @@ def _check_drawable_styles(self, method, styles, **kwargs):
             obj = method(drawable=style, **kwargs)
             self._check_drawable_attrs(obj.drawable.figure, style)
 
-    def test_dotplot_regression(self):
+    def test_dotplot_regression(self):  # ported
         """Tests whether dotplot produces traces and in correct ordering."""
         aln = load_aligned_seqs("data/brca1.fasta", moltype="dna")
         aln = aln.take_seqs(["Human", "Chimpanzee"])
@@ -277,7 +277,7 @@ def test_dotplot_regression(self):
                 "Trace name still present in dp traces even after popping off trace",
             )
 
-    def test_dotplot_annotated(self):
+    def test_dotplot_annotated(self):  # ported
         """alignment with / without annotated seqs"""
         from cogent3.draw.dotplot import Dotplot
 
@@ -314,7 +314,7 @@ def test_dotplot_annotated(self):
         self.assertIsInstance(dp.bottom_track, Drawable)
         self.assertIsInstance(dp.left_track, Drawable)
 
-    def test_annotated_dotplot_remove_tracks(self):
+    def test_annotated_dotplot_remove_tracks(self):  # ported
         """removing annotation tracks from dotplot should work"""
         # both annotated
         aln = load_alignment(True, True)
@@ -344,7 +344,7 @@ def test_annotated_dotplot_remove_tracks(self):
         self.assertIsNot(dp.figure, orig_fig)
         self.assertIsInstance(dp.figure, UnionDict)
 
-    def test_count_gaps_per_seq(self):
+    def test_count_gaps_per_seq(self):  # ported
         """creation of drawables works"""
         styles = "bar", "box", "violin"
         aln = load_alignment(False, False)
@@ -356,7 +356,7 @@ def test_count_gaps_per_seq(self):
         aln = aln.to_type(array_align=True)
         self._check_drawable_styles(aln.count_gaps_per_seq, styles)
 
-    def test_coevo_drawables(self):
+    def test_coevo_drawables(self):  # ported
         """coevolution produces drawables"""
         styles = "box", "heatmap", "violin"
         aln = load_alignment(False, False)
@@ -368,7 +368,7 @@ def test_coevo_drawables(self):
         aln = aln.to_type(array_align=True)
         self._check_drawable_styles(aln.coevolution, styles, show_progress=False)
 
-    def test_coevo_annotated(self):
+    def test_coevo_annotated(self):  # ported
         """coevolution on alignment with annotated seqs should add to heatmap plot"""
         aln = load_alignment(True, False)
         aln = aln[:30]
@@ -378,7 +378,7 @@ def test_coevo_annotated(self):
         self.assertIsInstance(drawable.left_track, Drawable)
         self.assertIsInstance(drawable.bottom_track, Drawable)
 
-    def test_information_plot(self):
+    def test_information_plot(self):  # ported
         """infoprmation plot makes a drawable"""
         aln = load_alignment(False, False)
         drawable = aln.information_plot()
@@ -389,7 +389,7 @@ def test_information_plot(self):
         drawable = aln.information_plot()
         self._check_drawable_attrs(drawable.figure, "scatter")
 
-    def test_get_drawable(self):
+    def test_get_drawable(self):  # ported
         """sliced alignment with features returns a drawable"""
         aln = make_aligned_seqs(
             data=dict(a="AAACGGTTT", b="CAA--GTAA"), array_align=False
diff --git a/tests/test_draw/test_new_draw_integration.py b/tests/test_draw/test_new_draw_integration.py
new file mode 100644
index 000000000..ca0e8aabf
--- /dev/null
+++ b/tests/test_draw/test_new_draw_integration.py
@@ -0,0 +1,327 @@
+import pathlib
+
+import pytest
+
+from cogent3 import load_aligned_seqs, load_seq
+from cogent3.core import new_alignment
+from cogent3.core.annotation_db import GffAnnotationDb
+from cogent3.draw.dotplot import Dotplot
+from cogent3.draw.drawable import AnnotatedDrawable, Drawable
+from cogent3.util.union_dict import UnionDict
+
+
+@pytest.fixture(scope="function")
+def seqs_dict():
+    return {
+        "seq1": "CAGGTCGACCTCGGC---------CACGAC",
+        "seq2": "CAGATCGACCTCGGC---------CACGAC",
+        "seq3": "CAGATCGACCTCGGT---------CACGAT",
+        "seq4": "CAGATCGACCTCGGCGAACACGGCCATGAT",
+        "seq5": "CCGATCGACATGGGC---------CACGAT",
+        "seq6": "GCC---------------------------",
+    }
+
+
+@pytest.fixture(scope="function")
+def annotated_seq(DATA_DIR):
+    return load_seq(
+        DATA_DIR / "c_elegans_WS199_dna_shortened.fasta",
+        annotation_path=DATA_DIR / "c_elegans_WS199_shortened_gff.gff3",
+        moltype="dna",
+        new_type=True,
+    )
+
+
+@pytest.fixture
+def load_alignment():
+    """Fixture to create an alignment with None, one, or two sequences annotated."""
+
+    def _load_alignment(annotate1=False, annotate2=False):
+        db = GffAnnotationDb()
+
+        path = str(pathlib.Path(__file__).parent.parent / "data/brca1_5.paml")
+        aln = load_aligned_seqs(path, moltype="dna", new_type=True)
+        aln = aln.omit_gap_pos()
+        if annotate1:
+            db.add_feature(
+                seqid=aln.names[0], biotype="gene", name="abcde1", spans=[(20, 50)]
+            )
+            db.add_feature(
+                seqid=aln.names[0], biotype="variation", name="one", spans=[(11, 12)]
+            )
+        if annotate2:
+            db.add_feature(
+                seqid=aln.names[1], biotype="gene", name="abcde2", spans=[(20, 50)]
+            )
+            db.add_feature(
+                seqid=aln.names[1], biotype="domain", name="abcde2", spans=[(10, 15)]
+            )
+        aln.annotation_db = db
+        return aln
+
+    return _load_alignment
+
+
+def check_drawable_attrs(fig, type_):
+    assert isinstance(fig, UnionDict), f"Expected fig to be UnionDict, got {type(fig)}"
+    # should have a layout and a data key
+    assert "layout" in fig, "Expected 'layout' key in fig"
+    assert "data" in fig, "Expected 'data' key in fig"
+    # data traces should be of type "scatter"
+    assert {tr.type for tr in fig.data} == {
+        type_
+    }, f"Expected data traces of type {type_}"
+
+
+def check_drawable_styles(method, styles, **kwargs):
+    for style in styles:
+        obj = method(drawable=style, **kwargs)
+        check_drawable_attrs(obj.drawable.figure, style)
+
+
+@pytest.fixture(scope="function")
+def dotplot_seqs():
+    data = {
+        "Human": "CAGATTTGGCAGTT-",
+        "Mouse": "CAGATTCAGCAGGTG",
+        "Rat": "CAGATTCAGCAGG--G",
+    }
+    return new_alignment.make_unaligned_seqs(data, moltype="dna")
+
+
+def test_dotplot_base_cases(dotplot_seqs):
+    """exercising dotplot method"""
+    # with provided names
+    plot = dotplot_seqs.dotplot(name1="Human", name2="Mouse")
+    assert str(plot.seq1) != str(plot.seq2)
+
+    # without providing names
+    plot = dotplot_seqs.dotplot()
+    assert str(plot.seq1) != str(plot.seq2)
+
+    # providing only one name
+    plot = dotplot_seqs.dotplot(name1="Human")
+    assert str(plot.seq1) != str(plot.seq2)
+
+    # a collection of one sequence should make dotplot with itself
+    less_seqs = dotplot_seqs.take_seqs("Human")
+    plot = less_seqs.dotplot()
+    assert str(plot.seq1) == str(plot.seq2)
+
+    # k larger than window should raise an error
+    with pytest.raises(AssertionError):
+        dotplot_seqs.dotplot(window=5, k=11)
+
+    # names not in the collection should raise an error
+    with pytest.raises(ValueError):
+        dotplot_seqs.dotplot(name1="Human", name2="Dog")
+
+
+@pytest.mark.parametrize("with_annotations", [True, False])
+@pytest.mark.parametrize(
+    "mk_cls", [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs]
+)
+def test_dotplot_annotated(annotated_seq, with_annotations, mk_cls):
+    if not with_annotations:
+        annotated_seq.replace_annotation_db(None)  # this drops all annotations
+
+    coll = mk_cls({"c_elegans": annotated_seq}, moltype="dna")
+    dp = coll.dotplot()
+    if with_annotations:
+        assert len(dp.figure.data) > 2
+    else:
+        assert len(dp.figure.data) == 2
+
+
+def test_dotplot_regression():
+    """Tests whether dotplot produces traces and in correct ordering."""
+    aln = load_aligned_seqs("data/brca1.fasta", moltype="dna", new_type=True)
+    aln = aln.take_seqs(["Human", "Chimpanzee"])
+    aln = aln[:200]
+    dp = aln.dotplot()
+    _ = dp.figure
+    trace_names = [tr.name for tr in dp.traces]
+
+    assert [tr.name for tr in dp.traces] != [] and len(trace_names) == len(
+        dp.traces
+    ), "No traces found for dotplot"
+    assert all(
+        trace_names[i] == dp.traces[i]["name"] for i in range(len(trace_names))
+    ), "Order of traces doesn't match with dp traces"
+
+    for trace_name in trace_names:
+        index = [tr.name for tr in dp.traces].index(trace_name)
+        dp.traces.pop(index)
+        assert trace_name not in [
+            tr.name for tr in dp.traces
+        ], "Trace name still present in dp traces even after popping off trace"
+
+
+def test_dotplot_with_diff_annotation_permutations(load_alignment):
+    """alignment with / without annotated seqs"""
+
+    # no annotation
+    aln = load_alignment()
+    dp = aln.dotplot(name1=aln.names[0], name2=aln.names[1])
+    check_drawable_attrs(dp.figure, "scatter")
+    assert isinstance(dp, Dotplot)
+
+    # seq1 annotated
+    aln = load_alignment(annotate1=True)
+    dp = aln.dotplot(name1=aln.names[0], name2=aln.names[1])
+    assert isinstance(dp, AnnotatedDrawable)
+    fig = dp.figure
+    check_drawable_attrs(fig, "scatter")
+    assert dp.left_track is None
+    assert isinstance(dp.bottom_track, Drawable)
+
+    # seq2 annotated
+    aln = load_alignment(annotate2=True)
+    dp = aln.dotplot(name1=aln.names[0], name2=aln.names[1])
+    assert isinstance(dp, AnnotatedDrawable)
+    fig = dp.figure
+    check_drawable_attrs(fig, "scatter")
+    assert dp.bottom_track is None
+    assert isinstance(dp.left_track, Drawable)
+
+    # both annotated
+    aln = load_alignment(annotate1=True, annotate2=True)
+    dp = aln.dotplot(name1=aln.names[0], name2=aln.names[1])
+    assert isinstance(dp, AnnotatedDrawable)
+    fig = dp.figure
+    check_drawable_attrs(fig, "scatter")
+    assert isinstance(dp.bottom_track, Drawable)
+    assert isinstance(dp.left_track, Drawable)
+
+
+def test_annotated_dotplot_remove_tracks(load_alignment):
+    """removing annotation tracks from dotplot should work"""
+    # both annotated
+    aln = load_alignment(True, True)
+    dp = aln.dotplot(name1=aln.names[0], name2=aln.names[1])
+    orig_fig = dp.figure
+    # remove left
+    dp.remove_track(left_track=True)
+    assert dp.left_track is None
+    assert dp._traces == []
+    assert dp.figure is not orig_fig
+    assert isinstance(dp.figure, UnionDict)
+
+    # both annotated, remove right
+    dp = aln.dotplot(name1=aln.names[0], name2=aln.names[1])
+    dp.remove_track(bottom_track=True)
+    assert dp.bottom_track is None
+    assert dp._traces == []
+    assert dp.figure is not orig_fig
+    assert isinstance(dp.figure, UnionDict)
+
+    # both annotated, remove both
+    dp = aln.dotplot(name1=aln.names[0], name2=aln.names[1])
+    dp.remove_track(True, True)
+    assert dp.bottom_track is None
+    assert dp.left_track is None
+    assert dp._traces == []
+    assert dp.figure is not orig_fig
+    assert isinstance(dp.figure, UnionDict)
+
+
+def test_sequence_collection_dotplot_annotated_no_matcing_seqids():
+    """has annotation_db but not for a selected sequence, returns plain DotPlot"""
+    db = GffAnnotationDb()
+    # dog not present
+    db.add_feature(seqid="Dog", biotype="exon", name="fred", spans=[(10, 15)])
+    data = {"Human": "CAGATTTGGCAGTT-", "Mouse": "CAGATTCAGCAGGTG"}
+    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seqs.annotation_db = db
+    seqs = seqs.take_seqs(["Human", "Mouse"], copy_annotations=True)
+    got = seqs.dotplot(show_progress=False, biotype="exon")
+    assert not isinstance(got, AnnotatedDrawable)
+    assert isinstance(got, Dotplot)
+
+
+def test_sequence_collection_dotplot_annotated_select_biotype():
+    """has annotation_db but not for a selected sequence, returns plain DotPlot"""
+    db = GffAnnotationDb()
+    # dog not present
+    db.add_feature(seqid="Human", biotype="exon", name="fred", spans=[(10, 15)])
+    data = {"Human": "CAGATTTGGCAGTT-", "Mouse": "CAGATTCAGCAGGTG"}
+    seqs = new_alignment.make_unaligned_seqs(data, moltype="dna")
+    seqs.annotation_db = db
+    seqs = seqs.take_seqs(["Human", "Mouse"])
+    got = seqs.dotplot(show_progress=False, biotype="gene")
+    assert not isinstance(got, AnnotatedDrawable)
+    assert isinstance(got, Dotplot)
+    # but if we select an available biotype
+    got = seqs.dotplot(show_progress=False, biotype="exon")
+    assert isinstance(got, AnnotatedDrawable)
+    # we get one trace with that biotype as the name
+    assert sum(tr.name == "exon" for tr in got.figure.data)
+
+
+def test_count_gaps_per_seq(load_alignment):
+    """creation of drawables works"""
+    styles = "bar", "box", "violin"
+    aln = load_alignment(False, False)
+    # no drawable
+    counts = aln.count_gaps_per_seq()
+    assert not hasattr(counts, "drawable")
+    check_drawable_styles(aln.count_gaps_per_seq, styles)
+
+
+def test_coevo_drawables(load_alignment):
+    """coevolution produces drawables"""
+    styles = "box", "heatmap", "violin"
+    aln = load_alignment(False, False)
+    aln = aln[:10]
+    coevo = aln.coevolution(show_progress=False)
+    assert not (hasattr(coevo, "drawable"))
+    check_drawable_styles(aln.coevolution, styles, show_progress=False)
+
+
+def test_coevo_annotated(load_alignment):
+    """coevolution on alignment with annotated seqs should add to heatmap plot"""
+    aln = load_alignment(True, False)
+    aln = aln[:30]
+    coevo = aln.coevolution(show_progress=False, drawable="heatmap")
+    drawable = coevo.drawable
+    assert isinstance(drawable, AnnotatedDrawable)
+    assert isinstance(drawable.left_track, Drawable)
+    assert isinstance(drawable.bottom_track, Drawable)
+
+
+def test_information_plot(load_alignment):
+    """infoprmation plot makes a drawable"""
+    aln = load_alignment(False, False)
+    drawable = aln.information_plot()
+    assert isinstance(drawable, Drawable)
+    check_drawable_attrs(drawable.figure, "scatter")
+
+
+def test_get_drawable():
+    """sliced alignment with features returns a drawable"""
+    aln = new_alignment.make_aligned_seqs(
+        dict(a="AAACGGTTT", b="CAA--GTAA"), moltype="dna"
+    )
+    db = GffAnnotationDb()
+    db.add_feature(seqid="b", biotype="domain", name="1", spans=[(1, 5)])
+    db.add_feature(seqid="b", biotype="variation", name="1", spans=[(1, 5)])
+    db.add_feature(seqid="b", biotype="gene", name="1", spans=[(1, 5)])
+    db.add_feature(seqid="b", biotype="gene", name="1", spans=[(5, 1)])
+    aln.annotation_db = db
+    got = aln.get_drawable()
+    assert got is not None
+
+
+def test_seqlogo(seqs_dict):
+    """exercise producing a seq logo"""
+    aln = new_alignment.make_aligned_seqs(seqs_dict, moltype="dna")
+    _ = aln.seqlogo()
+    # using wrap argument
+    _ = aln.seqlogo(wrap=20)
+
+
+def test_seqlogo_for_sliced_alignment(seqs_dict):
+    # exercise producing a seq logo for a sliced alignment
+    aln = new_alignment.make_aligned_seqs(seqs_dict, moltype="dna")
+    aln = aln[:20]
+    _ = aln.seqlogo()
diff --git a/tests/test_maths/test_stats/test_distribution.py b/tests/test_maths/test_stats/test_distribution.py
index 3dd7c6d1a..ccd44c42b 100644
--- a/tests/test_maths/test_stats/test_distribution.py
+++ b/tests/test_maths/test_stats/test_distribution.py
@@ -20,8 +20,6 @@
     pdtr,
     pdtrc,
     pdtri,
-    poisson_high,
-    poisson_low,
     probability_points,
     stdtr,
     stdtri,
diff --git a/tests/test_util/test_deserialise.py b/tests/test_util/test_deserialise.py
index b3f9f375f..e4a0c892b 100644
--- a/tests/test_util/test_deserialise.py
+++ b/tests/test_util/test_deserialise.py
@@ -542,18 +542,14 @@ def test_convert_annotation_to_annotation_db():
                             {
                                 "start": 2,
                                 "end": 5,
-                                "tidy_start": False,
-                                "tidy_end": False,
                                 "value": None,
                                 "reverse": False,
                                 "type": "cogent3.core.location.Span",
                                 "version": "2023.2.12a1",
                             }
                         ],
-                        "tidy": False,
                         "parent_length": 5,
-                        "termini_unknown": False,
-                        "type": "cogent3.core.location.Map",
+                        "type": "cogent3.core.location.FeatureMap",
                         "version": "2023.2.12a1",
                     },
                 },
@@ -617,8 +613,6 @@ def test_deser_annotated_aln():
                             {
                                 "start": 0,
                                 "end": 3,
-                                "tidy_start": False,
-                                "tidy_end": False,
                                 "value": None,
                                 "reverse": False,
                                 "type": "cogent3.core.location.Span",
@@ -627,18 +621,14 @@ def test_deser_annotated_aln():
                             {
                                 "start": 5,
                                 "end": 9,
-                                "tidy_start": False,
-                                "tidy_end": False,
                                 "value": None,
                                 "reverse": False,
                                 "type": "cogent3.core.location.Span",
                                 "version": "2023.2.12a1",
                             },
                         ],
-                        "tidy": False,
                         "parent_length": 12,
-                        "termini_unknown": False,
-                        "type": "cogent3.core.location.Map",
+                        "type": "cogent3.core.location.FeatureMap",
                         "version": "2023.2.12a1",
                     },
                 },