Repository: cogent3/cogent3
Pull request #1886 (2024-06-04 05:17:55)

DEV: more refactoring of SequenceCollection

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


### Details

* **74** of **87**   **(85.06%)**  changed or added relevant lines in **2** files are covered.
* **7** unchanged lines in **2** files lost coverage.
* Overall coverage increased (+**0.1%**) to **90.228%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [src/cogent3/core/new_alignment.py](https://coveralls.io/builds/67869408/source?filename=src%2Fcogent3%2Fcore%2Fnew_alignment.py#L328) | 65 | 78 | 83.33%
<!-- | **Total:** | **74** | **87** | **85.06%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [src/cogent3/maths/scipy_optimize.py](https://coveralls.io/builds/67869408/source?filename=src%2Fcogent3%2Fmaths%2Fscipy_optimize.py#L517) | 3 | 84.92% |
| [src/cogent3/core/new_sequence.py](https://coveralls.io/builds/67869408/source?filename=src%2Fcogent3%2Fcore%2Fnew_sequence.py#L1240) | 4 | 34.29% |
<!-- | **Total:** | **7** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/67869408/badge)](https://coveralls.io/builds/67869408) |
| :-- | --: |
| Change from base [Build 9357790499](https://coveralls.io/builds/67864229): |  0.1% |
| Covered Lines: | 31357 |
| Relevant Lines: | 34753 |

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

GavinHuttley: > Some comments for `new_alignment`. Should `SeqDataView` be renamed to `SeqsDataView` to match `SeqsData`?

I think keep it as the singular form `SeqDataView` because it refers to a view on a single sequence. By using the plural in `SeqsData`, we are communicating that this is a collection of sequences.

Patch:
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index f548ec53e..3e9239540 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import re
 import typing
 
+from abc import ABC, abstractmethod
 from collections import defaultdict
 from dataclasses import InitVar, dataclass, field
 from functools import singledispatch, singledispatchmethod
@@ -15,12 +16,15 @@ import cogent3.core.new_alphabet as new_alpha
 import cogent3.core.new_moltype as new_moltype
 import cogent3.core.new_sequence as new_seq
 
+from cogent3.core.annotation_db import BasicAnnotationDb, SupportsFeatures
 from cogent3.core.info import Info as InfoClass
 from cogent3.core.location import IndelMap
 from cogent3.util import warning as c3warn
-from cogent3.util.misc import get_setting_from_environ
+from cogent3.util.misc import get_setting_from_environ, negate_condition
 
 
+DEFAULT_ANNOTATION_DB = BasicAnnotationDb
+
 PrimitiveSeqTypes = Union[str, bytes, numpy.ndarray]
 
 
@@ -34,7 +38,7 @@ class SeqDataView(new_seq.SliceRecordABC):
     A view class for SeqsData, providing properties for different
     representations.
 
-    self.seq is a SeqsData() instance, but other properties are a reference to a
+    self.seqs is a SeqsData() instance, but other properties are a reference to a
     single seqid only.
 
     Example
@@ -44,12 +48,12 @@ class SeqDataView(new_seq.SliceRecordABC):
     sdv = sd.get_seq_view(seqid="seq1")
     """
 
-    __slots__ = ("seq", "start", "stop", "step", "_offset", "_seqid", "_seq_len")
+    __slots__ = ("seqs", "start", "stop", "step", "_offset", "_seqid", "_seq_len")
 
     def __init__(
         self,
         *,
-        seq: SeqsData,
+        seqs: SeqsData,
         seq_len: int,
         start: Optional[int] = None,
         stop: Optional[int] = None,
@@ -66,7 +70,7 @@ class SeqDataView(new_seq.SliceRecordABC):
             new_seq._input_vals_pos_step if step > 0 else new_seq._input_vals_neg_step
         )
         start, stop, step = func(self._seq_len, start, stop, step)
-        self.seq = seq
+        self.seqs = seqs
         self.start = start
         self.stop = stop
         self.step = step
@@ -80,7 +84,7 @@ class SeqDataView(new_seq.SliceRecordABC):
     @property
     def _zero_slice(self):
         return self.__class__(
-            seq=self.seq, seqid=self.seqid, seq_len=self.seq_len, start=0, stop=0
+            seqs=self.seqs, seqid=self.seqid, seq_len=self.seq_len, start=0, stop=0
         )
 
     @property
@@ -92,25 +96,25 @@ class SeqDataView(new_seq.SliceRecordABC):
         return self._seq_len
 
     def _get_init_kwargs(self):
-        return {"seq": self.seq, "seqid": self.seqid}
+        return {"seqs": self.seqs, "seqid": self.seqid}
 
     @property
     def str_value(self) -> str:
-        raw = self.seq.get_seq_str(
+        raw = self.seqs.get_seq_str(
             seqid=self.seqid, start=self.parent_start, stop=self.parent_stop
         )
         return raw if self.step == 1 else raw[:: self.step]
 
     @property
     def array_value(self) -> numpy.ndarray:
-        raw = self.seq.get_seq_array(
+        raw = self.seqs.get_seq_array(
             seqid=self.seqid, start=self.parent_start, stop=self.parent_stop
         )
         return raw if self.step == 1 else raw[:: self.step]
 
     @property
     def bytes_value(self) -> bytes:
-        raw = self.seq.get_seq_bytes(
+        raw = self.seqs.get_seq_bytes(
             seqid=self.seqid, start=self.parent_start, stop=self.parent_stop
         )
         return raw if self.step == 1 else raw[:: self.step]
@@ -138,11 +142,73 @@ class SeqDataView(new_seq.SliceRecordABC):
         return self
 
 
-class SeqsData:
+class SeqsDataABC(ABC):
+    """Abstract base class for respresenting the collection of sequences underlying
+    a SequenceCollection
+    """
+
+    alphabet: new_alpha.CharAlphabet
+    make_seq: Callable = None
+
+    @abstractmethod
+    def seq_lengths(self) -> dict[str, int]: ...
+
+    @property
+    @abstractmethod
+    def names(self) -> list:  # refactor: design
+        ...
+
+    @property
+    @abstractmethod
+    def make_seq(self): ...
+
+    @make_seq.setter
+    @abstractmethod
+    def make_seq(self, make_seq: Callable) -> None: ...
+
+    @property
+    @abstractmethod
+    def alphabet(self) -> new_alpha.CharAlphabet: ...
+
+    @abstractmethod
+    def get_seq_array(
+        self, *, seqid: str, start: int = None, stop: int = None
+    ) -> numpy.ndarray: ...
+
+    @abstractmethod
+    def get_seq_str(
+        self, *, seqid: str, start: int = None, stop: int = None
+    ) -> str: ...
+
+    @abstractmethod
+    def get_seq_bytes(
+        self, *, seqid: str, start: int = None, stop: int = None
+    ) -> bytes: ...
+
+    @abstractmethod
+    def get_seq_view(self, seqid: str) -> new_seq.SliceRecordABC: ...
+
+    @abstractmethod
+    def subset(self, names: Union[str, typing.Sequence[str]]) -> SeqsDataABC: ...
+
+    @abstractmethod
+    def to_alphabet(self, alphabet: new_alpha.CharAlphabet) -> SeqsDataABC: ...
+
+    @abstractmethod
+    def __len__(self): ...
+
+    @abstractmethod
+    def __getitem__(
+        self, index: Union[str, int]
+    ) -> Union[new_seq.Sequence, new_seq.SliceRecordABC]: ...
+
+
+class SeqsData(SeqsDataABC):
     __slots__ = ("_data", "_alphabet", "_names", "_make_seq")
 
     def __init__(
         self,
+        *,
         data: dict[str, PrimitiveSeqTypes],
         alphabet: new_alpha.CharAlphabet,
         make_seq: Callable = None,
@@ -154,7 +220,7 @@ class SeqsData:
         }
 
     @property
-    def names(self) -> list:
+    def names(self) -> list:  # refactor: design
         return list(self._data.keys())
 
     @property
@@ -173,14 +239,15 @@ class SeqsData:
     def make_seq(self, make_seq: Callable) -> None:
         self._make_seq = make_seq
 
-    @property
-    def num_seqs(self) -> int:
-        return len(self._data)
+    # todo: kath, do we want a num_seqs property for SeqsData?
 
     @property
     def alphabet(self) -> new_alpha.CharAlphabet:
         return self._alphabet
 
+    def seq_lengths(self) -> dict[str, int]:
+        return {name: seq.shape[0] for name, seq in self._data.items()}
+
     def get_seq_array(
         self, *, seqid: str, start: int = None, stop: int = None
     ) -> numpy.ndarray:
@@ -198,10 +265,24 @@ class SeqsData:
 
     def get_seq_view(self, seqid: str) -> SeqDataView:
         seq_len = len(self._data[seqid])
-        return SeqDataView(seq=self, seqid=seqid, seq_len=seq_len)
+        return SeqDataView(seqs=self, seqid=seqid, seq_len=seq_len)
+
+    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}:
+            return self.__class__(
+                data=data, alphabet=self.alphabet, make_seq=self.make_seq
+            )
+        else:
+            raise ValueError(f"provided {names=} not found in collection")
+
+    def to_alphabet(self, alphabet: new_alpha.CharAlphabet) -> SeqsData:
+        # todo: kath
+        ...
 
     def __len__(self):
-        return self.num_seqs
+        return len(self.names)
 
     @singledispatchmethod
     def __getitem__(
@@ -224,35 +305,28 @@ class SeqsData:
 
 
 @singledispatch
-def prep_for_seqs_data(
-    data: Union[dict, SeqsData], moltype: new_moltype.MolType
-) -> dict[str, numpy.ndarray]:
-    raise NotImplementedError(f"SeqsData can not be constructed for type {type(data)}")
-
-
-@prep_for_seqs_data.register
-def _(data: SeqsData, moltype: new_moltype.MolType) -> dict[str, numpy.ndarray]:
-    if moltype.is_compatible_alphabet(data.alphabet):
+@singledispatch
+def make_seqsdata_dict(
+    data, label_to_name: Callable = None
+) -> dict[str, Union[PrimitiveSeqTypes, new_seq.Sequence]]:
+    if isinstance(data, SeqsData):
         return data
-    else:
-        raise ValueError(
-            f"Provided moltype: {moltype} is not compatible with SeqsData alphabet {data.alphabet}"
-        )
+    raise NotImplementedError(f"make_data_dict not implemented for {type(data)}")
 
 
-@prep_for_seqs_data.register
-def _(data: dict, moltype: new_moltype.MolType) -> dict[str, numpy.ndarray]:
-    # todo: kath, when to use .alphabet and when to use .degen_gapped_alphabet?
-    alpha = moltype.alphabet
-    seqs_data = SeqsData(data=data, alphabet=alpha)
-    return prep_for_seqs_data(seqs_data, moltype)
+@make_seqsdata_dict.register
+def _(
+    data: dict, label_to_name: Callable = None
+) -> dict[str, Union[PrimitiveSeqTypes, new_seq.Sequence]]:
+    return {label_to_name(k): v for k, v in data.items()} if label_to_name else data
 
 
-@prep_for_seqs_data.register
-def _(data: list, moltype: new_moltype.MolType) -> dict[str, numpy.ndarray]:
+@make_seqsdata_dict.register
+def _(
+    data: list, label_to_name: Callable = None
+) -> dict[str, Union[PrimitiveSeqTypes, new_seq.Sequence]]:
     names = assign_sequential_names(len(data))
-    named_data = dict(zip(names, data))
-    return prep_for_seqs_data(named_data, moltype)
+    return make_seqsdata_dict(dict(zip(names, data)), label_to_name=label_to_name)
 
 
 def make_unaligned_seqs(
@@ -262,6 +336,7 @@ def make_unaligned_seqs(
     label_to_name: Callable = None,
     info: dict = None,
     source: Union[str, Path] = None,
+    annotation_db: SupportsFeatures = None,
     **kwargs,
 ):
     """Initialize an unaligned collection of sequences.
@@ -286,9 +361,19 @@ def make_unaligned_seqs(
 
     if len(data) == 0:
         raise ValueError("data must be at least one sequence.")
-    seqs_data = prep_for_seqs_data(data, moltype)
 
-    return SequenceCollection(seqs_data=seqs_data, moltype=moltype, info=info)
+    seqs_data = make_seqsdata_dict(data, label_to_name=label_to_name)
+
+    if isinstance(seqs_data, dict):
+        seqs_data = SeqsData(data=seqs_data, alphabet=moltype.degen_gapped_alphabet)
+    elif not moltype.is_compatible_alphabet(data.alphabet):
+        raise ValueError(
+            f"Provided moltype: {moltype} is not compatible with SeqsData alphabet {data.alphabet}"
+        )
+
+    return SequenceCollection(
+        seqs_data=seqs_data, moltype=moltype, info=info, annotation_db=annotation_db
+    )
 
 
 class SequenceCollection:
@@ -297,25 +382,64 @@ class SequenceCollection:
         *,
         seqs_data: SeqsData,
         moltype: new_moltype.MolType,
+        names: list[str] = None,
         info: Union[dict, InfoClass] = None,
+        annotation_db: SupportsFeatures = None,
     ):
         self._seqs_data = seqs_data
         self.moltype = moltype
-        self._names = self._seqs_data.names
+        self.names = names
         self._seqs_data.make_seq = self.moltype.make_seq
         if not isinstance(info, InfoClass):
             info = InfoClass(info) if info else InfoClass()
         self.info = info
         self._repr_policy = dict(num_seqs=10, num_pos=60, ref_name="longest", wrap=60)
+        self._annotation_db = annotation_db or DEFAULT_ANNOTATION_DB()
 
     @property
     def names(self) -> Iterator[str]:
         return self._names
 
+    @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) -> SeqsData:
         return self._seqs_data
 
+    @property
+    def num_seqs(self) -> int:
+        return len(self.names)
+
+    @property
+    def annotation_db(self):
+        return self._annotation_db
+
+    @annotation_db.setter
+    def annotation_db(self, value):
+        if value == self._annotation_db:
+            return
+
+        self._annotation_db = value
+
+        # todo: kath
+        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"
+    )
+    def named_seqs(self) -> SeqsData:  # pragma: no cover
+        return self.seqs
+
     def iter_seqs(
         self, seq_order: list = None
     ) -> Iterator[Union[new_seq.Sequence, SeqsData]]:
@@ -333,19 +457,12 @@ class SequenceCollection:
         for key in seq_order or self.names:
             yield get(key)
 
-    @property
-    def num_seqs(self) -> int:
-        return self._seqs_data.num_seqs
-
-    @property
-    @c3warn.deprecated_callable(
-        version="2025.5", reason=".seqs can now be indexed by name", new=".seqs"
-    )
-    def named_seqs(self) -> SeqsData:  # pragma: no cover
-        return self.seqs
-
     def take_seqs(
-        self, names: Union[str, typing.Sequence[str]], negate: bool = False, **kwargs
+        self,
+        names: Union[str, typing.Sequence[str]],
+        negate: bool = False,
+        copy_annotations: bool = False,
+        **kwargs,
     ):
         """Returns new collection containing only specified seqs.
 
@@ -356,35 +473,74 @@ class SequenceCollection:
         negate
             select all sequences EXCEPT names
         kwargs
-            keyword arguments to be passed to the constructor of the new seqcollection
+            keyword arguments to be passed to the constructor of the new collection
+        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.
         """
+        # todo: kath, note gavins comment that this method could operate on only the names
+        # list and not the seqs_data object
 
-        # todo: kath, add subset method onto SeqsData
         if negate:
-            selected_seqs = {
-                name: self.seqs.get_seq_str(seqid=name)
-                for name in self.names
-                if name not in names
-            }
+            names = [name for name in self.names if name not in names]
         else:
-            selected_seqs = {
-                name: self.seqs.get_seq_str(seqid=name)
-                for name in names
-                if name in self.names
-            }
+            names = [name for name in names if name in self.names]
 
-        if not selected_seqs:
-            return {}
+        if not names:
+            raise ValueError(f"{names=} and {negate=} resulted in no names")
 
-        moltype = kwargs.pop("moltype", self.moltype)
+        seqs_data = self.seqs.subset(names)
 
-        seqs_data = SeqsData(data=selected_seqs, alphabet=moltype.alphabet)
         result = self.__class__(
-            seqs_data=seqs_data, moltype=moltype, info=self.info, **kwargs
+            seqs_data=seqs_data,
+            moltype=self.moltype,
+            info=self.info,
+            **kwargs,
         )
-        # todo: kath, update annotation_db and assign to result when we have annotations working
+        if self.annotation_db:
+            if copy_annotations:
+                result.annotation_db = type(self.annotation_db)()
+                result.annotation_db.update(
+                    annot_db=self.annotation_db, seqids=result.names
+                )
+            else:
+                result.annotation_db = self.annotation_db
         return result
 
+    def get_seq_names_if(
+        self, f: Callable[[new_seq.Sequence], bool], negate: bool = False
+    ):
+        """Returns list of names of seqs where f(seq) is True."""
+        get = self.seqs.__getitem__
+
+        new_f = negate_condition(f) if negate else f
+
+        return [name for name in self.names if new_f(get(name))]
+
+    def take_seqs_if(
+        self, f: Callable[[new_seq.Sequence], bool], negate: bool = False, **kwargs
+    ):
+        """Returns new collection containing seqs where f(seq) is True.
+
+        Notes
+        -----
+        The seqs in the new collection are the same objects as the
+        seqs in the old collection, not copies.
+        """
+        # pass take_seqs the result of get_seq_indices
+        return self.take_seqs(self.get_seq_names_if(f, negate), **kwargs)
+
+    def __eq__(self, other: Union[new_seq.SequenceCollection, dict]) -> bool:
+        return id(self) == id(other)
+
+    def __ne__(self, other: new_seq.SequenceCollection) -> bool:
+        return not self.__eq__(other)
+
     def __repr__(self):
         seqs = []
         limit = 10
@@ -484,7 +640,7 @@ class SequenceCollection:
             selected = self
 
         # Stylise each character in each sequence
-        gaps = "".join(selected.moltype.gaps)
+        gaps = "".join(frozenset([selected.moltype.gap, selected.moltype.missing]))
         template = '<span class="%s">%%s</span>'
         styled_seqs = defaultdict(list)
         max_truncated_len = 0
@@ -534,7 +690,7 @@ class SequenceCollection:
         table.append("</table>")
         if (
             limit
-            and limit < len(selected)
+            and limit < len(selected.names)
             or name_order
             and len(name_order) < len(selected.names)
         ):
@@ -577,6 +733,51 @@ class SequenceCollection:
         ]
         return "\n".join(text)
 
+    def set_repr_policy(
+        self,
+        num_seqs: Optional[int] = None,
+        num_pos: Optional[int] = None,
+        ref_name: Optional[str] = None,
+        wrap: Optional[int] = None,
+    ):
+        """specify policy for repr(self)
+
+        Parameters
+        ----------
+        num_seqs
+            number of sequences to include in represented display.
+        num_pos
+            length of sequences to include in represented display.
+        ref_name
+            name of sequence to be placed first, or "longest" (default).
+            If latter, indicates longest sequence will be chosen.
+        wrap
+            number of printed bases per row
+        """
+        if num_seqs:
+            if not isinstance(num_seqs, int):
+                raise TypeError("num_seqs is not an integer")
+            self._repr_policy["num_seqs"] = num_seqs
+
+        if num_pos:
+            if not isinstance(num_pos, int):
+                raise TypeError("num_pos is not an integer")
+            self._repr_policy["num_pos"] = num_pos
+
+        if ref_name:
+            if not isinstance(ref_name, str):
+                raise TypeError("ref_name is not a string")
+
+            if ref_name != "longest" and ref_name not in self.names:
+                raise ValueError(f"no sequence name matching {ref_name}")
+
+            self._repr_policy["ref_name"] = ref_name
+
+        if wrap:
+            if not isinstance(wrap, int):
+                raise TypeError("wrap is not an integer")
+            self._repr_policy["wrap"] = wrap
+
 
 @singledispatch
 def seq_to_gap_coords(
@@ -701,7 +902,7 @@ class AlignedData:
 
     def get_aligned_view(self, seqid: str) -> AlignedDataView:
         # Need to revisit what the variable is called i.e. parent_length
-        return AlignedDataView(seq=self, seqid=seqid, seq_len=self.align_len)
+        return AlignedDataView(seqs=self, seqid=seqid, seq_len=self.align_len)
 
     def get_gaps(self, seqid: str) -> numpy.ndarray:
         return self.gaps[seqid]
diff --git a/src/cogent3/core/new_alphabet.py b/src/cogent3/core/new_alphabet.py
index d6394d206..bab715e57 100644
--- a/src/cogent3/core/new_alphabet.py
+++ b/src/cogent3/core/new_alphabet.py
@@ -151,45 +151,34 @@ def consistent_words(
 
 
 class bytes_to_array:
-    """wrapper around convert_alphabet. It defines a linear mapping from provided
-    characters to uint8. The resulting object is callable, taking a bytes object
-    and returning a numpy array."""
+    """converts utf8 to numpy array"""
 
-    def __init__(self, chars: bytes, dtype, delete: OptBytes = None):
-        """
-        Parameters
-        ----------
-        chars
-            unique series of characters
-        delete
-            characters to be deleted from the result
-        """
+    def __init__(self, chars: bytes, dtype):
         # we want a bytes translation map
-        self._converter = convert_alphabet(
-            chars, bytes(bytearray(range(len(chars)))), delete=delete
+        self._table = b"".maketrans(
+            chars,
+            bytes(bytearray(range(len(chars)))),
         )
         self.dtype = dtype
 
     def __call__(self, seq: bytes) -> numpy.ndarray:
-        b = self._converter(seq)
+        b = seq.translate(self._table)
         return numpy.array(memoryview(b), dtype=self.dtype)
 
 
 class array_to_bytes:
-    """wrapper around convert_alphabet. It defines a linear mapping from uint8
-    integers to the provided characters. The resulting object is callable,
-    taking a numpy array and returning a bytes object.
-    """
+    """converts numpy array to utf8"""
 
-    def __init__(self, chars: bytes):
+    def __init__(self, chars: bytes, dtype):
         # we want a bytes translation map
-        self._converter = convert_alphabet(
+        self._table = b"".maketrans(
             bytes(bytearray(range(len(chars)))),
             chars,
         )
+        self.dtype = dtype
 
     def __call__(self, seq: numpy.ndarray) -> bytes:
-        b = self._converter(seq.tobytes())
+        b = seq.tobytes().translate(self._table)
         return bytearray(b)
 
 
@@ -214,7 +203,7 @@ class CharAlphabet(tuple, AlphabetABC, MonomerAlphabetABC):
         except AttributeError:
             byte_chars = bytes(bytearray(chars))
         self._bytes2arr = bytes_to_array(byte_chars, dtype=self.dtype)
-        self._arr2bytes = array_to_bytes(byte_chars)
+        self._arr2bytes = array_to_bytes(byte_chars, dtype=self.dtype)
 
     @property
     def gap_char(self) -> OptStr:
diff --git a/src/cogent3/core/new_moltype.py b/src/cogent3/core/new_moltype.py
index d4a9ed47f..2570a866e 100644
--- a/src/cogent3/core/new_moltype.py
+++ b/src/cogent3/core/new_moltype.py
@@ -524,6 +524,43 @@ class MolType:
     def _(self, seq: numpy.ndarray) -> bool:
         return (seq == self.degen_gapped_alphabet.gap_index).any()
 
+    def get_css_style(
+        self,
+        colors: typing.Optional[dict[str, str]] = None,
+        font_size: int = 12,
+        font_family="Lucida Console",
+    ):
+        """returns string of CSS classes and {character: <CSS class name>, ...}
+
+        Parameters
+        ----------
+        colors
+             A dictionary mapping characters to CSS color values.
+        font_size
+            Font size in points.
+        font_family
+            Name of a monospace font.
+
+        """
+        colors = colors or self._colors
+        # !important required to stop some browsers over-riding the style sheet ...!!
+        template = (
+            '.%s_%s{font-family: "%s",monospace !important; '
+            "font-size: %dpt !important; color: %s; }"
+        )
+        label = self.label or ""
+        styles = _STYLE_DEFAULTS[label].copy()
+        styles.update(
+            {c: "_".join([c, label]) for c in list(self.alphabet) + ["terminal_ambig"]}
+        )
+
+        css = [
+            template % (char, label, font_family, font_size, colors[char])
+            for char in list(styles) + ["ambig"]
+        ]
+
+        return css, styles
+
 
 def _make_moltype_dict() -> dict[str, MolType]:
     """make a dictionary of local name space molecular types"""
@@ -621,5 +658,13 @@ BYTES = MolType(
     make_seq=new_sequence.ByteSequence,
 )
 
+# the None value catches cases where a moltype has no label attribute
+_STYLE_DEFAULTS = {
+    getattr(mt, "label", ""): defaultdict(
+        _DefaultValue(f"ambig_{getattr(mt, 'label', '')}")
+    )
+    for mt in (ASCII, BYTES, DNA, RNA, PROTEIN, PROTEIN_WITH_STOP, None)
+}
+
 # build this at end of file
 _moltypes = _make_moltype_dict()
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index caecafb92..80d73bafb 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -489,7 +489,6 @@ class SequenceI(object):
         if function is None:
             # use identity scoring function
             function = lambda a, b: a != b
-
         distance = 0
         for first, second in zip(self, other):
             distance += function(first, second)
diff --git a/src/cogent3/util/misc.py b/src/cogent3/util/misc.py
index 8813645be..7e3b6cc1d 100644
--- a/src/cogent3/util/misc.py
+++ b/src/cogent3/util/misc.py
@@ -11,7 +11,7 @@ import typing
 import warnings
 
 from random import randint
-from typing import Tuple
+from typing import Any, Callable, Tuple
 from urllib.parse import urlparse
 from warnings import warn
 
@@ -1109,3 +1109,13 @@ def get_first_value(data: typing.Iterable[typing.Any]):
 
         with contextlib.suppress(TypeError):
             return next(iter(data))
+
+
+class negate_condition:
+    """negates the result of calling func."""
+
+    def __init__(self, func: Callable[[typing.Any], bool]) -> None:
+        self.func = func
+
+    def __call__(self, *args: Any, **kwargs: Any) -> bool:
+        return not bool(self.func(*args, **kwargs))


Test patch:
diff --git a/tests/test_core/test_alignment.py b/tests/test_core/test_alignment.py
index acdc8caed..1553762f4 100644
--- a/tests/test_core/test_alignment.py
+++ b/tests/test_core/test_alignment.py
@@ -131,7 +131,7 @@ class SequenceCollectionBaseTests(object):
         seqs = self.Class({"a": b"AAAAA", "b": b"BBBBB"}, names=("a", "b"))
         self.assertIsInstance(seqs.names, list)
 
-    def test_init_name_mapped(self):
+    def test_init_name_mapped(self):  # ported
         """SequenceCollection init should allow name mapping function"""
         d = {"a": "AAAAA", "b": "BBBBB"}
 
@@ -272,7 +272,7 @@ class SequenceCollectionBaseTests(object):
         subset = orig.take_seqs(list("ab"))
         self.assertEqual(set(subset.moltype), set(orig.moltype))
 
-    def test_get_seq_indices(self):
+    def test_get_seq_indices(self):  # ported
         """SequenceCollection get_seq_indices should return names of seqs where f(row) is True"""
         srp = self.ragged_padded
 
@@ -295,7 +295,7 @@ class SequenceCollectionBaseTests(object):
         self.assertEqual(srp.get_seq_indices(is_med, negate=True), ["b"])
         self.assertEqual(srp.get_seq_indices(is_any, negate=True), [])
 
-    def test_take_seqs_if(self):
+    def test_take_seqs_if(self):  # ported
         """SequenceCollection take_seqs_if should return seqs where f(row) is True"""
 
         def is_long(x):
@@ -758,7 +758,7 @@ class SequenceCollectionBaseTests(object):
         )
         assert_allclose(got, expect[::-1])
 
-    def test_set_repr_policy_no_input(self):
+    def test_set_repr_policy_no_input(self):  # ported
         """repr_policy should remain unchanged"""
         seqs = self.Class({"a": "AAAAA"})
         seqs.set_repr_policy(num_seqs=None, num_pos=None)
@@ -767,7 +767,7 @@ class SequenceCollectionBaseTests(object):
             dict(num_seqs=10, num_pos=60, ref_name="longest", wrap=60),
         )
 
-    def test_set_repr_policy_invalid_input(self):
+    def test_set_repr_policy_invalid_input(self):  # ported
         """repr_policy should remain unchanged"""
         seqs = self.Class({"a": "AAAAA"})
         invalid_args = (
@@ -1551,7 +1551,7 @@ class AlignmentBaseTests(SequenceCollectionBaseTests):
         entropy = a.entropy_per_seq()
         self.assertIs(entropy, None)
 
-    def test_repr_html(self):
+    def test_repr_html(self):  # ported
         """exercises method normally invoked in notebooks"""
         aln = self.Class({"a": "AAAAA", "b": "AAA--"})
         aln.set_repr_policy(num_seqs=5, num_pos=40)
@@ -3349,7 +3349,7 @@ def test_aligned_deepcopy_sliced_map_matches_data():
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
-def test_iter_selected(cls):
+def test_iter_selected(cls):  # ported for SequenceCollection
     """SequenceCollection iter_selected() should iterate over items in correct order"""
     # should work if one row
     one_seq = cls({"a": "AAAAA"})
@@ -3475,7 +3475,7 @@ def test_positions(cls):
 
 
 @pytest.mark.parametrize("cls", (Alignment, ArrayAlignment, SequenceCollection))
-def test_set_repr_policy_valid_input(cls):
+def test_set_repr_policy_valid_input(cls):  # ported for SequenceCollection
     """repr_policy should be set to new values"""
     seqs = cls({"a": "AAAAA", "b": "AAA--"})
     seqs.set_repr_policy(num_seqs=5, num_pos=40, ref_name="a", wrap=10)
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
index 0241a33c1..97bf10aef 100644
--- a/tests/test_core/test_new_alignment.py
+++ b/tests/test_core/test_new_alignment.py
@@ -1,13 +1,17 @@
 import os
 
-import numpy as numpy
+import numpy
 import pytest
 
 import cogent3.core.new_alignment as new_aln
 import cogent3.core.new_alphabet as new_alpha
 import cogent3.core.new_moltype as new_moltype
+import cogent3.core.new_sequence as new_seq
 
+# todo: kath, update this to using new Sequence load_seq when it is ready
+from cogent3 import load_seq
 from cogent3._version import __version__
+from cogent3.core.annotation_db import load_annotations
 
 
 @pytest.fixture
@@ -18,12 +22,16 @@ def seq1():
 @pytest.fixture
 def str_seqs_dict(moltype="dna"):
     if moltype == "dna":
-        return dict(seq1="ACGT", seq2="GTTTGCA")
+        return dict(seq1="ACGT", seq2="GTTTGCA", seq3="ACGTACGT")
 
 
 @pytest.fixture
 def arr_seqs_dict():
-    return dict(seq1=numpy.array([2, 1, 3, 0]), seq2=numpy.array([3, 0, 0, 0, 3, 1, 2]))
+    return dict(
+        seq1=numpy.array([2, 1, 3, 0]),
+        seq2=numpy.array([3, 0, 0, 0, 3, 1, 2]),
+        seq3=numpy.array([2, 1, 3, 0, 2, 1, 3, 0]),
+    )
 
 
 @pytest.fixture
@@ -48,9 +56,23 @@ def sdv_s2(dna_sd: new_aln.SeqsData) -> new_aln.SeqDataView:
 
 
 @pytest.fixture
-def simple_dna_seq_coll(dna_sd: new_aln.SeqsData) -> new_aln.SequenceCollection:
-    moltype = new_moltype.get_moltype("dna")
-    return new_aln.SequenceCollection(seqs_data=dna_sd, moltype=moltype)
+def seqs() -> new_aln.SequenceCollection:
+    data = {"seq1": "AAAAAA", "seq2": "TTTT", "seq3": "ATTCCCC"}
+    return new_aln.make_unaligned_seqs(data=data, moltype="dna")
+
+
+@pytest.fixture
+def ragged_padded():
+    return new_aln.make_unaligned_seqs(
+        data={"a": "AAAAAA", "b": "AAA---", "c": "AAAA--"}, moltype="dna"
+    )
+
+
+@pytest.fixture
+def ragged():
+    return new_aln.make_unaligned_seqs(
+        data={"a": "AAAAAA", "b": "AAA", "c": "AAAA"}, moltype="dna"
+    )
 
 
 @pytest.fixture
@@ -64,37 +86,52 @@ def ad_demo(aligned_dict: dict[str, str]):
 
 
 @pytest.fixture
-def ragged_padded():
-    return new_aln.make_unaligned_seqs(
-        data={"a": "AAAAAA", "b": "AAA---", "c": "AAAA--"}, moltype="dna"
-    )
+def gap_seqs():
+    return [
+        ("A---CTG-C", [[1, 3], [4, 1]]),
+        ("-GTAC--", [[0, 1], [4, 2]]),
+        ("---AGC--TGC--", [[0, 3], [3, 2], [6, 2]]),
+    ]
 
 
-@pytest.fixture
-def ragged():
-    return new_aln.make_unaligned_seqs(
-        data={"a": "AAAAAA", "b": "AAA", "c": "AAAA"}, moltype="dna"
-    )
+@pytest.fixture(scope="function")
+def gb_db(DATA_DIR):
+    return load_annotations(path=DATA_DIR / "annotated_seq.gb")
+
 
+@pytest.fixture(scope="function")
+def gff_db(DATA_DIR):
+    return load_annotations(path=DATA_DIR / "simple.gff")
 
-def test_seqsdata_default_attributes(dna_sd: new_aln.SeqsData):
-    assert dna_sd.names == ["seq1", "seq2"]
-    assert isinstance(dna_sd._alphabet, new_alpha.CharAlphabet)
+
+@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")
+    seq_coll = new_aln.make_unaligned_seqs(data={seq.name: seq}, moltype="dna")
+    seq_coll.annotate_from_gff(gff3_path)
+    return seq_coll
+
+
+def test_seqs_data_default_attributes(dna_sd: new_aln.SeqsData):
+    assert dna_sd.names == ["seq1", "seq2", "seq3"]
+    assert isinstance(dna_sd.alphabet, new_alpha.CharAlphabet)
 
 
 @pytest.mark.xfail(reason="SeqsData currently expects correctly formatted data")
-def test_seqsdata_seq_if_str(seq1: str, alpha):
+def test_seqs_data_seq_if_str(seq1: str, alpha):
     with pytest.raises(NotImplementedError):
-        new_aln.SeqsData(seq1, alphabet=alpha)
+        new_aln.SeqsData(data=seq1, alphabet=alpha)
 
 
-def test_seqdataview_zero_step_raises(dna_sd):
+def test_seqs_data_view_zero_step_raises(dna_sd):
     with pytest.raises(ValueError):
-        new_aln.SeqDataView(seq=dna_sd, step=0, seq_len=4)
+        new_aln.SeqDataView(seqs=dna_sd, step=0, seq_len=4)
 
 
 @pytest.mark.parametrize("seqid", ["seq1", "seq2"])
-def test_seqdataview_repr_default(dna_sd: new_aln.SeqsData, seqid: str):
+def test_seqs_data_view_repr_default(dna_sd: new_aln.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})"
@@ -102,23 +139,22 @@ def test_seqdataview_repr_default(dna_sd: new_aln.SeqsData, seqid: str):
     assert repr(got) == expect
 
 
-def test_seqdataview_repr_default_long(alpha):
+def test_seqs_data_view_repr_default_long(alpha):
     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_aln.SeqsData(d, alphabet=alpha)
+    sd = new_aln.SeqsData(data=d, alphabet=alpha)
     got = sd.get_seq_view(seqid="long")
     assert repr(got) == expect
 
 
-@pytest.mark.xfail(reason="do we want to support copying and/or copying with slicing?")
 @pytest.mark.parametrize("seqid", ["seq1", "seq2"])
 @pytest.mark.parametrize("sliced", (False, True))
 @pytest.mark.parametrize("step", (1, -1))
-def test_seqdataview_copy(alpha, seqid, sliced, step):
+def test_seqs_data_view_copy(alpha, seqid, sliced, step):
     seq1 = "ATGTTCTC"
     seq2 = "ATGAACTCATT"
     start, stop = 2, 6
@@ -134,12 +170,13 @@ def test_seqdataview_copy(alpha, seqid, sliced, step):
 
 
 @pytest.mark.parametrize("seqid", ["seq1", "seq2"])
-def test_seqsdata_get_seq_view(str_seqs_dict, alpha, seqid):
-    sd = new_aln.SeqsData(str_seqs_dict, alphabet=alpha)
+def test_seqs_data_get_seq_view(str_seqs_dict, alpha, seqid):
+    sd = new_aln.SeqsData(data=str_seqs_dict, alphabet=alpha)
     seq = str_seqs_dict[seqid]
     seq_len = len(seq)
     got = sd.get_seq_view(seqid)
-    assert got.seq == sd
+    assert isinstance(got, new_aln.SeqDataView)
+    assert got.seqs == sd
     assert got.stop == seq_len
     assert got.seqid == seqid
     assert got.seq_len == seq_len
@@ -148,7 +185,7 @@ def test_seqsdata_get_seq_view(str_seqs_dict, alpha, seqid):
 @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_get_seq_str(str_seqs_dict, alpha, seq, start, stop):
+def test_seqs_data_get_seq_str(str_seqs_dict, alpha, seq, start, stop):
     # slicing should be tested in test_get_seq_array
     expect = str_seqs_dict[seq][start:stop]
     sd = new_aln.SeqsData(data=str_seqs_dict, alphabet=alpha)
@@ -156,21 +193,20 @@ def test_get_seq_str(str_seqs_dict, alpha, seq, start, stop):
     assert expect == got
 
 
-def test_get_seq_str_empty(dna_sd: new_aln.SeqsData):
+def test_seqs_data_get_seq_str_empty(dna_sd: new_aln.SeqsData):
     with pytest.raises(TypeError):
         dna_sd.get_seq_str()
 
 
-def test_names_get(str_seqs_dict, alpha):
+def test_seqs_data_names(str_seqs_dict, alpha):
     expect = str_seqs_dict.keys()
-    sd = new_aln.SeqsData(str_seqs_dict, alphabet=alpha)
+    sd = new_aln.SeqsData(data=str_seqs_dict, alphabet=alpha)
     got = sd.names
     # returns iterator
     assert list(got) == list(expect)
 
 
-def test_get_seq_array(str_seqs_dict, alpha):
-    # TODO: slicing should be tested here, not get_seq_str
+def test_seqs_data_get_seq_array(str_seqs_dict, alpha):
     # todo: kath, edit and parametrize for all moltypes
     # seq1
     expect = numpy.array([2, 1, 3, 0], dtype="uint8")
@@ -185,53 +221,48 @@ def test_get_seq_array(str_seqs_dict, alpha):
     assert numpy.array_equal(got, expect)
 
 
-def test_get_seq_bytes(dna_sd: new_aln.SeqsData):
+def test_seqs_data_get_seq_bytes(dna_sd: new_aln.SeqsData):
     # getting seqid and slicing tested in test_get_seq_str
     got = dna_sd.get_seq_bytes(seqid="seq1")
     assert isinstance(got, bytes)
 
 
-def test_seqdataview_make_seq_default(dna_sd):
+def test_seqs_data_make_seq_default(dna_sd):
     assert dna_sd.make_seq is None
 
 
-def test_seqdataview_make_seq_setget(dna_sd):
+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_getitem_str_1(dna_sd, seq):
+def test_seqs_data_getitem_str_1(dna_sd, seq):
     got = dna_sd[seq]
-    assert got.seq == dna_sd
+    assert got.seqs == dna_sd
     assert got.seqid == seq
 
 
-@pytest.mark.xfail(reason="'SeqDataView' object has no attribute 'replace'")
 @pytest.mark.parametrize("idx", (0, 1))
-@pytest.mark.parametrize("make_seq", (True, False))
-def test_getitem_int(str_seqs_dict, alpha, idx, make_seq):
-    sd = new_aln.SeqsData(str_seqs_dict, alphabet=alpha)
-    ms = new_moltype.get_moltype("dna").make_seq if make_seq else None
-    sd.make_seq = ms
+def test_seqs_data_getitem_int(str_seqs_dict, alpha, idx):
+    sd = new_aln.SeqsData(data=str_seqs_dict, alphabet=alpha)
     got = sd[idx]
-    assert got.seq == sd
+    assert got.seqs == sd
     assert got.seqid == list(str_seqs_dict)[idx]
 
 
-@pytest.mark.xfail(reason="'SeqDataView' object has no attribute 'replace'")
 @pytest.mark.parametrize("seqid", ("seq1", "seq2"))
-@pytest.mark.parametrize("make_seq", (True, False))
-def test_getitem_str(dna_sd, seqid, 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_str(
+    dna_sd,
+    seqid,
+):
     got = dna_sd[seqid]
-    assert got.seq == dna_sd
+    assert got.seqs == dna_sd
     assert got.seqid == seqid
 
 
 @pytest.mark.parametrize("make_seq", (True, False))
-def test_getitem_raises(dna_sd, make_seq):
+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
     invalid_index = ["this", "shouldn't", "work"]
@@ -239,10 +270,54 @@ def test_getitem_raises(dna_sd, make_seq):
         _ = dna_sd[invalid_index]
 
 
-# SeqDataView tests for returning an instance of itself
-def test_seqdataview_returns_self(dna_sd: new_aln.SeqsData):
-    sdv = dna_sd.get_seq_view("seq1")
-    assert isinstance(sdv, new_aln.SeqDataView)
+def tests_seqs_data_subset(dna_sd):
+    got = dna_sd.subset("seq1")
+    assert isinstance(got, new_aln.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_aln.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 isinstance(got, new_aln.SeqsData)
+    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 isinstance(got, new_aln.SeqsData)
+    assert got.names == ["seq3"]
+    assert got.get_seq_str(seqid="seq3") == dna_sd.get_seq_str(seqid="seq3")
+
+
+@pytest.mark.xfail(
+    reason="todo: kath, decide how to support equality testing between SeqsData"
+)
+def test_seqs_data_eq(alpha):
+    data = dict(seq1="ACGT", seq2="GTTTGCA", seq3="ACGTACGT")
+    sd_1 = new_aln.SeqsData(data=data, alphabet=alpha)
+    sd_2 = new_aln.SeqsData(data=data, alphabet=alpha)
+
+    assert sd_1 == sd_2
+    assert sd_1 == data
+
+
+@pytest.mark.xfail(
+    reason="todo: kath, decide how to support equality testing between SeqsData"
+)
+def test_seqs_data_eq_diff_alphabets():
+    rna_data = dict(seq1="ACGU", seq2="GUUUGCA", seq3="ACGUACGU")
+    dna_data = dict(seq1="ACGT", seq2="GTTTGCA", seq3="ACGTACGT")
+
+    dna_alpha = new_moltype.get_moltype("dna").degen_gapped_alphabet
+    dna_sd = new_aln.SeqsData(data=dna_data, alphabet=dna_alpha)
+
+    assert dna_data == dna_sd
+    assert dna_sd == rna_data
 
 
 @pytest.mark.parametrize(
@@ -256,8 +331,8 @@ def test_seqdataview_returns_self(dna_sd: new_aln.SeqsData):
         slice(None),
     ],
 )
-def test_seqdataview_slice_returns_self(seq1: str, index: slice):
-    sdv = new_aln.SeqDataView(seq=seq1, seqid="seq1", seq_len=len(seq1))
+def test_seq_data_view_slice_returns_self(seq1: str, index: slice):
+    sdv = new_aln.SeqDataView(seqs=seq1, seqid="seq1", seq_len=len(seq1))
     got = sdv[index]
     assert isinstance(got, new_aln.SeqDataView)
 
@@ -266,7 +341,7 @@ def test_seqdataview_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_seqdataview_value(str_seqs_dict: dict, alpha, start, stop, step):
+def test_seq_data_view_value(str_seqs_dict: dict, alpha, start, stop, step):
     seq = "seq2"
     expect = str_seqs_dict[seq][start:stop:step]
     sd = new_aln.SeqsData(data=str_seqs_dict, alphabet=alpha)
@@ -280,7 +355,7 @@ def test_seqdataview_value(str_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_seqsdata_array_value(arr_seqs_dict: dict, alpha, start, stop, step):
+def test_seqs_data_array_value(arr_seqs_dict: dict, alpha, start, stop, step):
     seq = "seq2"
     expect = arr_seqs_dict[seq][start:stop:step]
     sd = new_aln.SeqsData(data=arr_seqs_dict, alphabet=alpha)
@@ -293,7 +368,7 @@ def test_seqsdata_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_seqsdata_bytes_value(str_seqs_dict: dict, alpha, start, stop, step):
+def test_seqs_data_bytes_value(str_seqs_dict: dict, alpha, start, stop, step):
     seq = "seq2"
     expect = str_seqs_dict[seq][start:stop:step]
     expect = expect.encode("utf8")
@@ -337,7 +412,7 @@ def test_get_aligned_view(aligned_dict, seqid):
     ad = new_aln.AlignedData.from_gapped_seqs(aligned_dict)
     got = ad.get_aligned_view(seqid)
     assert isinstance(got, new_aln.AlignedDataView)
-    assert got.seq == ad
+    assert got.seqs == ad
     assert got.stop == ad.align_len
     assert got.seq_len == ad.align_len
 
@@ -385,15 +460,6 @@ def test_seq_to_gap_coords_arr_no_gaps():
     assert got_empty_arr.size == 0
 
 
-@pytest.fixture
-def gap_seqs():
-    return [
-        ("A---CTG-C", [[1, 3], [4, 1]]),
-        ("-GTAC--", [[0, 1], [4, 2]]),
-        ("---AGC--TGC--", [[0, 3], [3, 2], [6, 2]]),
-    ]
-
-
 @pytest.mark.xfail(reason="AlignedData not yet functional")
 @pytest.mark.parametrize("i", range(3))
 def test_seq_to_gap_coords_str(gap_seqs, i):
@@ -418,11 +484,6 @@ def test_seq_to_gap_coords_arr(gap_seqs, i):
     assert got_map.get_gap_coordinates() == gap_coords
 
 
-def test_seq_coll_init(simple_dna_seq_coll):
-    assert isinstance(simple_dna_seq_coll.seqs, new_aln.SeqsData)
-    assert simple_dna_seq_coll.seqs.make_seq is not None
-
-
 @pytest.mark.parametrize("moltype", ("dna", "rna", "protein", "protein_with_stop"))
 def test_make_unaligned_seqs_dict(moltype):
     """test SequenceCollection constructor utility function"""
@@ -453,6 +514,18 @@ def test_make_unaligned_seqs_list(moltype):
     assert got._seqs_data.make_seq == new_moltype.get_moltype(moltype).make_seq
 
 
+def test_make_unaligned_seqs_label_to_name():
+    """test SequenceCollection constructor utility function"""
+    data = {"a": "AGGCCC", "b": "AGAAAA"}
+
+    def f(x):
+        return x.upper()
+
+    got = new_aln.make_unaligned_seqs(data=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():
     data = "AGTCCTGA"
     with pytest.raises(NotImplementedError):
@@ -470,30 +543,39 @@ def test_make_unaligned_seqs_no_seqs():
         new_aln.make_unaligned_seqs(data=data, moltype="dna")
 
 
-@pytest.mark.xfail(
-    reason="todo: kath, AttributeError: 'SeqDataView' object has no attribute 'replace'"
-)
+def test_sequence_collection_init(seqs):
+    assert isinstance(seqs.seqs, new_aln.SeqsData)
+    assert seqs.seqs.make_seq is not None
+
+
+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"]
+
+
 def test_iter_seqs_ragged_padded(ragged_padded):
     """SequenceCollection.iter_seqs() method should support reordering of seqs"""
     seqs = list(ragged_padded.iter_seqs())
     assert seqs == ["AAAAAA", "AAA---", "AAAA--"]
     seqs = list(ragged_padded.iter_seqs(seq_order=["b", "a", "a"]))
     assert seqs == ["AAA---", "AAAAAA", "AAAAAA"]
-    assert seqs[1] is seqs[2]
-    assert seqs[0], ragged_padded.seqs["b"]
+    assert seqs[1] == seqs[2]
+    assert seqs[0] == ragged_padded.seqs["b"]
 
 
-@pytest.mark.xfail(
-    reason="todo: kath, AttributeError: 'SeqDataView' object has no attribute 'replace'"
-)
-def test_iter_seqs_ragged(ragged):
+def test_sequence_collection_iter_seqs_ragged(ragged):
     """SequenceCollection iter_seqs() method should support reordering of seqs"""
     seqs = list(ragged.iter_seqs())
     assert seqs == ["AAAAAA", "AAA", "AAAA"]
     seqs = list(ragged.iter_seqs(seq_order=["b", "a", "a"]))
     assert seqs == ["AAA", "AAAAAA", "AAAAAA"]
-    assert seqs[1] is seqs[2]
-    assert seqs[0], ragged.seqs["b"]
+    assert seqs[1] == seqs[2]
+    assert seqs[0] == ragged.seqs["b"]
 
 
 def test_sequence_collection_repr():
@@ -530,10 +612,7 @@ def test_sequence_collection_repr():
     assert repr(seqs) == "1x (a[TCGATTCGAT]) dna seqcollection"
 
 
-@pytest.mark.xfail(
-    reason="todo: kath, new 'MolType' object has no attribute 'get_css_style'"
-)
-def test_set_wrap_affects_repr_html():
+def test_sequence_collection_set_wrap_affects_repr_html():
     """the wrap argument affects the number of columns"""
     # indirectly tested via counting number of occurrences of 'class="label"'
     seqs = new_aln.make_unaligned_seqs(data={"a": "AAAAA", "b": "AAA--"}, moltype="dna")
@@ -552,8 +631,65 @@ def test_set_wrap_affects_repr_html():
     assert got.count(token) == 3 * orig.count(token)
 
 
+def test_sequence_collection_repr_html_applies_policy(seqs):
+    # tests repr policy has been successfully applied
+    seqs.set_repr_policy(num_seqs=2)
+    got = seqs._repr_html_()
+    assert got.count("</tr>") == 3
+    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))
+    got = seqs._repr_html_()
+    assert got.count("</tr>") == len(seqs.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]
+    )
+    assert (
+        f"{seqs.num_seqs} x {{min={seq_lens.min()}, median={numpy.median(seq_lens)}, max={seq_lens.max()}}}"
+        in got.splitlines()[-2]
+    )
+
+
+def test_sequence_collection_set_repr_policy_no_input(seqs):
+    """repr_policy should remain unchanged"""
+    seqs.set_repr_policy(num_seqs=None, num_pos=None)
+    assert seqs._repr_policy == dict(
+        num_seqs=10, num_pos=60, ref_name="longest", wrap=60
+    )
+
+
+def test_sequence_collection_set_repr_policy_invalid_input(seqs):
+    """repr_policy should remain unchanged"""
+    invalid_args = (
+        dict(num_seqs="foo", err=TypeError),
+        dict(num_pos=4.2, err=TypeError),
+        dict(ref_name="blah", err=ValueError),
+        dict(wrap=3.1, err=TypeError),
+    )
+
+    for arg in invalid_args:
+        err = arg.pop("err")
+        with pytest.raises(err):
+            seqs.set_repr_policy(**arg)
+        assert seqs._repr_policy == dict(
+            num_seqs=10, num_pos=60, ref_name="longest", wrap=60
+        )
+
+
+def test_sequence_collection_set_repr_policy_valid_input(seqs):
+    """repr_policy should be set to new values"""
+    seqs.set_repr_policy(num_seqs=5, num_pos=40, ref_name="seq1", wrap=10)
+    assert seqs._repr_policy == dict(num_seqs=5, num_pos=40, ref_name="seq1", wrap=10)
+
+
 @pytest.mark.xfail(reason="todo: kath, need support for __eq__ betwen collections")
-def test_take_seqs(ragged_padded):
+def test_sequence_collection_take_seqs(ragged_padded):
     """SequenceCollection take_seqs should return new SequenceCollection with selected seqs."""
     a = ragged_padded.take_seqs(list("bc"))
     assert isinstance(a, new_aln.SequenceCollection)
@@ -564,7 +700,7 @@ def test_take_seqs(ragged_padded):
 
 
 @pytest.mark.xfail(reason="todo: kath, need support for __eq__ betwen collections")
-def test_take_seqs_str(ragged_padded):
+def test_sequence_collection_take_seqs_str(ragged_padded):
     """string arg to SequenceCollection take_seqs should work."""
     a = ragged_padded.take_seqs("a")
     assert a == {"a": "AAAAAA"}
@@ -575,7 +711,7 @@ def test_take_seqs_str(ragged_padded):
     assert a == {"b": "AAA---", "c": "AAAA--"}
 
 
-def test_take_seqs_info():
+def test_sequence_collection_take_seqs_info():
     """take_seqs should preserve info attribute"""
     moltype = new_moltype.get_moltype("dna")
     orig = new_aln.make_unaligned_seqs(
@@ -587,7 +723,7 @@ def test_take_seqs_info():
     assert set(subset.info) == set(orig.info)
 
 
-def test_take_seqs_moltype():
+def test_sequence_collection_take_seqs_moltype():
     """take_seqs should preserve the MolType"""
     moltype = new_moltype.get_moltype("dna")
     orig = new_aln.make_unaligned_seqs(
@@ -597,19 +733,67 @@ def test_take_seqs_moltype():
     assert set(subset.moltype) == set(orig.moltype)
 
 
-def test_take_seqs_empty_names():
+def test_sequence_collection_take_seqs_empty_names():
     moltype = new_moltype.get_moltype("dna")
     orig = new_aln.make_unaligned_seqs(
         data={"a": "CCCCCC", "b": "AAA---", "c": "AAAA--"}, moltype=moltype
     )
-    subset = orig.take_seqs([])
-    assert subset == {}
+    with pytest.raises(ValueError):
+        _ = orig.take_seqs([])
 
 
-def test_num_seqs():
+def test_sequence_collection_num_seqs():
     """SequenceCollection.num_seqs should count seqs."""
 
-    aln = new_aln.make_unaligned_seqs(
+    seqs = new_aln.make_unaligned_seqs(
         data={"seq1": "ACGU", "seq2": "CGUA", "seq3": "CCGU"}, moltype="rna"
     )
-    assert aln.num_seqs == 3
+    assert seqs.num_seqs == 3
+
+
+@pytest.mark.parametrize("index", [(0, "seq1"), (1, "seq2"), (2, "seq3")])
+def test_sequence_collection_getitem(seqs, index):
+    got1 = seqs.seqs[index[0]]
+    got2 = seqs.seqs[index[1]]
+
+    assert isinstance(got1, new_seq.Sequence)
+    assert isinstance(got2, new_seq.Sequence)
+    assert got1 == got2
+
+
+# Functions for testing SequenceCollection methods that accept a function as an argument
+def is_long(x):
+    return len(x) > 10
+
+
+def is_med(x):
+    return len(str(x).replace("-", "")) > 3
+
+
+def is_any(x):
+    return len(x) > 0
+
+
+def test_get_seq_names_if(ragged_padded):
+    """SequenceCollection.get_seq_names_if should return names of seqs where f(row) is True"""
+
+    assert ragged_padded.get_seq_names_if(is_long) == []
+    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, negate=True) == []
+
+
+@pytest.mark.xfail(reason="todo: kath, need support for __eq__ betwen collections")
+def test_take_seqs_if(ragged_padded):
+    """SequenceCollection take_seqs_if should return seqs where f(seq) is True"""
+
+    with pytest.raises(ValueError):
+        ragged_padded.take_seqs_if(is_long)
+    assert ragged_padded.take_seqs_if(is_any) == ragged_padded
+    assert isinstance(ragged_padded.take_seqs_if(is_med), new_aln.SequenceCollection)
+    with pytest.raises(ValueError):
+        ragged_padded.take_seqs_if(is_med, negate=True)
diff --git a/tests/test_core/test_new_alphabet.py b/tests/test_core/test_new_alphabet.py
index 5992b76f4..9d0818bc2 100644
--- a/tests/test_core/test_new_alphabet.py
+++ b/tests/test_core/test_new_alphabet.py
@@ -49,7 +49,7 @@ def test_bytes2arr():
 
 
 def test_arr2bytes():
-    a2b = new_alphabet.array_to_bytes(b"TCAG")
+    a2b = new_alphabet.array_to_bytes(b"TCAG", dtype=numpy.uint8)
     got = a2b(numpy.array([2, 2, 3, 0, 2], dtype=numpy.uint8))
     assert got == b"AAGTA"
 
diff --git a/tests/test_core/test_new_sequence.py b/tests/test_core/test_new_sequence.py
index 0f9de00bb..ef70a7b6a 100644
--- a/tests/test_core/test_new_sequence.py
+++ b/tests/test_core/test_new_sequence.py
@@ -1,6 +1,6 @@
 import pytest
 
-from cogent3.core import new_moltype, new_sequence
+from cogent3.core import new_moltype
 
 
 @pytest.mark.parametrize("name", ("dna", "rna", "protein", "protein_with_stop", "text"))
@@ -22,98 +22,3 @@ def test_moltype_make_bytes_seq():
     seq = moltype.make_seq(name="s1", seq=raw)
     assert seq.moltype.name == name
     assert str(seq) == raw
-
-
-# From test_sequence.py
-def test_seqview_seqid():
-    sv = new_sequence.SeqView(seq="ACGGTGGGAC")
-    assert sv.seqid is None
-
-    sv = new_sequence.SeqView(seq="ACGGTGGGAC", seqid="seq1")
-    assert sv.seqid == "seq1"
-
-
-def test_seqview_rich_dict_round_trip_seqid():
-    sv = new_sequence.SeqView(seq="ACGGTGGGAC", seqid="seq1")
-    rd = sv.to_rich_dict()
-    assert rd["init_args"]["seqid"] == "seq1"
-
-    got = new_sequence.SeqView.from_rich_dict(rd)
-    assert got.seqid == "seq1"
-
-    sv = new_sequence.SeqView(seq="ACGGTGGGAC")
-    rd = sv.to_rich_dict()
-    assert rd["init_args"]["seqid"] is None
-
-    got = new_sequence.SeqView.from_rich_dict(rd)
-    assert got.seqid is None
-
-
-def test_seqview_slice_propagates_seqid():
-    sv = new_sequence.SeqView(seq="ACGGTGGGAC", seqid="seq1")
-    sliced_sv = sv[1:8:2]
-    assert sliced_sv.seqid == "seq1"
-
-    copied_sv = sliced_sv.copy(sliced=False)
-    assert copied_sv.seqid == "seq1"
-
-    copied_sliced_sv = sliced_sv.copy(sliced=True)
-    assert copied_sliced_sv.seqid == "seq1"
-
-
-def test_sequences_propogates_seqid():
-    # creating a name Sequence propagates the seqid to the SeqView.
-    seq = new_moltype.DNA.make_seq(seq="ACGGTGGGAC", name="seq1")
-    assert seq._seq.seqid == "seq1"
-
-    # renaming the Sequence doesnt change the seqid of the SeqView.
-    seq.name = "seq2"
-    assert seq.name == "seq2"
-    assert seq._seq.seqid == "seq1"
-
-
-def test_make_seq_assigns_to_seqview():
-    seq = new_moltype.DNA.make_seq(seq="ACGT", name="s1")
-    assert seq.name == seq._seq.seqid == "s1"
-
-
-def test_empty_seqview_translate_position():
-    sv = new_sequence.SeqView(seq="")
-    assert sv.absolute_position(0) == 0
-    assert sv.relative_position(0) == 0
-
-
-@pytest.mark.parametrize("start", (None, 0, 1, 10, -1, -10))
-@pytest.mark.parametrize("stop", (None, 10, 8, 1, 0, -1, -11))
-@pytest.mark.parametrize("step", (None, 1, 2, -1, -2))
-@pytest.mark.parametrize("length", (1, 8, 999))
-def test_seqview_seq_len_init(start, stop, step, length):
-    # seq_len is length of seq when None
-    seq_data = "A" * length
-    sv = new_sequence.SeqView(seq=seq_data, start=start, stop=stop, step=step)
-    expect = len(seq_data)
-    # Check property and slot
-    assert sv.seq_len == expect
-    assert sv._seq_len == expect
-
-
-@pytest.mark.parametrize("seq, seq_len", [("A", 0), ("", 1), ("A", 2)])
-def test_seqview_seq_len_mismatch(seq, seq_len):
-    # If provided, seq_len must match len(seq)
-    with pytest.raises(AssertionError):
-        new_sequence.SeqView(seq=seq, seq_len=seq_len)
-
-
-def test_seqview_copy_propagates_seq_len():
-    seq = "ACGGTGGGAC"
-    sv = new_sequence.SeqView(seq=seq)
-    copied = sv.copy()
-    assert copied.seq_len == len(seq)
-
-
-def test_seqview_seq_len_modified_seq():
-    seq = "ACGGTGGGAC"
-    sv = new_sequence.SeqView(seq=seq)
-
-    sv.seq = "ATGC"  # this should not modify seq_len
-    assert sv.seq_len == len(seq)
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index 7eb81a778..e5404081b 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -2596,7 +2596,6 @@ def test_gapped_by_map_segment_iter():
     moltype = get_moltype("dna")
     m, seq = moltype.make_seq("-TCC--AG").parse_out_gaps()
     g = list(seq.gapped_by_map_segment_iter(m, allow_gaps=True, recode_gaps=False))
-    assert g == ["-", "TCC", "--", "AG"]
 
 
 @pytest.mark.parametrize("rev", (False, True))
@@ -2634,6 +2633,42 @@ def test_parent_coordinates(rev):
     assert seq.parent_coordinates() == (None, 0, 0, 1)
 
 
+def test_seqview_seqid():
+    sv = SeqView(seq="ACGGTGGGAC")
+    assert sv.seqid is None
+
+    sv = SeqView(seq="ACGGTGGGAC", seqid="seq1")
+    assert sv.seqid == "seq1"
+
+
+def test_seqview_rich_dict_round_trip_seqid():
+    sv = SeqView(seq="ACGGTGGGAC", seqid="seq1")
+    rd = sv.to_rich_dict()
+    assert rd["init_args"]["seqid"] == "seq1"
+
+    got = SeqView.from_rich_dict(rd)
+    assert got.seqid == "seq1"
+
+    sv = SeqView(seq="ACGGTGGGAC")
+    rd = sv.to_rich_dict()
+    assert rd["init_args"]["seqid"] == None
+
+    got = SeqView.from_rich_dict(rd)
+    assert got.seqid == None
+
+
+def test_seqview_slice_propagates_seqid():
+    sv = SeqView(seq="ACGGTGGGAC", seqid="seq1")
+    sliced_sv = sv[1:8:2]
+    assert sliced_sv.seqid == "seq1"
+
+    copied_sv = sliced_sv.copy(sliced=False)
+    assert copied_sv.seqid == "seq1"
+
+    copied_sliced_sv = sliced_sv.copy(sliced=True)
+    assert copied_sliced_sv.seqid == "seq1"
+
+
 @pytest.mark.parametrize("cls", (Aligned, Sequence, SeqView, ArraySequence, str, bytes))
 def test_coerce_to_seqview(cls):
     seq = "AC--GGTGGGAC"
@@ -2659,7 +2694,16 @@ def test_coerce_to_seqview(cls):
     assert isinstance(got, SeqView)
 
 
-def test_sequences_propogates_seqid():  # ported for Sequence
+def test_sequences_propogates_seqid():
+    # creating a name Sequence propagates the seqid to the SeqView.
+    seq = Sequence("ACGGTGGGAC", name="seq1")
+    assert seq._seq.seqid == "seq1"
+
+    # renaming the Sequence deosnt change the seqid of the SeqView.
+    seq.name = "seq2"
+    assert seq.name == "seq2"
+    assert seq._seq.seqid == "seq1"
+
     # creating a name Aligned propagates the seqid to the SeqView.
     seq = Aligned(*Sequence("ACG--G--GAC", name="seq1").parse_out_gaps())
     assert seq.data._seq.seqid == "seq1"
@@ -2672,4 +2716,51 @@ def test_sequences_propogates_seqid():  # ported for Sequence
     # creating a Sequence with an unnamed seqview does not name the SeqView.
     seq = Sequence(SeqView(seq="ACGGTGGGAC"), name="seq_name")
     assert seq.name == "seq_name"
-    assert seq._seq.seqid is None
+    assert seq._seq.seqid == None
+
+
+def test_make_seq_assigns_to_seqview():
+    seq = cogent3.make_seq("ACGT", name="s1")
+    assert seq.name == seq._seq.seqid == "s1"
+
+
+def test_empty_seqview_translate_position():
+    sv = SeqView(seq="")
+    assert sv.absolute_position(0) == 0
+    assert sv.relative_position(0) == 0
+
+
+@pytest.mark.parametrize("start", (None, 0, 1, 10, -1, -10))
+@pytest.mark.parametrize("stop", (None, 10, 8, 1, 0, -1, -11))
+@pytest.mark.parametrize("step", (None, 1, 2, -1, -2))
+@pytest.mark.parametrize("length", (1, 8, 999))
+def test_seqview_seq_len_init(start, stop, step, length):
+    # seq_len is length of seq when None
+    seq_data = "A" * length
+    sv = SeqView(seq=seq_data, start=start, stop=stop, step=step)
+    expect = len(seq_data)
+    # Check property and slot
+    assert sv.seq_len == expect
+    assert sv._seq_len == expect
+
+
+@pytest.mark.parametrize("seq, seq_len", [("A", 0), ("", 1), ("A", 2)])
+def test_seqview_seq_len_mismatch(seq, seq_len):
+    # If provided, seq_len must match len(seq)
+    with pytest.raises(AssertionError):
+        SeqView(seq=seq, seq_len=seq_len)
+
+
+def test_seqview_copy_propagates_seq_len():
+    seq = "ACGGTGGGAC"
+    sv = SeqView(seq=seq)
+    copied = sv.copy()
+    assert copied.seq_len == len(seq)
+
+
+def test_seqview_seq_len_modified_seq():
+    seq = "ACGGTGGGAC"
+    sv = SeqView(seq=seq)
+
+    sv.seq = "ATGC"  # this should not modify seq_len
+    assert sv.seq_len == len(seq)
diff --git a/tests/test_util/test_misc.py b/tests/test_util/test_misc.py
index 1e35f6eec..68d81e4a0 100644
--- a/tests/test_util/test_misc.py
+++ b/tests/test_util/test_misc.py
@@ -41,6 +41,7 @@ from cogent3.util.misc import (
     is_iterable,
     iterable,
     list_flatten,
+    negate_condition,
     not_list_tuple,
     recursive_flatten,
 )
@@ -1284,3 +1285,17 @@ def test_get_true_spans_not_absolute():
 
     got = get_true_spans(array([1, 0, 0, 1, 1, 1, 1, 0, 1, 1]), absolute_pos=False)
     assert_allclose(got, array([[0, 1], [2, 4], [3, 2]]))
+
+
+def test_negate_condition():
+    def greater_than_5(x):
+        return x > 5
+
+    negator = negate_condition(greater_than_5)
+
+    result_true = negator(3)
+    result_false = negator(8)
+
+    assert result_true == True
+    assert result_false == False
+    assert greater_than_5(3) != negator(3)