Repository: cogent3/cogent3
Pull request #2079 (2024-10-29 23:33:46)

ENH: add visualisation capabilities to new Alignment class
includes other methods (filtered, trim_stop_codons,  omit_gap_pos, etc) required by the drawing methods

## Summary by Sourcery

Enhance the new Alignment class with drawing capabilities and additional sequence handling methods. Implement tests to verify the new functionalities, including drawing, filtering, and trimming sequences.

New Features:
- Introduce drawing capabilities to the new Alignment class, allowing for the creation of visual representations such as sequence logos, coevolution plots, and information plots.

Enhancements:
- Add methods to the Alignment class for filtering and trimming sequences based on specific criteria, such as gap fractions and stop codons.
- Implement new methods for handling sequence positions, including take_positions and take_positions_if, to facilitate selective sequence analysis.

Tests:
- Add new tests for the drawing capabilities of the Alignment class, including tests for dotplot functionality and coevolution drawables.
- Introduce tests for the new filtering and trimming methods, ensuring correct behavior when handling gaps and stop codons.


Patch:
diff --git a/src/cogent3/__init__.py b/src/cogent3/__init__.py
index 4acd6580c..88206d61d 100644
--- a/src/cogent3/__init__.py
+++ b/src/cogent3/__init__.py
@@ -200,6 +200,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 +220,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 a5895fe63..cecd283c2 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -3638,7 +3638,7 @@ class AlignmentI(object):
         show_progress=False,
         parallel=False,
         par_kw=None,
-    ):
+    ):  # ported
         """performs pairwise coevolution measurement
 
         Parameters
@@ -3745,7 +3745,9 @@ class AlignmentI(object):
 
         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
@@ -4775,7 +4777,7 @@ class Alignment(AlignmentI, SequenceCollection):
 
         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.
@@ -4789,7 +4791,7 @@ class Alignment(AlignmentI, SequenceCollection):
         """
         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
@@ -4986,7 +4988,9 @@ class Alignment(AlignmentI, SequenceCollection):
 
         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
@@ -5006,7 +5010,7 @@ class Alignment(AlignmentI, SequenceCollection):
         biotype: Optional[str, Iterable[str]] = None,
         width: int = 600,
         vertical: int = False,
-    ):
+    ):  # ported
         """make a figure from sequence features
 
         Parameters
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index cd3be9b46..4e5a59bb6 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -6,7 +6,7 @@ import re
 import typing
 import warnings
 from abc import ABC, abstractmethod
-from collections import defaultdict
+from collections import Counter, defaultdict
 from functools import singledispatch, singledispatchmethod
 from pathlib import Path
 from typing import Any, Callable, Iterator, Mapping, Optional, Union
@@ -15,7 +15,7 @@ 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,
@@ -60,6 +60,10 @@ StrORArray = Union[str, numpy.ndarray]
 StrORBytesORArray = Union[str, bytes, numpy.ndarray]
 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
+
 
 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
@@ -77,6 +81,99 @@ def assign_sequential_names(num_seqs: int, base_name: str = "seq", start_at: int
     return [f"{base_name}_{i}" for i in range(start_at, start_at + num_seqs)]
 
 
+class GapsOk:
+    """determine whether number of gaps satisfies allowed_frac"""
+
+    # refactor: array
+    # possibly convert to using numba jit functions
+
+    def __init__(
+        self,
+        gap_chars: set,
+        allowed_frac: float = 0,
+        motif_length: int = 1,
+        is_array: bool = False,
+        negate: bool = False,
+        gap_run: bool = False,
+        allowed_run: int = 1,
+    ):
+        """
+        Parameters
+        ----------
+        gap_chars
+            characters corresponding to gaps
+        allowed_frac
+            the threshold gap fraction, ignored if gap_run
+        motif_length
+            used to adjust for the denominator in the gap fraction
+        is_array
+            whether input will be a numpy array
+        negate
+            if False (default) evaluates fraction of gap
+            characters <= allowed_frac, if True, >= allowed_frac
+        gap_run
+            check for runs of gaps
+        allowed_run
+            length of the allowed gap run
+
+        """
+        self.motif_length = motif_length
+        self.is_array = is_array
+        self.allowed_frac = allowed_frac
+        self.allowed_run = allowed_run
+        if gap_run:
+            self._func = self.gap_run_ok
+        elif negate:
+            self._func = self.gap_frac_not_ok
+        else:
+            self._func = self.gap_frac_ok
+
+        try:
+            self.gap_chars = set(gap_chars)
+        except TypeError:
+            self.gap_chars = {gap_chars}
+
+    def _get_gap_frac(self, data: tuple[str, ...]) -> float:
+        length = len(data) * self.motif_length
+        # flatten the data and count elements equal to gap
+        if self.is_array:
+            data = Counter(data.flatten())
+        else:
+            data = Counter("".join([str(d) for d in data]))
+
+        num_gap = sum(data[g] for g in self.gap_chars)
+        return num_gap / length
+
+    def gap_frac_ok(self, data: tuple[str, ...]) -> bool:
+        """fraction of gap characters <= allowed_frac"""
+        gap_frac = self._get_gap_frac(data)
+        return gap_frac <= self.allowed_frac
+
+    def gap_frac_not_ok(self, data: tuple[str, ...]) -> bool:
+        """fraction of gap characters >= allowed_frac"""
+        gap_frac = self._get_gap_frac(data)
+        return gap_frac >= self.allowed_frac
+
+    def gap_run_ok(self, seq: str) -> bool:
+        """runs of gaps <= allowed_run"""
+        # refactor: design, this is not used in any of the new alignment code
+        curr_run = 0
+        is_gap = self.gap_chars.__contains__
+        result = True
+        for i in seq:
+            if is_gap(i):
+                curr_run += 1
+                if curr_run > self.allowed_run:
+                    result = False
+                    break
+            else:
+                curr_run = 0
+        return result
+
+    def __call__(self, data: Union[tuple[str, ...], str]):
+        return self._func(data)
+
+
 class SeqDataView(new_sequence.SeqView):
     """
     A view class for SeqsData, providing methods for different representations
@@ -607,6 +704,10 @@ class SequenceCollection:
         # 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]
 
@@ -730,7 +831,6 @@ class SequenceCollection:
             moltype=self.moltype,
             name_map=new_name_map,
             info=self.info,
-            source=self.source,
             annotation_db=self.annotation_db,
         )
 
@@ -807,6 +907,10 @@ class SequenceCollection:
         -----
         The returned collection will not retain an annotation_db if present.
         """
+        # refactor: design
+        # todo: this needs to be implemented for Alignment - need to think about the design.
+        # in the old implementation, this method removed all gaps and then returned a
+        # SequenceCollection (even for alignemnts).
         seqs = {
             name: self.moltype.degap(self._seqs_data.get_seq_array(seqid=name))
             for name in self._name_map.values()
@@ -3161,6 +3265,8 @@ class AlignedDataView(new_sequence.SeqViewABC):
                 numpy.array([], dtype=int),
             )
 
+        # refactor: design
+        # should the returned map be sliced with the slice_record?
         return IndelMap(
             gap_pos=gap_pos,
             cum_gap_lengths=cum_gap_lengths,
@@ -3396,6 +3502,7 @@ class Alignment(SequenceCollection):
             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)
@@ -3421,12 +3528,19 @@ class Alignment(SequenceCollection):
     @__getitem__.register
     def _(self, index: slice):
         new_slice = self._slice_record[index]
-        return self.__class__(
+        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):
@@ -3449,7 +3563,10 @@ class Alignment(SequenceCollection):
         return f"{len(self.names)} x {len(self)} {self.moltype.label} alignment: {seqs}"
 
     def __len__(self):
-        return len(self._seqs_data)
+        return len(self._slice_record)
+
+    def __array__(self):
+        return self.array_seqs
 
     def _make_aligned(self, seqid: str) -> Aligned:
         # refactor: design
@@ -3459,6 +3576,27 @@ class Alignment(SequenceCollection):
         )
         return Aligned(data=data, moltype=self.moltype, name=seqid)
 
+    @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 = numpy.empty(
+                (self.num_seqs, len(self)), dtype=self._seqs_data.alphabet.dtype
+            )
+            for i, seq in enumerate(self.seqs):
+                arr_seqs[i, :] = numpy.array(seq)
+            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:
@@ -3473,8 +3611,6 @@ class Alignment(SequenceCollection):
             to the annotation database of the Sequence object. If False, all
             annotations are copied.
         """
-        # todo
-        # more efficient to constuct the sequence object directly?
         seq = self.seqs[seqname].seq
         if copy_annotations:
             seq.annotation_db = type(self.annotation_db)()
@@ -3535,6 +3671,27 @@ class Alignment(SequenceCollection):
         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=}")
+
+        new = self.__class__(
+            seqs_data=self._seqs_data,
+            moltype=self.moltype,
+            name_map=new_name_map,
+            info=self.info,
+            annotation_db=self.annotation_db,
+        )
+
+        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]:
@@ -3552,7 +3709,8 @@ class Alignment(SequenceCollection):
         """
         # refactor: array
         # this could also iter columns of indices as a numpy array - could be an optional arg
-        pos_order = pos_order or range(self._seqs_data.align_len)
+        # 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]
 
@@ -3563,28 +3721,53 @@ class Alignment(SequenceCollection):
     ) -> list[int]:
         """Returns list of column indices for which f(col) is True.
 
-        f : callable
+        f
           function that returns true/false given an alignment position
-        native : boolean
-          if True, and ArrayAlignment, f is provided with slice of array
-          otherwise the string is used
-        negate : boolean
+        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
-        if negate:
+        new_f = negate_condition(f) if negate else f
 
-            def new_f(x):
-                return not f(x)
+        # refactor: design
+        # use array_positions here
+        return [i for i, col in enumerate(self.positions) if new_f(col)]
 
-        else:
-            new_f = f
+    def take_positions(self, cols: list, negate: bool = False):
+        """Returns new Alignment containing only specified positions.
 
-        result = [i for i, col in enumerate(self.positions) if new_f(col)]
+        Parameters
+        ----------
+        cols
+            list of column indices to keep
+        negate
+            if True, all columns except those in cols are kept
+        """
+        if negate:
+            col_lookup = dict.fromkeys(cols)
+            cols = [i for i in range(len(self)) if i not in col_lookup]
 
-        return result
+        new_data = {
+            seqid: self._seqs_data.get_gapped_seq_array(seqid=seqid)[cols]
+            for seqid in self.names
+        }
+        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
@@ -3647,9 +3830,10 @@ class Alignment(SequenceCollection):
             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)
+        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)
@@ -3663,7 +3847,7 @@ class Alignment(SequenceCollection):
             exclude_chars.update(ambigs)
 
         result = []
-        for i in range(0, len(self) - motif_length + 1, motif_length):
+        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)
@@ -3974,6 +4158,142 @@ class Alignment(SequenceCollection):
             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 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.
+        """
+
+        gaps = list(self.moltype.gaps)
+
+        # redesign: use array methods
+        gaps_ok = GapsOk(
+            gaps, allowed_gap_frac, is_array=False, motif_length=motif_length
+        )
+
+        return self.filtered(gaps_ok, motif_length=motif_length)
+
+    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 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
+
     def _get_seq_features(
         self,
         *,
@@ -4102,7 +4422,7 @@ class Alignment(SequenceCollection):
                 raise RuntimeError(f"{on_alignment=} {feature=}")
             if seq_map is None:
                 seq_map = self.seqs[0].map.to_feature_map()
-                *_, strand = self.seqs[0].data.parent_coordinates()
+                *_, strand = self.seqs[0].seq.parent_coordinates()
 
             spans = numpy.array(feature["spans"])
             spans = seq_map.relative_position(spans)
@@ -4111,6 +4431,370 @@ class Alignment(SequenceCollection):
             feature["strand"] = "-" if strand == -1 else "+"
             yield self.make_feature(feature=feature, on_alignment=on_al)
 
+    def get_drawables(self, *, biotype: Optional[str, Iterable[str]] = None) -> dict:
+        """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, 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
 


Test patch:
diff --git a/tests/test_core/test_alignment.py b/tests/test_core/test_alignment.py
index 1129c3406..f0087f48c 100644
--- a/tests/test_core/test_alignment.py
+++ b/tests/test_core/test_alignment.py
@@ -1574,7 +1574,7 @@ class AlignmentBaseTests(SequenceCollectionBaseTests):
         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",
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
index 0858ce712..2efb5f5f3 100644
--- a/tests/test_core/test_new_alignment.py
+++ b/tests/test_core/test_new_alignment.py
@@ -138,16 +138,6 @@ def gff_db(DATA_DIR):
     return load_annotations(path=DATA_DIR / "simple.gff")
 
 
-@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(scope="function")
 def seqcoll_db(DATA_DIR):
     from cogent3.parse.fasta import iter_fasta_records
@@ -1252,65 +1242,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")
@@ -1891,59 +1901,6 @@ 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")
-
-
-@pytest.mark.parametrize("with_annotations", [True, False])
-def test_sequence_collection_dotplot_annotated(annotated_seq, with_annotations):
-    if not with_annotations:
-        annotated_seq.replace_annotation_db(None)  # this drops all annotations
-
-    coll = new_alignment.make_unaligned_seqs(
-        {"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
-
-
 @pytest.mark.parametrize(
     "collection_maker",
     [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
@@ -3065,6 +3022,114 @@ def test_aligned_data_view_gapped_bytes_value(aligned_array_dict, dna_alphabet,
     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
@@ -3740,6 +3805,171 @@ def test_get_feature():
     assert feat.get_slice().to_dict() == dict(x="AAA", y="CCT")
 
 
+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_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_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
+    assert new_aln.omit_gap_pos(0) is None
+
+
 @pytest.fixture
 def alignment():
     data = {
diff --git a/tests/test_draw/test_draw_integration.py b/tests/test_draw/test_draw_integration.py
index b2129376d..4040b04cb 100644
--- a/tests/test_draw/test_draw_integration.py
+++ b/tests/test_draw/test_draw_integration.py
@@ -241,7 +241,7 @@ class AlignmentDrawablesTests(BaseDrawablesTests):
             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"])
@@ -268,7 +268,7 @@ class AlignmentDrawablesTests(BaseDrawablesTests):
                 "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
 
@@ -305,7 +305,7 @@ class AlignmentDrawablesTests(BaseDrawablesTests):
         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)
@@ -335,7 +335,7 @@ class AlignmentDrawablesTests(BaseDrawablesTests):
         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)
@@ -347,7 +347,7 @@ class AlignmentDrawablesTests(BaseDrawablesTests):
         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)
@@ -359,7 +359,7 @@ class AlignmentDrawablesTests(BaseDrawablesTests):
         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]
@@ -369,7 +369,7 @@ class AlignmentDrawablesTests(BaseDrawablesTests):
         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()
@@ -380,7 +380,7 @@ class AlignmentDrawablesTests(BaseDrawablesTests):
         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..829e7fc97
--- /dev/null
+++ b/tests/test_draw/test_new_draw_integration.py
@@ -0,0 +1,296 @@
+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])
+def test_dotplot_annotated(annotated_seq, with_annotations):
+    if not with_annotations:
+        annotated_seq.replace_annotation_db(None)  # this drops all annotations
+
+    coll = new_alignment.make_unaligned_seqs(
+        {"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_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)
+
+
+@pytest.mark.xfail(
+    reason="todo: for sliced alignment, inconsistent length between length of alignment and length of the map"
+)
+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()