Repository: cogent3/cogent3
Pull request #2029 (2024-09-30 23:52:57)

ENH: better construction of dict arrays and distance matrices


Patch:
diff --git a/changelog.d/20241001_094946_Gavin.Huttley.md b/changelog.d/20241001_094946_Gavin.Huttley.md
new file mode 100644
index 000000000..fa03a2cd7
--- /dev/null
+++ b/changelog.d/20241001_094946_Gavin.Huttley.md
@@ -0,0 +1,42 @@
+<!--
+A new scriv changelog fragment.
+
+Uncomment the section that is right (remove the HTML comment wrapper).
+-->
+
+<!--
+### Contributors
+
+- A bullet item for the Contributors category.
+
+-->
+### ENH
+
+- Provide convenience class methods on both DictArray and DistanceMatrix to simplify
+  creation when you have a numpy array and the names corresponding to the dimension
+  element labels. These methods are mainly of use for developers.
+
+<!--
+### BUG
+
+- A bullet item for the BUG category.
+
+-->
+<!--
+### DOC
+
+- A bullet item for the DOC category.
+
+-->
+<!--
+### Deprecations
+
+- A bullet item for the Deprecations category.
+
+-->
+<!--
+### Discontinued
+
+- A bullet item for the Discontinued category.
+
+-->
diff --git a/pyproject.toml b/pyproject.toml
index 5818590d7..f1147851b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -48,7 +48,7 @@ Changelog = "https://github.com/cogent3/cogent3/blob/develop/changelog.md"
 
 [project.optional-dependencies]
 test = [
-    "ruff==0.5.7",
+    "ruff==0.6.7",
     "kaleido",
     "pandas",
     "plotly",
@@ -82,7 +82,7 @@ doc  = ["click",
         "sphinxcontrib-bibtex",
         "sphinxcontrib-video"]
 extra = ["pandas", "plotly", "psutil", "kaleido"]
-dev = ["ruff==0.5.7",
+dev = ["ruff==0.6.7",
        "flit",
        "ipykernel",
        "ipython",
diff --git a/src/cogent3/__init__.py b/src/cogent3/__init__.py
index bf91e2334..e33d45b35 100644
--- a/src/cogent3/__init__.py
+++ b/src/cogent3/__init__.py
@@ -77,7 +77,12 @@ __numba_logger.setLevel(logging.WARNING)
 
 
 def make_seq(
-    seq, name: str = None, moltype=None, new_type: bool = False
+    seq,
+    name: str = None,
+    moltype=None,
+    new_type: bool = False,
+    annotation_offset: int = 0,
+    **kw: dict,
 ):  # refactor: type hinting, need to capture optional args and the return type
     """
     Parameters
@@ -92,6 +97,10 @@ def make_seq(
         if True, returns a new type Sequence (cogent3.core.new_sequence.Sequence).
         The default will be changed to True in 2024.12. Support for the old
         style will be removed as of 2025.6.
+    annotation_offset
+        integer indicating start position relative to annotations
+    **kw
+        other keyword arguments passed to Sequence
 
     Returns
     -------
@@ -104,7 +113,9 @@ def make_seq(
         moltype = new_moltype.get_moltype(moltype)
     else:
         moltype = get_moltype(moltype)
-    seq = moltype.make_seq(seq=seq, name=name)
+    seq = moltype.make_seq(
+        seq=seq, name=name, annotation_offset=annotation_offset, **kw
+    )
     return seq
 
 
@@ -256,17 +267,13 @@ def _load_files_to_unaligned_seqs(
     )
 
 
-def _load_seqs(file_format, filename, fmt, kw, parser_kw):
+def _load_seqs(file_format, filename, fmt, parser_kw):
     """utility function for loading sequences"""
     fmt = fmt or file_format
     if not fmt:
         msg = "could not determined file format, set using the format argument"
         raise ValueError(msg)
     parser_kw = parser_kw or {}
-    for other_kw in ("constructor_kw", "kw"):
-        other_kw = kw.pop(other_kw, None) or {}
-        kw.update(other_kw)
-
     parser = get_parser(fmt)
     return list(parser(filename, **parser_kw))
 
@@ -280,7 +287,8 @@ def load_seq(
     parser_kw: Optional[dict] = None,
     info: Optional[dict] = None,
     new_type: bool = False,
-    **kw,
+    annotation_offset: int = 0,
+    **kw: dict,
 ) -> Sequence:
     """
     loads unaligned sequences from file
@@ -303,8 +311,10 @@ def load_seq(
         if True, returns a new type Sequence (cogent3.core.new_sequence.Sequence)
         The default will be changed to True in 2024.12. Support for the old
         style will be removed as of 2025.6.
+    annotation_offset
+        integer indicating start position relative to annotations
     **kw
-        other keyword arguments passed to SequenceCollection
+        other keyword arguments passed to sequence loader
 
     Notes
     -----
@@ -323,11 +333,18 @@ def load_seq(
         seq.name = label_to_name(seq.name) if label_to_name else seq.name
         return seq
 
-    data = _load_seqs(file_format, filename, format, kw, parser_kw)
+    data = _load_seqs(file_format, filename, format, parser_kw)
     name, seq = data[0]
     name = label_to_name(name) if label_to_name else name
 
-    result = make_seq(seq, name, moltype=moltype, new_type=new_type)
+    result = make_seq(
+        seq,
+        name,
+        moltype=moltype,
+        new_type=new_type,
+        annotation_offset=annotation_offset,
+        **kw,
+    )
     result.info.update(info)
 
     if getattr(seq, "annotation_db", None):
@@ -372,7 +389,6 @@ def load_unaligned_seqs(
         (cogent3.core.new_sequence.SequenceCollection). The default will be
         changed to True in 2024.12. Support for the old style will be removed
         as of 2025.6.
-
     **kw
         other keyword arguments passed to SequenceCollection, or show_progress.
         The latter induces a progress bar for number of files processed when
@@ -401,7 +417,7 @@ def load_unaligned_seqs(
     if file_format == "json":
         return load_from_json(filename, (SequenceCollection,))
 
-    data = _load_seqs(file_format, filename, format, kw, parser_kw)
+    data = _load_seqs(file_format, filename, format, parser_kw)
 
     return make_unaligned_seqs(
         data,
@@ -441,6 +457,8 @@ def load_aligned_seqs(
         function for converting original name into another name.
     parser_kw : dict
         optional arguments for the parser
+    kw
+        passed to make_aligned_seqs
 
     Returns
     -------
@@ -450,7 +468,7 @@ def load_aligned_seqs(
     if file_format == "json":
         return load_from_json(filename, (Alignment, ArrayAlignment))
 
-    data = _load_seqs(file_format, filename, format, kw, parser_kw)
+    data = _load_seqs(file_format, filename, format, parser_kw)
     return make_aligned_seqs(
         data,
         array_align=array_align,
diff --git a/src/cogent3/core/annotation.py b/src/cogent3/core/annotation.py
index 1051e8ab4..af8f5aa4c 100644
--- a/src/cogent3/core/annotation.py
+++ b/src/cogent3/core/annotation.py
@@ -107,10 +107,8 @@ class Feature:
         return self._do_seq_slice(result)
 
     def _do_seq_slice(self, result):
-        offset = self.map.start if self.map.num_spans == 1 else 0
         if self.reversed:
             result = result.rc()
-        result.annotation_offset = offset
         if self.map.num_spans > 1:
             # db querying will be incorrect so make sure it can't be done
             result.annotation_db = None
diff --git a/src/cogent3/core/new_sequence.py b/src/cogent3/core/new_sequence.py
index 85624aa1f..8f3c29474 100644
--- a/src/cogent3/core/new_sequence.py
+++ b/src/cogent3/core/new_sequence.py
@@ -78,20 +78,49 @@ def _moltype_seq_from_rich_dict(data):
     data.pop("type")
     data.pop("version")
     data.pop("annotation_db", None)
+    offset = data.pop("annotation_offset", 0)
 
     moltype = data.pop("moltype")
     moltype = new_moltype.get_moltype(moltype)
 
     seqview_data = data.pop("seq")
     seq = _coerce_to_seqview(
-        seqview_data["init_args"]["seq"], data["name"], moltype.most_degen_alphabet()
+        seqview_data["init_args"]["seq"],
+        data["name"],
+        moltype.most_degen_alphabet(),
+        offset,
     )
     seq = seq[:: seqview_data["init_args"]["step"]]
-    seq.offset = seqview_data["init_args"]["offset"]
 
     return moltype, seq
 
 
+# This is a design note about the annotation_offset attribute on sequences
+#  and the related offset attribute on the sequence view.
+
+# The SeqView object is responsible for performing slices and keeping track of
+#  those slice operations relative to a parent. This is done via the start, stop
+#  and step attributes.
+
+# The annotation_offset attribute is intended to facilitate relating sequences
+#  to annotations stored in coordinates of an annotated parent sequence. Consider
+#  for example a gene that lies on a chromosome. If a user has an instance of that
+#  gene's sequence in a sequence object, then the annotation_offset would be the
+#  start position of that gene on the plus strand of the original chromosome.
+
+# It's the case that an annotation_offset can only be specified by the user as
+#  an argument to the sequence constructor. The design decision was made to
+#  have the responsibility for providing the coordinates on the parent lie
+#  with the SeqView class. These values are provided by the parent_start,
+#  parent_stop and absolute_position methods.
+
+# The state flow is in the constructor for the sequence class. There is
+#  an assignment of the annotation_offset to the provided sequence_view.
+
+# The only time the offset value is not at the SeqView level is the
+#  sequence object is being serialised.
+
+
 @total_ordering
 class Sequence:
     """Holds the standard Sequence object. Immutable.
@@ -127,12 +156,14 @@ class Sequence:
         """
         self.moltype = moltype
         self.name = name
-        self._seq = _coerce_to_seqview(seq, name, self.moltype.most_degen_alphabet())
+        self._seq = _coerce_to_seqview(
+            seq, name, self.moltype.most_degen_alphabet(), annotation_offset
+        )
+
         info = info or {}
         self.info = InfoClass(**info)
         self._repr_policy = dict(num_pos=60)
         self._annotation_db = DEFAULT_ANNOTATION_DB()
-        self.annotation_offset = annotation_offset
 
     def __str__(self):
         result = str(self._seq)
@@ -827,10 +858,6 @@ class Sequence:
 
         return self._seq.parent_start
 
-    @annotation_offset.setter
-    def annotation_offset(self, value: int):
-        self._seq.offset = value
-
     @property
     def annotation_db(self):
         return self._annotation_db
@@ -1197,14 +1224,17 @@ class Sequence:
         exclude_annotations
             drops annotation_db when True
         """
-        annotation_offset = self.annotation_offset if sliced else self._seq.offset
+        # when slicing a seqview with sliced=True, seqview discards the
+        # offset attribute. Sequence then needs to be provided to its
+        # constructor from its current state.
+        offset = self.annotation_offset if sliced else 0
         data = self._seq.copy(sliced=sliced)
         new = self.__class__(
             moltype=self.moltype,
             seq=data,
             name=self.name,
             info=self.info,
-            annotation_offset=annotation_offset,
+            annotation_offset=offset,
         )
         db = None if exclude_annotations else copy.deepcopy(self.annotation_db)
         new._annotation_db = db
@@ -1306,9 +1336,20 @@ class Sequence:
 
     def _mapped(self, map):
         # Called by generic __getitem__
-        segments = self.gapped_by_map_segment_iter(map, allow_gaps=False)
+        if map.num_spans == 1:
+            seq = self._seq[map.start : map.end]
+            offset = map.start
+        else:
+            segments = self.gapped_by_map_segment_iter(map, allow_gaps=False)
+            seq = "".join(segments)
+            offset = 0
+
         return self.__class__(
-            moltype=self.moltype, seq="".join(segments), name=self.name, info=self.info
+            moltype=self.moltype,
+            seq=seq,
+            name=self.name,
+            info=self.info,
+            annotation_offset=offset,
         )
 
     def __repr__(self):
@@ -1329,7 +1370,8 @@ class Sequence:
 
         if isinstance(index, (FeatureMap, IndelMap)):
             new = self._mapped(index)
-            preserve_offset = True
+            # annotations have no meaning if disjoint slicing segments
+            preserve_offset = index.num_spans == 1
 
         elif isinstance(index, slice) or _is_int(index):
             new = self.__class__(
@@ -1346,7 +1388,6 @@ class Sequence:
 
         if self.annotation_db is not None and preserve_offset:
             new.replace_annotation_db(self.annotation_db, check=False)
-            new.annotation_offset = self.annotation_offset
 
         if _is_float(index):
             raise TypeError("cannot slice using float")
@@ -2561,7 +2602,6 @@ class SeqView(SeqViewABC, SliceRecordABC):
             start, stop = self.start, self.stop
 
         data["init_args"]["seq"] = self.seq[start:stop]
-        data["init_args"]["offset"] = int(self.parent_start)
         data["init_args"]["alphabet"] = self.alphabet.to_rich_dict()
         return data
 
@@ -2596,42 +2636,48 @@ class SeqView(SeqViewABC, SliceRecordABC):
 
 
 @singledispatch
-def _coerce_to_seqview(data, seqid, alphabet) -> SeqViewABC:
+def _coerce_to_seqview(data, seqid, alphabet, offset) -> SeqViewABC:
     from cogent3.core.alignment import Aligned
     from cogent3.core.sequence import Sequence as old_Sequence
     from cogent3.core.sequence import SeqView as old_SeqView
 
     if isinstance(data, (Aligned, old_Sequence, old_SeqView)):
-        return _coerce_to_seqview(str(data), seqid, alphabet)
+        return _coerce_to_seqview(str(data), seqid, alphabet, offset)
     raise NotImplementedError(f"{type(data)}")
 
 
 @_coerce_to_seqview.register
-def _(data: SeqViewABC, seqid, alphabet) -> SeqViewABC:
+def _(data: SeqViewABC, seqid, alphabet, offset) -> SeqViewABC:
+    if offset and data.offset:
+        raise ValueError(
+            f"cannot set {offset=} on a SeqView with an offset {data.offset=}"
+        )
+    elif offset:
+        data.offset = offset
     return data
 
 
 @_coerce_to_seqview.register
-def _(data: Sequence, seqid, alphabet) -> SeqViewABC:
-    return _coerce_to_seqview(data._seq, seqid, alphabet)
+def _(data: Sequence, seqid, alphabet, offset) -> SeqViewABC:
+    return _coerce_to_seqview(data._seq, seqid, alphabet, offset)
 
 
 @_coerce_to_seqview.register
-def _(data: str, seqid, alphabet) -> SeqViewABC:
-    return SeqView(seq=data, seqid=seqid, alphabet=alphabet)
+def _(data: str, seqid, alphabet, offset) -> SeqViewABC:
+    return SeqView(seq=data, seqid=seqid, alphabet=alphabet, offset=offset)
 
 
 @_coerce_to_seqview.register
-def _(data: bytes, seqid, alphabet) -> SeqViewABC:
+def _(data: bytes, seqid, alphabet, offset) -> SeqViewABC:
     data = data.decode("utf8")
-    return SeqView(seq=data, seqid=seqid, alphabet=alphabet)
+    return SeqView(seq=data, seqid=seqid, alphabet=alphabet, offset=offset)
 
 
 @_coerce_to_seqview.register
-def _(data: tuple, seqid, alphabet) -> SeqViewABC:
-    return _coerce_to_seqview("".join(data), seqid, alphabet)
+def _(data: tuple, seqid, alphabet, offset) -> SeqViewABC:
+    return _coerce_to_seqview("".join(data), seqid, alphabet, offset)
 
 
 @_coerce_to_seqview.register
-def _(data: list, seqid, alphabet) -> SeqViewABC:
-    return _coerce_to_seqview("".join(data), seqid, alphabet)
+def _(data: list, seqid, alphabet, offset) -> SeqViewABC:
+    return _coerce_to_seqview("".join(data), seqid, alphabet, offset)
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index 3b741c41e..058502587 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -822,7 +822,9 @@ class Sequence(SequenceI):
             else (lambda x: x)
         )
 
-        self._seq = _coerce_to_seqview(seq, self.name, preserve_case, checker)
+        self._seq = _coerce_to_seqview(
+            seq, self.name, preserve_case, checker, annotation_offset
+        )
 
         if not isinstance(info, InfoClass):
             try:
@@ -836,7 +838,6 @@ class Sequence(SequenceI):
 
         self._repr_policy = dict(num_pos=60)
         self._annotation_db = DEFAULT_ANNOTATION_DB()
-        self.annotation_offset = annotation_offset
 
     @property
     def annotation_offset(self):
@@ -857,10 +858,6 @@ class Sequence(SequenceI):
 
         return self._seq.parent_start
 
-    @annotation_offset.setter
-    def annotation_offset(self, value):
-        self._seq.offset = value
-
     @property
     def annotation_db(self):
         return self._annotation_db
@@ -1207,13 +1204,13 @@ class Sequence(SequenceI):
         exclude_annotations
             drops annotation_db when True
         """
-        annotation_offset = self.annotation_offset if sliced else self._seq.offset
         data = self._seq.copy(sliced=sliced)
+        # when slicing a seqview, if we sliced=True, it discards the
+        # offset attribute, in which case this needs to be provided to the
+        # seq constructor
+        offset = self.annotation_offset if sliced else 0
         new = self.__class__(
-            data,
-            name=self.name,
-            info=self.info,
-            annotation_offset=annotation_offset,
+            data, name=self.name, info=self.info, annotation_offset=offset
         )
         db = None if exclude_annotations else copy.deepcopy(self.annotation_db)
         new._annotation_db = db
@@ -1305,8 +1302,11 @@ class Sequence(SequenceI):
 
     def _mapped(self, map):
         # Called by generic __getitem__
+        offset = map.start if map.num_spans == 1 else 0
         segments = self.gapped_by_map_segment_iter(map, allow_gaps=False)
-        return self.__class__("".join(segments), self.name, info=self.info)
+        return self.__class__(
+            "".join(segments), self.name, info=self.info, annotation_offset=offset
+        )
 
     def __repr__(self):
         myclass = f"{self.__class__.__name__}"
@@ -1326,12 +1326,11 @@ class Sequence(SequenceI):
 
         if isinstance(index, (FeatureMap, IndelMap)):
             new = self._mapped(index)
-            preserve_offset = True
+            preserve_offset = index.num_spans == 1
 
         elif isinstance(index, slice) or _is_int(index):
-            new = self.__class__(
-                self._seq[index], name=self.name, check=False, info=self.info
-            )
+            _seq = self._seq[index]
+            new = self.__class__(_seq, name=self.name, check=False, info=self.info)
             stride = getattr(index, "step", 1) or 1
             preserve_offset = stride > 0
 
@@ -1340,7 +1339,6 @@ class Sequence(SequenceI):
 
         if self.annotation_db is not None and preserve_offset:
             new.replace_annotation_db(self.annotation_db, check=False)
-            new.annotation_offset = self.annotation_offset
 
         if _is_float(index):
             raise TypeError("cannot slice using float")
@@ -2384,7 +2382,6 @@ class SeqView(SliceRecordABC):
             start, stop = self.start, self.stop
 
         data["init_args"]["seq"] = self.seq[start:stop]
-        data["init_args"]["offset"] = int(self.parent_start)
         return data
 
     @classmethod
@@ -3001,51 +2998,67 @@ class ArrayProteinWithStopSequence(ArraySequence):
 
 
 @singledispatch
-def _coerce_to_seqview(data, seqid, preserve_case, checker):
+def _coerce_to_seqview(data, seqid, preserve_case, checker, annotation_offset):
     from cogent3.core.alignment import Aligned
 
     if isinstance(data, Aligned):
-        return _coerce_to_seqview(str(data), seqid, preserve_case, checker)
+        return _coerce_to_seqview(
+            str(data), seqid, preserve_case, checker, annotation_offset
+        )
     raise NotImplementedError(f"{type(data)}")
 
 
 @_coerce_to_seqview.register
-def _(data: SeqView, seqid, preserve_case, checker):
+def _(data: SeqView, seqid, preserve_case, checker, annotation_offset):
+    if annotation_offset and data.offset:
+        raise ValueError(
+            f"cannot set {annotation_offset=} on a SeqView with an offset {data.offset=}"
+        )
+    elif annotation_offset:
+        data.offset = annotation_offset
     return data
 
 
 @_coerce_to_seqview.register
-def _(data: Sequence, seqid, preserve_case, checker):
-    return _coerce_to_seqview(data._seq, seqid, preserve_case, checker)
+def _(data: Sequence, seqid, preserve_case, checker, annotation_offset):
+    return _coerce_to_seqview(
+        data._seq, seqid, preserve_case, checker, annotation_offset
+    )
 
 
 @_coerce_to_seqview.register
-def _(data: ArraySequence, seqid, preserve_case, checker):
-    return _coerce_to_seqview(str(data), seqid, preserve_case, checker)
+def _(data: ArraySequence, seqid, preserve_case, checker, annotation_offset):
+    return _coerce_to_seqview(
+        str(data), seqid, preserve_case, checker, annotation_offset
+    )
 
 
 @_coerce_to_seqview.register
-def _(data: str, seqid, preserve_case, checker):
+def _(data: str, seqid, preserve_case, checker, annotation_offset):
     if not preserve_case:
         data = data.upper()
     checker(data)
-    return SeqView(seq=data, seqid=seqid)
+    return SeqView(seq=data, seqid=seqid, offset=annotation_offset)
 
 
 @_coerce_to_seqview.register
-def _(data: bytes, seqid, preserve_case, checker):
+def _(data: bytes, seqid, preserve_case, checker, annotation_offset):
     if not preserve_case:
         data = data.upper()
     data = data.decode("utf8")
     checker(data)
-    return SeqView(seq=data, seqid=seqid)
+    return SeqView(seq=data, seqid=seqid, offset=annotation_offset)
 
 
 @_coerce_to_seqview.register
-def _(data: tuple, seqid, preserve_case, checker):
-    return _coerce_to_seqview("".join(data), seqid, preserve_case, checker)
+def _(data: tuple, seqid, preserve_case, checker, annotation_offset):
+    return _coerce_to_seqview(
+        "".join(data), seqid, preserve_case, checker, annotation_offset
+    )
 
 
 @_coerce_to_seqview.register
-def _(data: list, seqid, preserve_case, checker):
-    return _coerce_to_seqview("".join(data), seqid, preserve_case, checker)
+def _(data: list, seqid, preserve_case, checker, annotation_offset):
+    return _coerce_to_seqview(
+        "".join(data), seqid, preserve_case, checker, annotation_offset
+    )
diff --git a/src/cogent3/evolve/fast_distance.py b/src/cogent3/evolve/fast_distance.py
index b9a954279..bb58b0d22 100644
--- a/src/cogent3/evolve/fast_distance.py
+++ b/src/cogent3/evolve/fast_distance.py
@@ -2,7 +2,7 @@ from collections import defaultdict, namedtuple
 from numbers import Number
 from typing import Tuple
 
-import numpy as np
+import numpy
 from numpy import array, diag, dot, eye, float64, int32, log, sqrt, zeros
 from numpy.linalg import det, inv
 
@@ -706,17 +706,23 @@ class DistanceMatrix(DictArray):
     """pairwise distance matrix"""
 
     def __init__(self, dists, invalid=None):
-        super(DistanceMatrix, self).__init__(dists, dtype=float)
+        super().__init__(dists, dtype=float)
 
         self._invalid = invalid
 
+    @classmethod
+    def from_array_names(cls, matrix: numpy.ndarray, names, invalid=None):
+        """construct a distance matrix from numpy array and names"""
+        darr = DictArray.from_array_names(matrix, names, names)
+        return cls(darr, invalid=invalid)
+
     def __setitem__(self, names, value):
-        (index, remaining) = self.template.interpret_index(names)
+        index, _ = self.template.interpret_index(names)
         self.array[index] = value
         return
 
     def __getitem__(self, names):
-        (index, remaining) = self.template.interpret_index(names)
+        index, remaining = self.template.interpret_index(names)
         result = self.array[index]
         if remaining is not None:
             result = self.__class__(result, remaining)
@@ -741,7 +747,7 @@ class DistanceMatrix(DictArray):
 
     def to_dict(self, **kwargs):
         """Returns a flattened dict with diagonal elements removed"""
-        result = super(DistanceMatrix, self).to_dict(flatten=True)
+        result = super().to_dict(flatten=True)
         for n1 in self.names:
             del result[(n1, n1)]
         return result
@@ -782,20 +788,13 @@ class DistanceMatrix(DictArray):
         else:
             keep = [i for i, n in enumerate(current_names) if n in names]
 
+        if len(keep) <= 1:
+            return None
+
         data = self.array.take(keep, axis=0)
         data = data.take(keep, axis=1)
         names = current_names.take(keep)
-        dists = {
-            (names[i], names[j]): data[i, j]
-            for i in range(len(names))
-            for j in range(len(names))
-            if i != j
-        }
-        if not dists:
-            result = None
-        else:
-            result = self.__class__(dists)
-        return result
+        return self.from_array_names(data, names)
 
     def drop_invalid(self):
         """drops all rows / columns with an invalid entry"""
@@ -806,9 +805,9 @@ class DistanceMatrix(DictArray):
             raise RuntimeError("Must be a square matrix")
         names = array(self.names)
         # NaN is an invalid value
-        cols = np.isnan(self.array).sum(axis=0)
+        cols = numpy.isnan(self.array).sum(axis=0)
         exclude = names[cols != 0].tolist()
-        rows = np.isnan(self.array).sum(axis=1)
+        rows = numpy.isnan(self.array).sum(axis=1)
         exclude += names[rows != 0].tolist()
         exclude = set(exclude)
         keep = set(names) ^ exclude
@@ -842,8 +841,8 @@ class DistanceMatrix(DictArray):
         In case of multiple occurrences of the maximum values, the names
         corresponding to the first occurrence are returned.
         """
-        max_index_flat = np.argmax(self)
-        max_index_1, max_index_2 = np.unravel_index(max_index_flat, self.shape)
+        max_index_flat = numpy.argmax(self)
+        max_index_1, max_index_2 = numpy.unravel_index(max_index_flat, self.shape)
         max_pair = self.names[max_index_1], self.names[max_index_2]
 
         return max_pair
@@ -861,11 +860,9 @@ class DistanceMatrix(DictArray):
         corresponding to the first occurrence are returned.
         """
         dmat_copy = self.array.copy()
-        np.fill_diagonal(
-            dmat_copy, np.inf
+        numpy.fill_diagonal(
+            dmat_copy, numpy.inf
         )  # Exclude diagonal by setting diagonal elements to infinity
-        min_index_flat = np.argmin(dmat_copy)
-        min_index_1, min_index_2 = np.unravel_index(min_index_flat, self.shape)
-        min_pair = self.names[min_index_1], self.names[min_index_2]
-
-        return min_pair
+        min_index_flat = numpy.argmin(dmat_copy)
+        min_index_1, min_index_2 = numpy.unravel_index(min_index_flat, self.shape)
+        return self.names[min_index_1], self.names[min_index_2]
diff --git a/src/cogent3/util/dict_array.py b/src/cogent3/util/dict_array.py
index 75a92ae21..cc7e69fa7 100644
--- a/src/cogent3/util/dict_array.py
+++ b/src/cogent3/util/dict_array.py
@@ -21,6 +21,7 @@ A    B    C
 """
 
 import json
+import typing
 from collections import defaultdict
 from itertools import combinations, product
 
@@ -243,7 +244,7 @@ class NumericKey(int):
         return int.__new__(cls, val)
 
 
-class DictArrayTemplate(object):
+class DictArrayTemplate:
     def __init__(self, *dimensions):
         self.names = []
         self.ordinals = []
@@ -337,7 +338,7 @@ class DictArrayTemplate(object):
         return (tuple(index), klass)
 
 
-class DictArray(object):
+class DictArray:
     """Wraps a numpy array so that it can be indexed with strings. Behaves
     like nested dictionaries (only ordered).
 
@@ -377,6 +378,26 @@ class DictArray(object):
             self.__dict__ = create_new.__dict__
         self.shape = self.array.shape
 
+    @classmethod
+    def from_array_names(cls, array: numpy.ndarray, *names: typing.Sequence[str]):
+        """creates instance directly from a numpy array and series of names
+
+        Parameters
+        ----------
+        array
+            any data type
+        names
+            must match the array dimensions
+        """
+
+        if len(names) != array.ndim or any(
+            len(labels) != array.shape[dim] for dim, labels in enumerate(names)
+        ):
+            raise ValueError("names must match array dimensions")
+
+        template = DictArrayTemplate(*names)
+        return DictArray(array, template)
+
     def to_array(self):
         return self.array
 


Test patch:
diff --git a/tests/test_core/test_core_standalone.py b/tests/test_core/test_core_standalone.py
index 3065e3330..a25dc62a6 100644
--- a/tests/test_core/test_core_standalone.py
+++ b/tests/test_core/test_core_standalone.py
@@ -33,15 +33,6 @@ DATA_DIR = pathlib.Path(__file__).parent.parent / "data"
 
 
 class TestConstructorFunctions(unittest.TestCase):
-    def test_make_seq(self):
-        """test constructor utility function"""
-        _seq = "ACGGT"
-        seq = make_seq(seq=_seq)
-        self.assertEqual(seq.moltype.label, "text")
-        seq = make_seq(seq=_seq, moltype="dna")
-        self.assertEqual(seq.moltype.label, "dna")
-        self.assertEqual(str(seq), _seq)
-
     def test_make_aligned_seqs(self):
         """test Alignment/ArrayAlignment constructor utility function"""
         data = {"a": "AGGTT", "b": "AGAA-"}
@@ -880,30 +871,55 @@ def test_load_unaligned_seqs_no_format(new_type):
         load_unaligned_seqs("somepath", moltype="dna", new_type=new_type)
 
 
-def test_load_seq():
+@pytest.fixture(
+    params=zip(
+        (
+            "c_elegans_WS199_dna_shortened.fasta",
+            "annotated_seq.gb",
+            "brca1_5.250.paml",
+        ),
+        ("I", "AE017341", "NineBande"),
+    )
+)
+def path_name(request):
+    yield request.param
+
+
+def test_load_seq(path_name, tmp_path, DATA_DIR):
     """load single sequence"""
-    # refactor: design
-    # we should use the pytest tmp_path fixture, more robust than tmpdir
     from cogent3 import Sequence
 
-    paths = (
-        "c_elegans_WS199_dna_shortened.fasta",
-        "annotated_seq.gb",
-        "brca1_5.250.paml",
-    )
-    seq_names = ("I", "AE017341", "NineBande")
-    data_dir = pathlib.Path(DATA_DIR)
-    for i, path in enumerate(paths):
-        got = load_seq(data_dir / path)
-        assert isinstance(got, Sequence)
-        assert got.info.source == str(data_dir / path)
-        assert got.name == seq_names[i]
+    path, name = path_name
+    seq = load_seq(DATA_DIR / path)
+    assert isinstance(seq, Sequence)
+    assert seq.info.source == str(DATA_DIR / path)
+    assert seq.name == name
 
     # try json
-    seq = got
-    with TemporaryDirectory(dir=".") as dirname:
-        outpath = pathlib.Path(dirname) / "seq.json"
-        outpath.write_text(seq.to_json())
-        got = load_seq(outpath)
-        assert str(got) == str(seq)
-        assert got.name == seq.name
+    outpath = tmp_path / "seq.json"
+    outpath.write_text(seq.to_json())
+    got = load_seq(outpath)
+    assert str(got) == str(seq)
+    assert got.name == seq.name
+
+
+@pytest.mark.parametrize("offset", (0, 20))
+@pytest.mark.parametrize("moltype", ("dna", "text"))
+def test_make_seq(moltype, offset):
+    """test constructor utility function"""
+    _seq = "ACGGT"
+    seq = make_seq(seq=_seq, moltype=moltype, annotation_offset=offset)
+    assert seq.moltype.label == moltype
+    assert seq.annotation_offset == offset
+    assert str(seq) == _seq
+
+
+@pytest.mark.parametrize("offset", (0, 20))
+@pytest.mark.parametrize("moltype", ("dna", "text"))
+def test_load_seq_moltype_offset(moltype, offset, DATA_DIR):
+    """test loader propagates moltype and offset args"""
+    seq = load_seq(
+        DATA_DIR / "brca1_5.250.paml", moltype=moltype, annotation_offset=offset
+    )
+    assert seq.moltype.label == moltype
+    assert seq.annotation_offset == offset
diff --git a/tests/test_core/test_new_sequence.py b/tests/test_core/test_new_sequence.py
index 40c6eade5..dd93c4d1a 100644
--- a/tests/test_core/test_new_sequence.py
+++ b/tests/test_core/test_new_sequence.py
@@ -1915,19 +1915,19 @@ def test_absolute_position_base_cases(one_seq):
 
 def test_absolute_position_positive(one_seq):
     # with an offset, the abs index should be offset + index
-    one_seq.annotation_offset = 2
+    one_seq._seq.offset = 2
     got = one_seq._seq.absolute_position(2)
     assert got == 2 + 2
 
     # with an offset and start, the abs index should be offset + start + index
     view = one_seq[2::]
-    view.annotation_offset = 2  # todo: do we want the annotation_offset to be preserved when slicing? I think yes
+    view._seq.offset = 2  # todo: do we want the annotation_offset to be preserved when slicing? I think yes
     got = view._seq.absolute_position(2)
     assert got == 2 + 2 + 2
 
     # with an offset, start and step, the abs index should be offset + start + index * step
     view = one_seq[2::2]
-    view.annotation_offset = 2
+    view._seq.offset = 2
     got = view._seq.absolute_position(2)
     assert got == 2 + 2 + 2 * 2
 
@@ -2014,7 +2014,7 @@ def test_relative_position_with_remainder(integer_seq):
 def test_absolute_relative_roundtrip(one_seq, value, offset, start, stop, step):
     # a round trip from relative to absolute then from absolute to relative, should return the same value we began with
     view = one_seq[start:stop:step]
-    view.annotation_offset = offset or 0
+    view._seq.offset = offset or 0
     abs_val = view._seq.absolute_position(value)
     rel_val = view._seq.relative_position(abs_val)
     assert rel_val == value
@@ -2163,7 +2163,6 @@ def test_sliced_seqview_rich_dict(reverse, dna_alphabet):
     sv = sv[::-1] if reverse else sv
     rd = sv.to_rich_dict()
     assert rd["init_args"]["seq"] == parent[sl]
-    assert rd["init_args"]["offset"] == 2
 
 
 @pytest.mark.parametrize(
@@ -2387,7 +2386,7 @@ def test_coerce_to_seqview_str_bytes(cls, dna_alphabet):
     seq = "AC--GGTGGGAC"
     seqid = "seq1"
     s = bytes(seq, "utf8") if cls == bytes else seq
-    got = new_sequence._coerce_to_seqview(s, seqid, alphabet=dna_alphabet)
+    got = new_sequence._coerce_to_seqview(s, seqid, alphabet=dna_alphabet, offset=0)
     assert got.str_value == seq
     assert isinstance(got, new_sequence.SeqView)
 
@@ -2396,7 +2395,7 @@ def test_coerce_to_seqview_sequence(dna_alphabet):
     seq = "AC--GGTGGGAC"
     seqid = "seq1"
     got = new_sequence._coerce_to_seqview(
-        new_moltype.DNA.make_seq(seq=seq), seqid, alphabet=dna_alphabet
+        new_moltype.DNA.make_seq(seq=seq), seqid, alphabet=dna_alphabet, offset=0
     )
     assert got.str_value == seq
     assert isinstance(got, new_sequence.SeqView)
@@ -2409,6 +2408,7 @@ def test_coerce_to_seqview_already_seqview(dna_alphabet):
         new_sequence.SeqView(seq=seq, alphabet=dna_alphabet),
         seqid,
         alphabet=dna_alphabet,
+        offset=0,
     )
     assert got.str_value == seq
     assert isinstance(got, new_sequence.SeqView)
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index bff57ccd5..6a9f0085d 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -2126,19 +2126,19 @@ def test_absolute_position_base_cases(one_seq):  # ported
 
 def test_absolute_position_positive(one_seq):  # ported
     # with an offset, the abs index should be offset + index
-    one_seq.annotation_offset = 2
+    one_seq._seq.offset = 2
     got = one_seq._seq.absolute_position(2)
     assert got == 2 + 2
 
     # with an offset and start, the abs index should be offset + start + index
     view = one_seq[2::]
-    view.annotation_offset = 2  # todo: do we want the annotation_offset to be preserved when slicing? I think yes
+    view._seq.offset = 2  # todo: do we want the annotation_offset to be preserved when slicing? I think yes
     got = view._seq.absolute_position(2)
     assert got == 2 + 2 + 2
 
     # with an offset, start and step, the abs index should be offset + start + index * step
     view = one_seq[2::2]
-    view.annotation_offset = 2
+    view._seq.offset = 2
     got = view._seq.absolute_position(2)
     assert got == 2 + 2 + 2 * 2
 
@@ -2233,7 +2233,7 @@ def test_absolute_relative_roundtrip(
 ):  # ported
     # a round trip from relative to absolute then from absolute to relative, should return the same value we began with
     view = one_seq[start:stop:step]
-    view.annotation_offset = offset or 0
+    view._seq.offset = offset or 0
     abs_val = view._seq.absolute_position(value)
     rel_val = view._seq.relative_position(abs_val)
     assert rel_val == value
@@ -2418,7 +2418,6 @@ def test_sliced_seqview_rich_dict(reverse):  # ported
         sv = sv[::-1]
     rd = sv.to_rich_dict()
     assert rd["init_args"]["seq"] == parent[sl]
-    assert rd["init_args"]["offset"] == 2
 
 
 @pytest.mark.parametrize(
@@ -2681,21 +2680,32 @@ def test_coerce_to_seqview(cls):  # ported for Sequence, SeqView, str and bytes
     seq = "AC--GGTGGGAC"
     seqid = "seq1"
     if cls in (str, bytes):
-        got = _coerce_to_seqview(seq, seqid, preserve_case=True, checker=(lambda x: x))
+        got = _coerce_to_seqview(
+            seq, seqid, preserve_case=True, checker=(lambda x: x), annotation_offset=0
+        )
     elif cls is Aligned:
         got = _coerce_to_seqview(
             cls(*Sequence(seq).parse_out_gaps()),
             seqid,
             preserve_case=True,
             checker=(lambda x: x),
+            annotation_offset=0,
         )
     elif cls is SeqView:
         got = _coerce_to_seqview(
-            cls(seq=seq), seqid, preserve_case=True, checker=(lambda x: x)
+            cls(seq=seq),
+            seqid,
+            preserve_case=True,
+            checker=(lambda x: x),
+            annotation_offset=0,
         )
     else:
         got = _coerce_to_seqview(
-            cls(seq), seqid, preserve_case=True, checker=(lambda x: x)
+            cls(seq),
+            seqid,
+            preserve_case=True,
+            checker=(lambda x: x),
+            annotation_offset=0,
         )
     assert got.value == seq
     assert isinstance(got, SeqView)
@@ -2771,3 +2781,17 @@ def test_seqview_seq_len_modified_seq():  # ported
 
     sv.seq = "ATGC"  # this should not modify seq_len
     assert sv.seq_len == len(seq)
+
+
+@pytest.mark.parametrize("new_type", [True, False])
+def test_preserving_offsets(new_type):
+    raw_seq = "ACGT"
+    seq = cogent3.make_seq(raw_seq, moltype="dna", new_type=new_type)
+    start, stop, step = 1, 2, 1
+    expect = raw_seq[start:stop:step]
+    sl = seq[start:stop:None]
+
+    assert str(sl) == expect
+    s2 = sl[::1]
+    assert str(s2) == expect
+    assert sl.annotation_offset == s2.annotation_offset
diff --git a/tests/test_evolve/test_distance.py b/tests/test_evolve/test_distance.py
index 5a72c8202..b4e9a51c0 100644
--- a/tests/test_evolve/test_distance.py
+++ b/tests/test_evolve/test_distance.py
@@ -612,45 +612,6 @@ class TestDistanceMatrix(TestCase):
         with self.assertRaises(ValueError):
             darr = DistanceMatrix(data)
 
-    def test_dropping_from_matrix(self):
-        """pairwise distances should have method for dropping invalid data"""
-        data = {
-            ("ABAYE2984", "Atu3667"): None,
-            ("ABAYE2984", "Avin_42730"): 0.638,
-            ("ABAYE2984", "BAA10469"): None,
-            ("Atu3667", "ABAYE2984"): None,
-            ("Atu3667", "Avin_42730"): 2.368,
-            ("Atu3667", "BAA10469"): None,
-            ("Avin_42730", "ABAYE2984"): 0.638,
-            ("Avin_42730", "Atu3667"): 2.368,
-            ("Avin_42730", "BAA10469"): 1.85,
-            ("BAA10469", "ABAYE2984"): None,
-            ("BAA10469", "Atu3667"): None,
-            ("BAA10469", "Avin_42730"): 1.85,
-        }
-
-        darr = DistanceMatrix(data)
-        new = darr.drop_invalid()
-        self.assertEqual(new, None)
-
-        data = {
-            ("ABAYE2984", "Atu3667"): 0.25,
-            ("ABAYE2984", "Avin_42730"): 0.638,
-            ("ABAYE2984", "BAA10469"): None,
-            ("Atu3667", "ABAYE2984"): 0.25,
-            ("Atu3667", "Avin_42730"): 2.368,
-            ("Atu3667", "BAA10469"): 0.25,
-            ("Avin_42730", "ABAYE2984"): 0.638,
-            ("Avin_42730", "Atu3667"): 2.368,
-            ("Avin_42730", "BAA10469"): 1.85,
-            ("BAA10469", "ABAYE2984"): 0.25,
-            ("BAA10469", "Atu3667"): 0.25,
-            ("BAA10469", "Avin_42730"): 1.85,
-        }
-        darr = DistanceMatrix(data)
-        new = darr.drop_invalid()
-        self.assertEqual(new.shape, (2, 2))
-
     def test_take_dists(self):
         """subsets the distance matrix"""
         data = {
@@ -995,3 +956,57 @@ def test_min_pair_tied():
     got = set(dmat.min_pair())
     expect = {frozenset(("A", "B")), frozenset(("A", "C"))}
     assert got in expect
+
+
+def test_dropping_from_matrix():
+    """pairwise distances should have method for dropping invalid data"""
+    data = {
+        ("ABAYE2984", "Atu3667"): None,
+        ("ABAYE2984", "Avin_42730"): 0.638,
+        ("ABAYE2984", "BAA10469"): None,
+        ("Atu3667", "ABAYE2984"): None,
+        ("Atu3667", "Avin_42730"): 2.368,
+        ("Atu3667", "BAA10469"): None,
+        ("Avin_42730", "ABAYE2984"): 0.638,
+        ("Avin_42730", "Atu3667"): 2.368,
+        ("Avin_42730", "BAA10469"): 1.85,
+        ("BAA10469", "ABAYE2984"): None,
+        ("BAA10469", "Atu3667"): None,
+        ("BAA10469", "Avin_42730"): 1.85,
+    }
+
+    darr = DistanceMatrix(data)
+    new = darr.drop_invalid()
+    assert new is None
+
+    data = {
+        ("ABAYE2984", "Atu3667"): 0.25,
+        ("ABAYE2984", "Avin_42730"): 0.638,
+        ("ABAYE2984", "BAA10469"): None,
+        ("Atu3667", "ABAYE2984"): 0.25,
+        ("Atu3667", "Avin_42730"): 2.368,
+        ("Atu3667", "BAA10469"): 0.25,
+        ("Avin_42730", "ABAYE2984"): 0.638,
+        ("Avin_42730", "Atu3667"): 2.368,
+        ("Avin_42730", "BAA10469"): 1.85,
+        ("BAA10469", "ABAYE2984"): 0.25,
+        ("BAA10469", "Atu3667"): 0.25,
+        ("BAA10469", "Avin_42730"): 1.85,
+    }
+    darr = DistanceMatrix(data)
+    new = darr.drop_invalid()
+    assert new.shape == (2, 2)
+
+
+def test_distance_matrix_from_array_names():
+    data = numpy.array(
+        [
+            [0.7, 0.1, 0.2, 0.3],
+            [0.1, 0.7, 0.1, 0.3],
+            [0.3, 0.2, 0.6, 0.3],
+            [0.4, 0.1, 0.1, 0.7],
+        ]
+    )
+    names = list("abcd")
+    got = DistanceMatrix.from_array_names(data, names)
+    assert got["b", "c"] == 0.1
diff --git a/tests/test_util/test_dictarray.py b/tests/test_util/test_dictarray.py
index 1828002d7..027475ac1 100644
--- a/tests/test_util/test_dictarray.py
+++ b/tests/test_util/test_dictarray.py
@@ -4,6 +4,7 @@ from tempfile import TemporaryDirectory
 from unittest import TestCase
 
 import numpy
+import pytest
 from numpy.testing import assert_allclose
 
 from cogent3 import DNA
@@ -494,3 +495,22 @@ class DictArrayTest(TestCase):
         # must be equal dimensions
         with self.assertRaises(ValueError):
             darr1 + DictArrayTemplate(list("CD"), list("AB")).wrap(data)
+
+
+_d = [
+    [0.7, 0.1, 0.2, 0.3],
+    [0.1, 0.7, 0.1, 0.3],
+    [0.3, 0.2, 0.6, 0.3],
+    [0.4, 0.1, 0.1, 0.7],
+]
+_n = list("TCAG"), list("TCAG")
+
+
+@pytest.mark.parametrize("data,names", ((_d, _n), (_d[0], (_n[0],))))
+def test_dictarray_from_array_names(data, names):
+    data = numpy.array(data)
+    darr = DictArray.from_array_names(data, *names)
+    # dimension names correct
+    assert darr.template.names == list(names)
+    # indexed value correct, can be an array, or a float
+    assert_allclose(darr["T"], data[0])