Repository: cogent3/cogent3
Pull request #1727 (2024-02-20 06:04:36)

Progress towards refactor of map


Patch:
diff --git a/src/cogent3/align/traceback.py b/src/cogent3/align/traceback.py
index 626d30c09..16a8d0ceb 100644
--- a/src/cogent3/align/traceback.py
+++ b/src/cogent3/align/traceback.py
@@ -3,7 +3,7 @@
 gapped sequences or Cogent Alignment objects"""
 
 from cogent3.core.alignment import Aligned, Alignment
-from cogent3.core.annotation import Map
+from cogent3.core.location import IndelMap
 
 
 def seq_traceback(s1, s2, aligned_positions, gap_value):
@@ -56,10 +56,13 @@ def gap_traceback(aligned_positions):
 
 
 def map_traceback(aligned_positions):
-    # using Map's to keep track of gaps for indel alignment
+    # using IndelMap's to keep track of gaps for indel alignment
     (starts, ends, gap_vectors, alignment_len) = gap_traceback(aligned_positions)
     # print 'gv', gap_vectors
-    maps = [Map(gv, parent_length=alignment_len).inverse() for gv in gap_vectors]
+    maps = [
+        IndelMap(locations=gv, parent_length=alignment_len).inverse()
+        for gv in gap_vectors
+    ]
     return (starts, ends, maps)
 
 
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index 77a317b1a..b8e982ba3 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -56,7 +56,7 @@ from numpy.random import choice, permutation, randint
 import cogent3  # will use to get at cogent3.parse.fasta.MinimalFastaParser,
 
 from cogent3._version import __version__
-from cogent3.core.annotation import Feature, Map
+from cogent3.core.annotation import Feature
 from cogent3.core.annotation_db import (
     BasicAnnotationDb,
     FeatureDataType,
@@ -66,6 +66,7 @@ from cogent3.core.annotation_db import (
 )
 from cogent3.core.genetic_code import get_code
 from cogent3.core.info import Info as InfoClass
+from cogent3.core.location import FeatureMap, IndelMap
 from cogent3.core.profile import PSSM, MotifCountsArray
 from cogent3.core.sequence import ArraySequence, Sequence, frac_same
 # which is a circular import otherwise.
@@ -636,11 +637,12 @@ class _SequenceCollectionBase:
             annotations.
         """
         if isinstance(self, Alignment):
-            reversed = self.seqs[0].map.reverse
+            *_, strand = self.seqs[0].data.parent_coordinates()
+
         else:
-            # WARNING GAH this is tightly coupling to implementation of seq attribute!
-            # todo GAH make method on Sequence?
-            reversed = self.seqs[0]._seq.reverse
+            *_, strand = self.seqs[0].parent_coordinates()
+
+        reversed = strand == "-"
         new_seqs = dict()
         db = None if reversed and sliced else deepcopy(self.annotation_db)
         for seq in self.seqs:
@@ -2145,7 +2147,7 @@ class Aligned:
         # Unlike the normal map constructor, here we take a list of pairs of
         # alignment coordinates, NOT a list of pairs of sequence coordinates
         if isinstance(map, list):
-            map = Map(map, parent_length=length).inverse()
+            map = IndelMap(locations=map, parent_length=length).inverse()
         self.map = map
         self.data = data
         if hasattr(data, "info"):
@@ -2189,7 +2191,8 @@ class Aligned:
         new_seq = self.data.copy(exclude_annotations=exclude_annotations, sliced=sliced)
         if sliced:
             db = new_seq.annotation_db
-            if self.map.reverse or exclude_annotations:
+            *_, strand = self.data.parent_coordinates()
+            if strand == "-" or exclude_annotations:
                 new_seq.annotation_db = None
             else:
                 new_seq.annotation_offset = self.map.start
@@ -2241,20 +2244,23 @@ class Aligned:
 
     def __add__(self, other):
         if self.data is other.data:
-            (map, seq) = (self.map + other.map, self.data)
+            map, seq = self.map + other.map, self.data
         else:
             seq = self.get_gapped_seq() + other.get_gapped_seq()
-            (map, seq) = seq.parse_out_gaps()
+            map, seq = seq.parse_out_gaps()
         return Aligned(map, seq)
 
     def __getitem__(self, slice):
+        # todo we need to get the sequence coordinates that slice corresponds to
+        #  so we can update the self.data, plus we will need to zero new_map
         new_map = self.map[slice]
         data = (
-            self.data[new_map.start : new_map.end] if new_map.useful else self.data[0:0]
+            self.data[new_map.start : new_map.end] if new_map.useful else self.data[:0]
         )
-        if new_map.reverse:
-            # A reverse slice means we should have an empty sequence
-            new_map = type(new_map)(locations=(), parent_length=len(self.data))
+        if new_map.useful and new_map.start > new_map.end:
+            # For now, a reverse slice means we should have an empty sequence
+            # todo modify this clause if a negative step is ever allowed
+            new_map = new_map.__class__(locations=(), parent_length=len(self.data))
         elif new_map.useful:
             new_map = new_map.zeroed()
         return Aligned(new_map, data)
@@ -2297,7 +2303,7 @@ class Aligned:
             deserialise_seq,
         )
 
-        map_ = deserialise_map_spans(data["map_init"])
+        map_ = IndelMap.from_rich_dict(data["map_init"])
         seq = deserialise_seq(data["seq_init"])
         return cls(map_, seq)
 
@@ -2325,7 +2331,9 @@ class Aligned:
     def make_feature(self, feature: FeatureDataType, alignment: "Alignment") -> Feature:
         """returns a feature, not written into annotation_db"""
         annot = self.data.make_feature(feature)
-        return annot.remapped_to(alignment, self.map.inverse())
+        inverted = self.map.inverse()
+        # todo should indicate whether tidy or not
+        return annot.remapped_to(alignment, inverted)
 
     def gap_vector(self):
         """Returns gap_vector of GappedSeq, for omit_gap_pos."""
@@ -2826,7 +2834,7 @@ class AlignmentI(object):
         positions = [
             (loc * motif_length, (loc + 1) * motif_length) for loc in locations
         ]
-        sample = Map(positions, parent_length=len(self))
+        sample = IndelMap(locations=positions, parent_length=len(self))
         return self.gapped_by_map(sample, info=self.info)
 
     def sliding_windows(self, window, step, start=None, end=None):
@@ -4659,7 +4667,7 @@ class Alignment(AlignmentI, SequenceCollection):
                 raise ValueError(f"feature.parent {index.seqid!r} is not self")
             return index.get_slice()
 
-        if isinstance(index, Map):
+        if isinstance(index, (FeatureMap, IndelMap)):
             new = self._mapped(index)
 
         elif isinstance(index, (int, slice)):
@@ -4833,7 +4841,7 @@ class Alignment(AlignmentI, SequenceCollection):
 
         locations = [(gv[i], gv[i + 1]) for i in range(0, len(gv), 2)]
 
-        keep = Map(locations, parent_length=len(self))
+        keep = IndelMap(locations=locations, parent_length=len(self))
         return self.gapped_by_map(keep, info=self.info)
 
     def get_seq(self, seqname):
@@ -4841,8 +4849,7 @@ class Alignment(AlignmentI, SequenceCollection):
 
         Note: always returns Sequence object, not ArraySequence.
         """
-        seq = self.named_seqs[seqname]
-        return seq.data[seq.map.without_gaps()]
+        return self.named_seqs[seqname].data
 
     def get_gapped_seq(self, seq_name, recode_gaps=False):
         """Return a gapped Sequence object for the specified seqname.
@@ -5232,7 +5239,7 @@ class Alignment(AlignmentI, SequenceCollection):
         # in Sequence?
         revd = feature.pop("strand", None) == "-"
         feature["strand"] = "-" if revd else "+"
-        fmap = Map(parent_length=len(self), locations=feature.pop("spans"))
+        fmap = FeatureMap(parent_length=len(self), locations=feature.pop("spans"))
         if revd:
             fmap = fmap.nucleic_reversed()
         return Feature(parent=self, map=fmap, **feature)
diff --git a/src/cogent3/core/annotation.py b/src/cogent3/core/annotation.py
index 5f8a4270b..8ddc86a6e 100644
--- a/src/cogent3/core/annotation.py
+++ b/src/cogent3/core/annotation.py
@@ -2,7 +2,7 @@ from typing import Iterable, Optional
 
 from numpy import array
 
-from .location import Map
+from .location import FeatureMap
 
 
 # todo gah write docstrings!
@@ -23,7 +23,14 @@ class Feature:
 
     # todo gah implement a __new__ to trap args for serialisation purposes?
     def __init__(
-        self, *, parent, seqid: str, map: Map, biotype: str, name: str, strand: str
+        self,
+        *,
+        parent,
+        seqid: str,
+        map: FeatureMap,
+        biotype: str,
+        name: str,
+        strand: str,
     ):
         # _serialisable is used for creating derivative instances
         d = locals()
@@ -147,6 +154,11 @@ class Feature:
         return f"{name}({txt})"
 
     def remapped_to(self, grandparent, gmap):
+        # grandparent can be either a Sequence or an Alignment
+        if not isinstance(gmap, FeatureMap):
+            # due to separation of IndelMap and Map, change class
+            gmap = gmap.to_feature_map()
+
         seqid = grandparent.name or f"from {self.seqid!r}"
         kwargs = {
             **self._serialisable,
@@ -213,13 +225,13 @@ class Feature:
         -----
         Overlapping spans are merged
         """
-        combined = self.map.spans[:]
+        combined = list(self.map.spans)
         feat_names = [self.name] if self.name else set()
         biotypes = {self.biotype} if self.biotype else set()
         seqids = {self.seqid} if self.seqid else set()
         for feature in features:
             if feature.parent is not self.parent:
-                raise ValueError(f"cannot merge annotations from different objects")
+                raise ValueError("cannot merge annotations from different objects")
 
             combined.extend(feature.map.spans)
             if feature.name:
@@ -229,7 +241,7 @@ class Feature:
             if feature.biotype:
                 biotypes.add(feature.biotype)
         name = ", ".join(feat_names)
-        map = Map(spans=combined, parent_length=len(self.parent))
+        map = FeatureMap(spans=combined, parent_length=len(self.parent))
         map = map.covered()  # No overlaps
         # the covered method drops reversed status so we need to
         # resurrect that, but noting we've not checked consistency
diff --git a/src/cogent3/core/location.py b/src/cogent3/core/location.py
index db6c9d67c..d8ce1066a 100644
--- a/src/cogent3/core/location.py
+++ b/src/cogent3/core/location.py
@@ -20,11 +20,14 @@ and d), etc. Similarly, Span(0,2) does _not_ overlap Span(2,3), since the
 former contains a and b while the latter contains c.
 """
 import copy
+import dataclasses
+import inspect
 
+from abc import ABC, abstractmethod
 from bisect import bisect_left, bisect_right
 from functools import total_ordering
 from itertools import chain
-from typing import Union
+from typing import Iterator, List, Optional, Sequence, Tuple, Union
 
 from numpy import array, ndarray
 
@@ -66,25 +69,21 @@ def _norm_slice(index, length):
         return (start, start + 1, 1)
 
 
-def as_map(slice, length):
+def as_map(slice, length, cls):
     """Take anything that might be used as a subscript: Integer, Slice,
-    or Map, and return a Map."""
+    or MapABC, and return cls."""
 
     if isinstance(slice, (list, tuple)):
         spans = []
         for i in slice:
-            spans.extend(as_map(i, length).spans)
-        map = Map(spans=spans, parent_length=length)
-    elif isinstance(slice, Map):
-        map = slice
-        # TODO reasons for failure when the following is not commented out
-        # should be checked further
-        # assert map.parent_length == length, (map, length)
+            spans.extend(as_map(i, length).spans, cls)
+        return cls(spans=spans, parent_length=length)
+    elif isinstance(slice, (FeatureMap, IndelMap)):
+        return slice
     else:
-        (lo, hi, step) = _norm_slice(slice, length)
+        lo, hi, step = _norm_slice(slice, length)
         assert (step or 1) == 1
-        map = Map([(lo, hi)], parent_length=length)
-    return map
+        return cls(locations=[(lo, hi)], parent_length=length)
 
 
 class SpanI(object):
@@ -323,13 +322,13 @@ class Span(SpanI):
         C is a span of a feature on B which itself is a feature on A, so to
         place C on A return that part of B (map) covered by C (self)"""
 
-        (offsets, spans) = (map.offsets, map.spans)
+        offsets, spans = map.offsets, list(map.spans)
         map_length = offsets[-1] + spans[-1].length
 
         # don't try to remap any non-corresponding end region(s)
         # this won't matter if all spans lie properly within their
         # parent maps, but that might not be true of Display slices.
-        (zlo, zhi) = (max(0, self.start), min(map_length, self.end))
+        zlo, zhi = max(0, self.start), min(map_length, self.end)
 
         # Find the right span(s) of the map
         first = bisect_right(offsets, zlo) - 1
@@ -450,7 +449,7 @@ class Span(SpanI):
             return type(self) == type(other)
 
 
-class _LostSpan(object):
+class _LostSpan:
     """A placeholder span which doesn't exist in the underlying sequence"""
 
     __slots__ = ["length", "value", "_serialisable"]
@@ -529,7 +528,7 @@ class TerminalPadding(_LostSpan):
         return f"?{self.length}?"
 
 
-class Map:
+class Map:  # pragma: no cover
     """A map holds a list of spans."""
 
     def __init__(
@@ -552,7 +551,7 @@ class Map:
                 reverse = start > end
                 if max(start, end) < 0 or min(start, end) > parent_length:
                     raise RuntimeError(
-                        f"located outside sequence: {str((start, end, parent_length))}"
+                        f"located outside sequence: {(start, end, parent_length)}"
                     )
                 if max(start, end) > parent_length and min(start, end) < 0:
                     l_diff = min(start, end)
@@ -605,12 +604,13 @@ class Map:
                     self.reverse = None
 
         if termini_unknown:
+            spans = list(spans)
             if spans[0].lost:
                 spans[0] = TerminalPadding(spans[0].length)
             if spans[-1].lost:
                 spans[-1] = TerminalPadding(spans[-1].length)
 
-        self.spans = spans
+        self.spans = tuple(spans)
         self.length = posn
         self.parent_length = parent_length
         self.__inverse = None
@@ -619,11 +619,11 @@ class Map:
         return self.length
 
     def __repr__(self):
-        return repr(self.spans) + f"/{self.parent_length}"
+        return repr(list(self.spans)) + f"/{self.parent_length}"
 
     def __getitem__(self, slice):
         # A possible shorter map at the same level
-        slice = as_map(slice, len(self))
+        slice = as_map(slice, len(self), self.__class__)
         new_parts = []
         for span in slice.spans:
             new_parts.extend(span.remap_with(self))
@@ -1099,7 +1099,836 @@ def RangeFromString(string, delimiter=","):  # pragma: no cover
     return result
 
 
-def gap_coords_to_map(gaps_lengths: dict, seq_length: int) -> Map:
+class MapABC(ABC):
+    """base class for genomic map objects"""
+
+    def __new__(cls, *args, **kwargs):
+        obj = object.__new__(cls)
+        init_sig = inspect.signature(cls.__init__)
+        bargs = init_sig.bind_partial(cls, *args, **kwargs)
+        bargs.apply_defaults()
+        init_vals = bargs.arguments
+        init_vals.pop("self", None)
+
+        obj._serialisable = init_vals
+        return obj
+
+    @abstractmethod
+    def __len__(self):
+        ...
+
+    @abstractmethod
+    def __add__(self, other):
+        ...
+
+    @classmethod
+    @abstractmethod
+    def from_rich_dict(cls, data):
+        ...
+
+    @abstractmethod
+    def gaps(self):
+        ...
+
+    @abstractmethod
+    def nongap(self):
+        ...
+
+    @abstractmethod
+    def get_coordinates(self):
+        ...
+
+    @abstractmethod
+    def nucleic_reversed(self):
+        ...
+
+    @abstractmethod
+    def to_rich_dict(self):
+        ...
+
+    @abstractmethod
+    def inverse(self):
+        ...
+
+
+def _spans_from_locations(locations, parent_length) -> Tuple[Union[Span, _LostSpan]]:
+    spans = []
+    for start, end in locations:
+        reverse = start > end
+        if max(start, end) < 0 or min(start, end) > parent_length:
+            raise RuntimeError(
+                f"located outside sequence: {(start, end, parent_length)}"
+            )
+        if max(start, end) > parent_length and min(start, end) < 0:
+            l_diff = min(start, end)
+            r_diff = max(start, end) - parent_length
+            start, end = (0, parent_length) if start < end else (parent_length, 0)
+            spans += [
+                LostSpan(abs(l_diff)),
+                Span(start, end, reverse=reverse),
+                LostSpan(abs(r_diff)),
+            ]
+        elif min(start, end) < 0:
+            diff = min(start, end)
+            start = max(start, 0)
+            end = max(end, 0)
+            spans += [
+                LostSpan(abs(diff)),
+                Span(start, end, reverse=reverse),
+            ]
+        elif max(start, end) > parent_length:
+            diff = max(start, end) - parent_length
+            start = min(start, parent_length)
+            end = min(end, parent_length)
+            spans += [
+                Span(start, end, reverse=reverse),
+                LostSpan(abs(diff)),
+            ]
+        else:
+            spans += [Span(start, end, reverse=reverse)]
+    return tuple(spans)
+
+
+T = Union[List[int], Tuple[int]]
+
+
+@dataclasses.dataclass
+class IndelMap(MapABC):
+    """store locations of deletions in a Aligned sequence"""
+
+    # todo design notes
+    #  I think this object should never try and store "history", i.e. it
+    #  should directly relate to the current sequence only. Let that sequence,
+    #  which is represented by a SeqView, store history.
+    #  Following ths, storing reverse is also a bad idea for this object,
+    #  also done by the SeqView only.
+    # TODO reverse complement of Alignment -> Aligned -> SeqView, IndelMap
+    #  should just do nucleic reverse. I think this is the next task.
+
+    spans: dataclasses.InitVar[Optional[tuple]] = ()
+    locations: dataclasses.InitVar[Optional[Sequence[T]]] = None
+    termini_unknown: dataclasses.InitVar[bool] = dataclasses.field(default=False)
+    parent_length: int = 0
+    start: Optional[int] = dataclasses.field(init=False, default=0)
+    end: Optional[int] = dataclasses.field(init=False, default=0)
+    length: int = dataclasses.field(init=False, default=0)
+    _serialisable: dict = dataclasses.field(init=False, repr=False)
+    _spans: Tuple[Union[Span, _LostSpan, TerminalPadding]] = dataclasses.field(
+        default=(), init=False
+    )
+
+    def __post_init__(self, spans, locations, termini_unknown):
+        if locations:
+            spans = _spans_from_locations(locations, parent_length=self.parent_length)
+        elif isinstance(spans, property):
+            # This clause is due a known issue with dataclasses.
+            # As we have a spans property, the default spans value is
+            # ignored, so we have to check for its value being a property
+            # and then set the default value here
+            spans = ()
+
+        spans = tuple(spans)
+        last_not_lost = None
+        start, end = None, None
+        for i, span in enumerate(spans):
+            self.length += len(span)
+            if span.lost:
+                continue
+            elif start is None:
+                # this ugly logic because we're using spans!
+                start = span.end if span.reverse else span.start
+
+            last_not_lost = i
+
+        if last_not_lost is not None:
+            span = spans[last_not_lost]
+            end = span.start if span.reverse else span.end
+
+        if start is None:
+            start = 0
+
+        if end is None:
+            end = self.parent_length
+
+        self.start, self.end = start, end
+
+        if termini_unknown:
+            spans = list(spans)
+            if spans[0].lost:
+                spans[0] = TerminalPadding(spans[0].length)
+            if spans[-1].lost:
+                spans[-1] = TerminalPadding(spans[-1].length)
+
+        self._spans = tuple(spans)
+
+    def __getitem__(self, slice):
+        # A possible shorter map at the same level
+        new_map = as_map(slice, len(self), self.__class__)
+        new_parts = []
+        for span in new_map.spans:
+            # we reset tidy start / end to false to avoid changes to
+            # default behaviour of tidy on IndelMap (which ignores these
+            # anyway)
+            old_tidy = span.tidy_start, span.tidy_end
+            span.tidy_start, span.tidy_end = False, False
+            new_parts.extend(span.remap_with(self))
+            span.tidy_start, span.tidy_end = old_tidy
+        return self.__class__(spans=new_parts, parent_length=self.parent_length)
+
+    def __len__(self):
+        return self.length
+
+    def __add__(self, other):
+        if other.parent_length != self.parent_length:
+            raise ValueError("Those maps belong to different sequences")
+        return self.__class__(
+            spans=self._spans + tuple(other.spans), parent_length=self.parent_length
+        )
+
+    def __mul__(self, scale):
+        # For Protein -> DNA
+        new_parts = []
+        for span in self._spans:
+            new_parts.append(span * scale)
+        return self.__class__(spans=new_parts, parent_length=self.parent_length * scale)
+
+    def __repr__(self):
+        return repr(self._spans) + f"/{self.parent_length}"
+
+    @property
+    def offsets(self):
+        return [0] + array([s.length for s in self._spans[:-1]]).cumsum().tolist()
+
+    def gaps(self):
+        """The gaps (lost spans) in this map"""
+        locations = []
+        offset = 0
+        for s in self._spans:
+            if s.lost:
+                locations.append((offset, offset + s.length))
+            offset += s.length
+        return self.__class__(locations=locations, parent_length=len(self))
+
+    def nongap(self):
+        """ungappeed segments in this map"""
+        locations = []
+        offset = 0
+        for s in self._spans:
+            if not s.lost:
+                locations.append((offset, offset + s.length))
+            offset += s.length
+        return self.__class__(locations=locations, parent_length=len(self))
+
+    @property
+    def spans(self) -> Iterator[Span]:
+        """generator of spans"""
+        yield from self._spans
+
+    @property
+    def complete(self):
+        """whether any span represents a gap"""
+        return not any(span.lost for span in self._spans)
+
+    @property
+    def useful(self):
+        return not all(span.lost for span in self._spans)
+
+    def get_coordinates(self):
+        """returns span coordinates as [(start, end), ...]"""
+        return [(s.start, s.end) for s in self._spans if not s.lost]
+
+    def get_gap_coordinates(self):
+        """returns [(gap pos, gap length), ...]"""
+        gap_pos = []
+        for i, span in enumerate(self._spans):
+            if not span.lost:
+                continue
+
+            pos = self._spans[i - 1].end if i else 0
+            gap_pos.append((pos, len(span)))
+
+        return gap_pos
+
+    def nucleic_reversed(self):
+        """Same location on reversed parent"""
+        spans = [s.reversed_relative_to(self.parent_length) for s in self._spans]
+        return self.__class__(spans=spans, parent_length=self.parent_length)
+
+    def strict_nucleic_reversed(self):
+        """map for a sequence that has itself been reversed and complemented
+
+        Notes
+        -----
+        discards reverse attribute on both spans and self
+        """
+        spans = []
+        parent_length = self.parent_length
+        for s in self._spans:
+            if not s.lost:
+                start = parent_length - s.end
+                assert start >= 0
+                end = start + s.length
+                s = Span(start=start, end=end)
+            spans.append(s)
+
+        spans.reverse()
+        return self.__class__(spans=spans, parent_length=self.parent_length)
+
+    def to_rich_dict(self):
+        """returns dicts for contained spans [dict(), ..]"""
+        spans = [s.to_rich_dict() for s in self._spans]
+        # exclude spans from deep copy since being overwritten
+        data = copy.deepcopy(
+            {k: v for k, v in self._serialisable.items() if k != "spans"}
+        )
+        data.pop("locations")
+        data["spans"] = spans
+        data["type"] = get_object_provenance(self)
+        data["version"] = __version__
+        return data
+
+    @classmethod
+    def from_rich_dict(cls, map_element):
+        from cogent3.util.deserialise import _get_class
+
+        map_element.pop("version", None)
+        type_ = map_element.pop("type")
+        assert _get_class(type_) == cls
+        spans = []
+        for element in map_element["spans"]:
+            element.pop("version", None)
+            klass = _get_class(element.pop("type"))
+            instance = klass(**element)
+            spans.append(instance)
+
+        map_element["spans"] = spans
+        return cls(**map_element)
+
+    def with_termini_unknown(self):
+        """returns new instance with terminal gaps indicated as unknown"""
+        return self.__class__(
+            spans=self._spans[:],
+            parent_length=self.parent_length,
+            termini_unknown=True,
+        )
+
+    def inverse(self):
+        """returns instance with coordinates updated for aligned, unaligned"""
+        # is this only required for parse_out_gaps?
+        # NO also used in cogent3.align code
+
+        # can't work if there are overlaps in the map
+        # tidy ends don't survive inversion
+        if self.parent_length is None:
+            raise ValueError("Uninvertable. parent length not known")
+
+        cum_posn = 0
+        temp = []
+        for span in self._spans:
+            if not span.lost:
+                if span.reverse:
+                    temp.append(
+                        (span.start, span.end, cum_posn + span.length, cum_posn)
+                    )
+                else:
+                    temp.append(
+                        (span.start, span.end, cum_posn, cum_posn + span.length)
+                    )
+            cum_posn += span.length
+
+        temp.sort()
+        new_spans = []
+        last_start = 0
+        for start, end, cum_start, cum_end in temp:
+            if start > last_start:
+                new_spans.append(LostSpan(start - last_start))
+            elif start < last_start:
+                raise ValueError(f"Uninvertable. Overlap: {start} < {last_start}")
+
+            # we force tidy_<start/end> to be same as self, attribute has no meaning
+            # for IndelMap, but retained for compatability for now
+            new_spans.append(
+                Span(
+                    cum_start,
+                    cum_end,
+                    reverse=cum_start > cum_end,
+                )
+            )
+            last_start = end
+
+        if self.parent_length > last_start:
+            new_spans.append(LostSpan(self.parent_length - last_start))
+
+        return self.__class__(spans=new_spans, parent_length=len(self))
+
+    T = Union[ndarray, int]
+
+    def absolute_position(self, rel_pos: T) -> T:
+        """converts rel_pos into an absolute position
+
+        Raises
+        ------
+        raises ValueError if rel_pos < 0
+        """
+        check = array([rel_pos], dtype=int) if isinstance(rel_pos, int) else rel_pos
+        if check.min() < 0:
+            raise ValueError(f"must positive, not {rel_pos=}")
+
+        if len(self) == self.parent_length:
+            # handle case of reversed here?
+            return rel_pos
+
+        return self.start + rel_pos
+
+    def relative_position(self, abs_pos: T) -> T:
+        """converts abs_pos into an relative position
+
+        Raises
+        ------
+        raises ValueError if abs_pos < 0
+        """
+        check = array([abs_pos], dtype=int) if isinstance(abs_pos, int) else abs_pos
+        if check.min() < 0:
+            raise ValueError(f"must positive, not {abs_pos=}")
+        return abs_pos - self.start
+
+    def get_covering_span(self):
+        span = (self.end, self.start)
+        return self.__class__(locations=[span], parent_length=self.parent_length)
+
+    def zeroed(self):
+        """returns a new instance with the first span starting at 0
+
+        Note
+        ----
+
+        Useful when an annotated sequence is sliced, but the connection to
+        the original parent is being deliberately broken as in the
+        Sequence.deepcopy(sliced=True) case.
+        """
+        # todo is this really required, best ifn we can rely on SeqView to
+        #  store all relationship to underlying sequence
+        min_val = min(self.start, self.end)
+        spans = []
+        for span in self._spans:
+            if span.lost:
+                spans.append(span)
+                continue
+            kwargs = span.to_rich_dict()
+            del kwargs["version"]
+            del kwargs["type"]
+            kwargs["start"] = kwargs["start"] - min_val
+            kwargs["end"] = kwargs["end"] - min_val
+            spans.append(Span(**kwargs))
+
+        kwargs = self.to_rich_dict()
+        del kwargs["version"]
+        del kwargs["type"]
+        kwargs["spans"] = spans
+        kwargs["parent_length"] = abs(self.start - self.end)
+        return self.__class__(**kwargs)
+
+    def to_feature_map(self):
+        """returns a Map type, suited to Features"""
+        spans = []
+        for span in self._spans:
+            if span.lost:
+                spans.append(span)
+                continue
+            kwargs = span.to_rich_dict()
+            del kwargs["version"]
+            del kwargs["type"]
+            spans.append(Span(**kwargs))
+
+        kwargs = self.to_rich_dict()
+        del kwargs["version"]
+        del kwargs["type"]
+        kwargs["spans"] = spans
+        return FeatureMap(**kwargs)
+
+
+class FeatureMap:
+    """A map holds a list of spans."""
+
+    def __init__(
+        self,
+        locations=None,
+        spans=None,
+        tidy=False,
+        parent_length=None,
+        termini_unknown=False,
+    ):
+        assert parent_length is not None
+        d = locals()
+        exclude = ("self", "__class__", "__slots__")
+        self._serialisable = {k: v for k, v in d.items() if k not in exclude}
+
+        if spans is None:
+            spans = []
+            for start, end in locations:
+                diff = 0
+                reverse = start > end
+                if max(start, end) < 0 or min(start, end) > parent_length:
+                    raise RuntimeError(
+                        f"located outside sequence: {(start, end, parent_length)}"
+                    )
+                if max(start, end) > parent_length and min(start, end) < 0:
+                    l_diff = min(start, end)
+                    r_diff = max(start, end) - parent_length
+                    start, end = (
+                        (0, parent_length) if start < end else (parent_length, 0)
+                    )
+                    spans += [
+                        LostSpan(abs(l_diff)),
+                        Span(start, end, tidy, tidy, reverse=reverse),
+                        LostSpan(abs(r_diff)),
+                    ]
+                elif min(start, end) < 0:
+                    diff = min(start, end)
+                    start = 0 if start < 0 else start
+                    end = 0 if end < 0 else end
+                    spans += [
+                        LostSpan(abs(diff)),
+                        Span(start, end, tidy, tidy, reverse=reverse),
+                    ]
+                elif max(start, end) > parent_length:
+                    diff = max(start, end) - parent_length
+                    start = parent_length if start > parent_length else start
+                    end = parent_length if end > parent_length else end
+                    spans += [
+                        Span(start, end, tidy, tidy, reverse=reverse),
+                        LostSpan(abs(diff)),
+                    ]
+                else:
+                    spans += [Span(start, end, tidy, tidy, reverse=reverse)]
+
+        self.offsets = []
+        self.useful = False
+        self.complete = True
+        self.reverse = None
+        posn = 0
+        for span in spans:
+            self.offsets.append(posn)
+            posn += span.length
+            if span.lost:
+                self.complete = False
+            elif not self.useful:
+                self.useful = True
+                (self.start, self.end) = (span.start, span.end)
+                self.reverse = span.reverse
+            else:
+                self.start = min(self.start, span.start)
+                self.end = max(self.end, span.end)
+                if self.reverse is not None and (span.reverse != self.reverse):
+                    self.reverse = None
+
+        if termini_unknown:
+            spans = list(spans)
+            if spans[0].lost:
+                spans[0] = TerminalPadding(spans[0].length)
+            if spans[-1].lost:
+                spans[-1] = TerminalPadding(spans[-1].length)
+
+        self.spans = tuple(spans)
+        self.length = posn
+        self.parent_length = parent_length
+
+    def __len__(self):
+        return self.length
+
+    def __repr__(self):
+        return repr(list(self.spans)) + f"/{self.parent_length}"
+
+    def __getitem__(self, new_map):
+        # A possible shorter map at the same level
+        new_map = as_map(new_map, len(self), self.__class__)
+        new_parts = []
+        for span in new_map.spans:
+            new_parts.extend(span.remap_with(self))
+        return self.__class__(spans=new_parts, parent_length=self.parent_length)
+
+    def __mul__(self, scale):
+        new_parts = [span * scale for span in self.spans]
+        return self.__class__(spans=new_parts, parent_length=self.parent_length * scale)
+
+    def __div__(self, scale):
+        new_parts = [span / scale for span in self.spans]
+        return self.__class__(
+            spans=new_parts, parent_length=self.parent_length // scale
+        )
+
+    def __add__(self, other):
+        if other.parent_length != self.parent_length:
+            raise ValueError("Those maps belong to different sequences")
+        return self.__class__(
+            spans=self.spans + other.spans, parent_length=self.parent_length
+        )
+
+    def _with_termini_unknown(self):
+        return self.__class__(
+            self,
+            spans=self.spans[:],
+            parent_length=self.parent_length,
+            termini_unknown=True,
+        )
+
+    def get_covering_span(self):
+        if self.reverse:
+            span = (self.end, self.start)
+        else:
+            span = (self.start, self.end)
+        return self.__class__([span], parent_length=self.parent_length)
+
+    def covered(self):
+        """>>> Map([(10,20), (15, 25), (80, 90)]).covered().spans
+        [Span(10,25), Span(80, 90)]"""
+
+        delta = {}
+        for span in self.spans:
+            if span.lost:
+                continue
+            delta[span.start] = delta.get(span.start, 0) + 1
+            delta[span.end] = delta.get(span.end, 0) - 1
+        positions = sorted(delta.keys())
+        last_y = y = 0
+        last_x = start = None
+        result = []
+        for x in positions:
+            y += delta[x]
+            if x == last_x:
+                continue
+            if y and not last_y:
+                assert start is None
+                start = x
+            elif last_y and not y:
+                result.append((start, x))
+                start = None
+            last_x = x
+            last_y = y
+        assert y == 0
+        return self.__class__(locations=result, parent_length=self.parent_length)
+
+    def reversed(self):
+        """Reversed location on same parent"""
+        spans = [s.reversed() for s in self.spans]
+        spans.reverse()
+        return self.__class__(spans=spans, parent_length=self.parent_length)
+
+    def nucleic_reversed(self):
+        """Same location on reversed parent"""
+        spans = [s.reversed_relative_to(self.parent_length) for s in self.spans]
+        return self.__class__(spans=spans, parent_length=self.parent_length)
+
+    def get_gap_coordinates(self):
+        """returns [(gap pos, gap length), ...]"""
+        gap_pos = []
+        for i, span in enumerate(self.spans):
+            if not span.lost:
+                continue
+
+            pos = self.spans[i - 1].end if i else 0
+            gap_pos.append((pos, len(span)))
+
+        return gap_pos
+
+    def gaps(self):
+        """The gaps (lost spans) in this map"""
+        locations = []
+        offset = 0
+        for s in self.spans:
+            if s.lost:
+                locations.append((offset, offset + s.length))
+            offset += s.length
+        return self.__class__(locations, parent_length=len(self))
+
+    def shadow(self):
+        """The 'negative' map of the spans not included in this map"""
+        return self.inverse().gaps()
+
+    def nongap(self):
+        locations = []
+        offset = 0
+        for s in self.spans:
+            if not s.lost:
+                locations.append((offset, offset + s.length))
+            offset += s.length
+        return self.__class__(locations, parent_length=len(self))
+
+    def without_gaps(self):
+        return self.__class__(
+            spans=[s for s in self.spans if not s.lost],
+            parent_length=self.parent_length,
+        )
+
+    def inverse(self):
+        """returns instance with coordinates updated for aligned, unaligned"""
+        # is this only required for parse_out_gaps?
+        # NO also used in cogent3.align code
+
+        # can't work if there are overlaps in the map
+        # tidy ends don't survive inversion
+        if self.parent_length is None:
+            raise ValueError("Uninvertable. parent length not known")
+
+        cum_posn = 0
+        temp = []
+        for span in self.spans:
+            if not span.lost:
+                if span.reverse:
+                    temp.append(
+                        (span.start, span.end, cum_posn + span.length, cum_posn)
+                    )
+                else:
+                    temp.append(
+                        (span.start, span.end, cum_posn, cum_posn + span.length)
+                    )
+            cum_posn += span.length
+
+        temp.sort()
+        new_spans = []
+        last_start = 0
+        for start, end, cum_start, cum_end in temp:
+            if start > last_start:
+                new_spans.append(LostSpan(start - last_start))
+            elif start < last_start:
+                raise ValueError(f"Uninvertable. Overlap: {start} < {last_start}")
+
+            # we force tidy_<start/end> to be same as self, attribute has no meaning
+            # for IndelMap, but retained for compatability for now
+            new_spans.append(
+                Span(
+                    cum_start,
+                    cum_end,
+                    reverse=cum_start > cum_end,
+                )
+            )
+            last_start = end
+
+        if self.parent_length > last_start:
+            new_spans.append(LostSpan(self.parent_length - last_start))
+
+        return self.__class__(spans=new_spans, parent_length=len(self))
+
+    def _inverse(self):
+        # can't work if there are overlaps in the map
+        # tidy ends don't survive inversion
+        if self.parent_length is None:
+            raise ValueError("Uninvertable. parent length not known")
+        posn = 0
+        temp = []
+        for span in self.spans:
+            if not span.lost:
+                if span.reverse:
+                    temp.append((span.start, span.end, posn + span.length, posn))
+                else:
+                    temp.append((span.start, span.end, posn, posn + span.length))
+            posn += span.length
+
+        temp.sort()
+        new_spans = []
+        last_hi = 0
+        for lo, hi, start, end in temp:
+            if lo > last_hi:
+                new_spans.append(LostSpan(lo - last_hi))
+            elif lo < last_hi:
+                raise ValueError(f"Uninvertable. Overlap: {lo} < {last_hi}")
+            new_spans.append(Span(start, end, reverse=start > end))
+            last_hi = hi
+        if self.parent_length > last_hi:
+            new_spans.append(LostSpan(self.parent_length - last_hi))
+        return self.__class__(spans=new_spans, parent_length=len(self))
+
+    def get_coordinates(self):
+        """returns span coordinates as [(v1, v2), ...]
+
+        v1/v2 are (start, end) unless the map is reversed, in which case it will
+        be (end, start)"""
+
+        if self.reverse:
+            order_func = lambda x: (max(x), min(x))
+        else:
+            order_func = lambda x: x
+
+        coords = list(
+            map(order_func, [(s.start, s.end) for s in self.spans if not s.lost])
+        )
+
+        return coords
+
+    def to_rich_dict(self):
+        """returns dicts for contained spans [dict(), ..]"""
+        spans = [s.to_rich_dict() for s in self.spans]
+        data = copy.deepcopy(self._serialisable)
+        data.pop("locations")
+        data["spans"] = spans
+        data["type"] = get_object_provenance(self)
+        data["version"] = __version__
+        return data
+
+    def zeroed(self):
+        """returns a new instance with the first span starting at 0
+
+        Note
+        ----
+
+        Useful when an Annotatable object is sliced, but the connection to
+        the original parent is being deliberately broken as in the
+        Sequence.deepcopy(sliced=True) case.
+        """
+        # todo there's probably a more efficient way to do this
+        # create the new instance
+        from cogent3.util.deserialise import deserialise_map_spans
+
+        data = self.to_rich_dict()
+        zeroed = deserialise_map_spans(data)
+        zeroed.parent_length = len(zeroed.get_covering_span())
+        shift = min(zeroed.start, zeroed.end)
+        new_end = 0
+        for span in zeroed.spans:
+            if span.lost:
+                continue
+            span.start -= shift
+            span.end -= shift
+            new_end = max(new_end, span.end)
+
+        zeroed.start = 0
+        zeroed.end = new_end
+
+        return zeroed
+
+    T = Union[ndarray, int]
+
+    def absolute_position(self, rel_pos: T) -> T:
+        """converts rel_pos into an absolute position
+
+        Raises
+        ------
+        raises ValueError if rel_pos < 0
+        """
+        check = array([rel_pos], dtype=int) if isinstance(rel_pos, int) else rel_pos
+        if check.min() < 0:
+            raise ValueError(f"must positive, not {rel_pos=}")
+
+        if len(self) == self.parent_length:
+            # handle case of reversed here?
+            return rel_pos
+
+        return self.start + rel_pos
+
+    def relative_position(self, abs_pos: T) -> T:
+        """converts abs_pos into an relative position
+
+        Raises
+        ------
+        raises ValueError if abs_pos < 0
+        """
+        check = array([abs_pos], dtype=int) if isinstance(abs_pos, int) else abs_pos
+        if check.min() < 0:
+            raise ValueError(f"must positive, not {abs_pos=}")
+        return abs_pos - self.start
+
+
+def gap_coords_to_map(gaps_lengths: dict, seq_length: int) -> FeatureMap:
     """
     Parameters
     ----------
@@ -1114,7 +1943,7 @@ def gap_coords_to_map(gaps_lengths: dict, seq_length: int) -> Map:
     """
 
     if not gaps_lengths:
-        return Map([(0, seq_length)], parent_length=seq_length)
+        return FeatureMap([(0, seq_length)], parent_length=seq_length)
 
     spans = []
     last = pos = 0
@@ -1131,4 +1960,4 @@ def gap_coords_to_map(gaps_lengths: dict, seq_length: int) -> Map:
     if pos < seq_length:
         spans.append(Span(last, seq_length))
 
-    return Map(spans=spans, parent_length=seq_length)
+    return FeatureMap(spans=spans, parent_length=seq_length)
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index 14279b9dd..0a5c6459b 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -56,7 +56,7 @@ from cogent3.core.annotation_db import (
 )
 from cogent3.core.genetic_code import get_code
 from cogent3.core.info import Info as InfoClass
-from cogent3.core.location import LostSpan, Map
+from cogent3.core.location import FeatureMap, IndelMap, LostSpan
 from cogent3.format.fasta import alignment_to_fasta
 from cogent3.maths.stats.contingency import CategoryCounts
 from cogent3.maths.stats.number import CategoryCounter
@@ -1059,16 +1059,16 @@ class Sequence(SequenceI):
                 continue
             new_spans.append(new.tolist())
 
-        fmap = Map(locations=new_spans, parent_length=len(self))
+        fmap = FeatureMap(locations=new_spans, parent_length=len(self))
         if pre or post:
             # create a lost span to represent the segment missing from
             # the instance
-            spans = fmap.spans
+            spans = list(fmap.spans)
             if pre:
                 spans.insert(0, LostSpan(pre))
             if post:
                 spans.append(LostSpan(post))
-            fmap = Map(spans=spans, parent_length=len(self))
+            fmap = FeatureMap(spans=spans, parent_length=len(self))
 
         if revd and not seq_rced:
             # the sequence is on the plus strand, and the
@@ -1268,7 +1268,7 @@ class Sequence(SequenceI):
                 seqid=self.name,
                 name=None,
                 biotype=None,
-                map=Map(locations=[], parent_length=len(self)),
+                map=FeatureMap(locations=[], parent_length=len(self)),
                 strand="+",
             )
         else:
@@ -1344,7 +1344,7 @@ class Sequence(SequenceI):
         if hasattr(index, "map"):
             index = index.map
 
-        if isinstance(index, Map):
+        if isinstance(index, (FeatureMap, IndelMap)):
             new = self._mapped(index)
             preserve_offset = not index.reverse
 
@@ -1484,7 +1484,7 @@ class Sequence(SequenceI):
         for match in nongap.finditer(str(self)):
             segments.append(match.span())
             gapless.append(match.group())
-        map = Map(segments, parent_length=len(self)).inverse()
+        map = IndelMap(locations=segments, parent_length=len(self)).inverse()
         seq = self.__class__(
             "".join(gapless), name=self.get_name(), info=self.info, preserve_case=True
         )
diff --git a/src/cogent3/parse/cigar.py b/src/cogent3/parse/cigar.py
index 77051e554..6c6dc28f6 100644
--- a/src/cogent3/parse/cigar.py
+++ b/src/cogent3/parse/cigar.py
@@ -18,14 +18,14 @@ the aligned sequence will be:
 import re
 
 from cogent3 import DNA, make_aligned_seqs
-from cogent3.core.location import LostSpan, Map, Span
+from cogent3.core.location import IndelMap, LostSpan, Span
 
 
 _pattern = re.compile("([0-9]*)([DM])")
 
 
 def map_to_cigar(map):
-    """convert a Map into a cigar string"""
+    """convert a IndelMap into a cigar string"""
     cigar = ""
     for span in map.spans:
         if isinstance(span, Span):
@@ -39,7 +39,7 @@ def map_to_cigar(map):
 
 
 def cigar_to_map(cigar_text):
-    """convert cigar string into Map"""
+    """convert cigar string into IndelMap"""
     assert "I" not in cigar_text
     spans, posn = [], 0
     for n, c in _pattern.findall(cigar_text):
@@ -49,7 +49,7 @@ def cigar_to_map(cigar_text):
             posn += n
         else:
             spans.append(LostSpan(n))
-    return Map(spans=spans, parent_length=posn)
+    return IndelMap(spans=spans, parent_length=posn)
 
 
 def aligned_from_cigar(cigar_text, seq, moltype=DNA):
@@ -88,7 +88,7 @@ def _remap(map):
                 span.end = span.end - start
                 length = span.end
             spans.append(span)
-        new_map = Map(spans=spans, parent_length=length)
+        new_map = IndelMap(spans=spans, parent_length=length)
     return new_map
 
 
diff --git a/src/cogent3/parse/gbseq.py b/src/cogent3/parse/gbseq.py
index cf0b501f5..bc2712b70 100644
--- a/src/cogent3/parse/gbseq.py
+++ b/src/cogent3/parse/gbseq.py
@@ -5,7 +5,7 @@ DOCTYPE Bioseq-set PUBLIC "-//NCBI//NCBI Seqset/EN" "http://www.ncbi.nlm.nih.gov
 import io
 import xml.dom.minidom
 
-from cogent3.core import annotation, moltype
+from cogent3.core import location, moltype
 
 
 """
@@ -64,7 +64,7 @@ def GbSeqXmlParser(doc):
 
         seq = alphabet.make_seq(raw_string, name=name)
 
-        all = annotation.Map([(0, len(seq))], parent_length=len(seq))
+        all = location.FeatureMap(locations=[(0, len(seq))], parent_length=len(seq))
         seq.add_feature(
             biotype="source", name=name, spans=all.get_coordinates(), strand=all.reverse
         )
@@ -107,7 +107,7 @@ def GbSeqXmlParser(doc):
                         .nodeValue
                     )
                     spans.append((point - 1, point))
-            if spans == []:
+            if not spans:
                 spans = [(0, len(seq))]
             for qualifier in feature.getElementsByTagName("GBQualifier"):
                 qname = (


Test patch:
diff --git a/tests/test_core/test_alignment.py b/tests/test_core/test_alignment.py
index 5dc949871..dd2be94b8 100644
--- a/tests/test_core/test_alignment.py
+++ b/tests/test_core/test_alignment.py
@@ -2827,7 +2827,7 @@ def test_aligned_rich_dict(reverse):
 
     rd = seq.to_rich_dict()
     got = Aligned.from_rich_dict(rd)
-    assert str(seq) == str(got)
+    assert str(got) == str(seq)
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
@@ -3498,9 +3498,6 @@ def test_sliced_deepcopy(data, name, rev):
     if rev:
         aln = aln.rc()
 
-    # the map just mirrors the slice effect, it no longer has "memory" of rev complement
-    assert aln.named_seqs[name].map.reverse == False
-
     notsliced = aln.deepcopy(sliced=False)
     # the annotation offsets should match original object
     assert {s.data.annotation_offset for s in notsliced.seqs} == {
@@ -3525,10 +3522,6 @@ def test_sliced_deepcopy(data, name, rev):
         sliced.named_seqs[name].data._seq.seq is not orig.named_seqs[name].data._seq.seq
     )
 
-    # the map just mirrors the slice effect, it has no  "memory" of rev complement
-    assert notsliced.named_seqs[name].map.reverse == False
-    assert sliced.named_seqs[name].map.reverse == False
-
     assert sliced.named_seqs[name].map.parent_length == len(
         str(sliced_seq).replace("-", "")
     )
diff --git a/tests/test_core/test_annotation.py b/tests/test_core/test_annotation.py
index e8f384cb8..bddd156ff 100644
--- a/tests/test_core/test_annotation.py
+++ b/tests/test_core/test_annotation.py
@@ -4,7 +4,7 @@ import pytest
 
 from cogent3 import DNA, load_seq, make_aligned_seqs, make_unaligned_seqs
 from cogent3.core.alignment import Alignment, SequenceCollection
-from cogent3.core.location import Map, Span
+from cogent3.core.location import FeatureMap, Span
 
 
 def makeSampleSequence(name, with_gaps=False):
@@ -95,10 +95,10 @@ class TestMapSpans(unittest.TestCase):
         """reversing a map with multiple spans should preserve span relative
         order"""
         forward = [Span(20, 30), Span(40, 50)]
-        fmap = Map(spans=forward, parent_length=100)
+        fmap = FeatureMap(spans=forward, parent_length=100)
         fmap_reversed = fmap.nucleic_reversed()
         reverse = [Span(70, 80, reverse=True), Span(50, 60, reverse=True)]
-        rmap = Map(spans=reverse, parent_length=100)
+        rmap = FeatureMap(spans=reverse, parent_length=100)
         for i in range(2):
             self.assertEqual(fmap_reversed.spans[i], rmap.spans[i])
 
diff --git a/tests/test_core/test_annotation_db.py b/tests/test_core/test_annotation_db.py
index 28e45da99..ac081d1a0 100644
--- a/tests/test_core/test_annotation_db.py
+++ b/tests/test_core/test_annotation_db.py
@@ -406,7 +406,7 @@ def test_feature_nucleic():
     from cogent3.core import location as loc
 
     seq = make_seq("AACCTTTGGGGAATTT", moltype="dna")
-    mmap = loc.Map(locations=[(4, 7), (11, 13)], parent_length=16)
+    mmap = loc.FeatureMap(locations=[(4, 7), (11, 13)], parent_length=16)
     expect = seq[mmap.reversed()]
 
     rcseq = seq.rc()
diff --git a/tests/test_core/test_location.py b/tests/test_core/test_location.py
index 6c940da11..fc415a4c1 100644
--- a/tests/test_core/test_location.py
+++ b/tests/test_core/test_location.py
@@ -2,8 +2,17 @@
 """
 from unittest import TestCase
 
+import pytest
+
 from cogent3 import DNA
-from cogent3.core.location import Map, Span, gap_coords_to_map
+from cogent3.core.location import (
+    FeatureMap,
+    IndelMap,
+    LostSpan,
+    Span,
+    TerminalPadding,
+    gap_coords_to_map,
+)
 
 
 class SpanTests(TestCase):
@@ -257,13 +266,13 @@ class MapTests(TestCase):
     def test_get_coords(self):
         """get_coordinates should return raw coordinates matching input"""
         spans = [(0, 9), (20, 32)]
-        map = Map(spans, parent_length=100)
+        map = FeatureMap(spans, parent_length=100)
         coords = map.get_coordinates()
         self.assertEqual(coords, spans)
 
         # should work for reversed Maps too
         spans = [(32, 20), (9, 0)]
-        map = Map(spans, parent_length=100)
+        map = FeatureMap(spans, parent_length=100)
         coords = map.get_coordinates()
         self.assertEqual(coords, spans)
 
@@ -300,14 +309,15 @@ class MapTests(TestCase):
             got = gap_coords_to_map({20: 1}, len(seq))
 
 
-def test_map_plus_position():
+@pytest.mark.parametrize("cls", (IndelMap, FeatureMap))
+def test_map_plus_position(cls):
     # seq is 9 long
     # plus coords  012345678
     # +slice         **
     # plus seq     AAACCCTGG
 
     # orig = Aligned(*DNA.make_seq("AAACCCTGG", name="a").parse_out_gaps())
-    orig = Map([(0, 9)], parent_length=9)
+    orig = cls(locations=[(0, 9)], parent_length=9)
     assert orig.absolute_position(2) == 2
     assert orig.absolute_position(6) == 6
 
@@ -329,3 +339,262 @@ def test_map_plus_position():
     rc = orig.nucleic_reversed()
     rcsubseq = rc[2:7]
     abs_coord = rcsubseq.absolute_position(0)
+
+
+def test_indel_map_useful_complete():
+    im = IndelMap(spans=[LostSpan(3)], parent_length=0)
+    assert not im.useful
+    assert not im.complete
+    assert len(im) == im.length == 3
+
+
+@pytest.mark.parametrize("cls,expect", ((FeatureMap, [(9, 0)]), (IndelMap, [(0, 9)])))
+def test_map_nucleic_reversed(cls, expect):
+    # seq is 9 long
+    # plus coords  012345678
+    # +slice         **
+    # plus seq     AAACCCTGG
+
+    # orig = Aligned(*DNA.make_seq("AAACCCTGG", name="a").parse_out_gaps())
+    orig = cls(locations=[(0, 9)], parent_length=9)
+    # minus coords 012345678
+    # rel coord      01234
+    # -slice         *****
+    # minus seq    CCAGGGTTT
+    # plus coords  876543210
+    rc = orig.nucleic_reversed()
+    coords = rc.get_coordinates()
+    assert coords == expect
+
+
+@pytest.mark.parametrize("cls", (FeatureMap, IndelMap))
+def test_coordinate(cls):
+    # coordinates are for ungapped segments in underlying sequence
+    #                   01   2 345
+    seq = DNA.make_seq("AC---G-TAA--")
+    m, _ = seq.parse_out_gaps()
+    m = cls(spans=tuple(m.spans), parent_length=m.parent_length)
+    got = m.get_coordinates()
+    assert got == [(0, 2), (2, 3), (3, 6)]
+
+
+@pytest.mark.parametrize("cls", (FeatureMap, IndelMap))
+def test_gap_coordinate(cls):
+    seq = DNA.make_seq("AC---G-TAA--")
+    m, _ = seq.parse_out_gaps()
+    m = cls(spans=tuple(m.spans), parent_length=m.parent_length)
+    got = m.get_gap_coordinates()
+    assert got == [(2, 3), (3, 1), (6, 2)]
+
+
+@pytest.mark.parametrize("cls", (FeatureMap, IndelMap))
+def test_gaps(cls):
+    # returns spans corresponding to position on "aligned" seq of gaps
+    #                   000000000011
+    #                   012345678901
+    seq = DNA.make_seq("AC---G-TAA--")
+    m, s = seq.parse_out_gaps()
+    m = cls(spans=tuple(m.spans), parent_length=m.parent_length)
+    got = [(g.start, g.end) for g in tuple(m.gaps().spans)]
+    assert got == [(2, 5), (6, 7), (10, 12)]
+
+
+@pytest.mark.parametrize("cls", (IndelMap, FeatureMap))
+def test_nongap(cls):
+    # returns spans corresponding to position on "aligned" seq of nongaps
+    #                   000000000011
+    #                   012345678901
+    seq = DNA.make_seq("AC---G-TAA--")
+    m, _ = seq.parse_out_gaps()
+    m = cls(spans=tuple(m.spans), parent_length=m.parent_length)
+
+    got = [(g.start, g.end) for g in m.nongap().spans]
+    assert got == [(0, 2), (5, 6), (7, 10)]
+
+
+def test_reversed():
+    seq = DNA.make_seq("AC---G-TAA--")
+    m, _ = seq.parse_out_gaps()
+    m = FeatureMap(spans=m.spans, parent_length=m.parent_length)
+    # reversed() reverses the order of spans, but keeps their coordinates
+    # differs from nucleic reversed, which computes a new relative position
+    rev = m.reversed()
+    got = [s.length if s.lost else (s.start, s.end) for s in rev.spans]
+    expect = [s.length if s.lost else (s.start, s.end) for s in m.spans]
+    expect.reverse()
+    assert got == expect
+
+
+def test_round_trip_rich_dict():
+    seq = DNA.make_seq("AC---G-TAA--")
+    m, s = seq.parse_out_gaps()
+    # reversed() reverses the order of spans, but keeps their coordinates
+    # differs from nucleic reversed, which computes a new relative position
+    im = IndelMap(spans=m.spans, parent_length=m.parent_length)
+    rd = im.to_rich_dict()
+    got = IndelMap.from_rich_dict(rd)
+    assert im is not got
+    assert got.to_rich_dict() == im.to_rich_dict()
+
+
+def test_serialisable_attr():
+    im = IndelMap(locations=[(0, 2)], parent_length=20)
+    set_vals = {"locations": [(0, 2)], "parent_length": 20}
+    got = {k: im._serialisable[k] for k in set_vals}
+    assert got == set_vals
+
+
+def test_terminal_unknown():
+    # span idx          01 2  345  6
+    seq = DNA.make_seq("-AC---G-TAA--")
+    m, _ = seq.parse_out_gaps()
+    # not unknown, by default
+    m_spans = tuple(m.spans)
+    assert m_spans[0].lost and not isinstance(m_spans[0], TerminalPadding)
+    # use the constructor arg
+    m = IndelMap(
+        spans=tuple(m.spans), parent_length=m.parent_length, termini_unknown=True
+    )
+    m_spans = tuple(m.spans)
+    assert isinstance(m_spans[0], TerminalPadding)
+    assert isinstance(m_spans[-1], TerminalPadding)
+    assert m_spans[2].lost and not isinstance(m_spans[1], TerminalPadding)
+    assert m_spans[4].lost and not isinstance(m_spans[2], TerminalPadding)
+
+    # use the method
+    m, _ = seq.parse_out_gaps()
+    m = IndelMap(
+        spans=tuple(m.spans), parent_length=m.parent_length
+    ).with_termini_unknown()
+    m_spans = tuple(m.spans)
+    assert isinstance(m_spans[0], TerminalPadding)
+    assert isinstance(m_spans[-1], TerminalPadding)
+    # middle gap is not terminal, so...
+    assert not isinstance(m_spans[2], TerminalPadding)
+
+    # no gaps, no effect
+    seq = DNA.make_seq("ACGTAA")
+    m, _ = seq.parse_out_gaps()
+    # use the constructor arg
+    m = IndelMap(
+        spans=tuple(m.spans), parent_length=m.parent_length, termini_unknown=True
+    )
+    m_spans = tuple(m.spans)
+    assert not isinstance(m_spans[0], TerminalPadding)
+    # use the method
+    m = IndelMap(
+        spans=tuple(m.spans), parent_length=m.parent_length
+    ).with_termini_unknown()
+    m_spans = tuple(m.spans)
+    assert not isinstance(m_spans[0], TerminalPadding)
+
+
+@pytest.mark.parametrize("cls", (FeatureMap, IndelMap))
+def test_map_inverse(cls):
+    m = cls(locations=[(0, 2), (4, 6)], parent_length=6)
+    assert len(m) == 4
+    mi = m.inverse()
+    assert len(mi) == 6
+    mi_spans = tuple(mi.spans)
+    assert mi_spans[1].lost and len(mi_spans[1]) == 2
+    # invert the inversion, should give us back the original
+    re_inv = mi.inverse()
+    expect = m.to_rich_dict()
+    got = re_inv.to_rich_dict()
+    assert got == expect
+
+
+@pytest.mark.parametrize("cls", (FeatureMap, IndelMap))
+def test_map_offsets(cls):
+    # offsets are absolute starts of spans
+    #                              1
+    #                   01 3  678  1
+    seq = DNA.make_seq("-AC---G-TAA--")
+    m, s = seq.parse_out_gaps()
+    got = m.offsets
+    assert got == [0, 1, 3, 6, 7, 8, 11]
+
+
+@pytest.mark.parametrize("cls", (FeatureMap, IndelMap))
+def test_map_indexed(cls):
+    m = cls(locations=[(0, 2), (4, 6)], parent_length=6).inverse()
+    indexed = m[2]
+    assert len(indexed) == 1
+
+
+def test_compare_map_indexed():
+    from cogent3.core.alignment import Aligned
+
+    seq = DNA.make_seq("--AC-GTAA--".replace("-", ""))
+    spans = [LostSpan(2), Span(0, 2), LostSpan(2), Span(2, 6), LostSpan(2)]
+    kwargs = dict(spans=spans, parent_length=len(seq))
+    mm = FeatureMap(**kwargs)
+    im = IndelMap(**kwargs)
+    ma = Aligned(mm, seq)
+    ia = Aligned(im, seq)
+    first = ia[0]
+    assert first == "-"
+    length = len(ma)
+    got = [str(ia[i]) for i in range(length)]
+    expect = [str(ma[i]) for i in range(length)]
+    assert got == expect
+
+
+@pytest.mark.parametrize("slice_it", (True, False))
+def test_indel_map_zeroed(slice_it):
+    spans = [LostSpan(2), Span(2, 4), LostSpan(2), Span(4, 8), LostSpan(2)]
+    kwargs = dict(spans=spans, parent_length=6)
+
+    mm = FeatureMap(**kwargs)
+    if slice_it:
+        mm = mm[:6]
+    mm_zeroed = mm.zeroed()
+
+    im = IndelMap(**kwargs)
+    if slice_it:
+        im = im[:6]
+
+    im_zeroed = im.zeroed()
+    assert im_zeroed.get_coordinates() == mm_zeroed.get_coordinates()
+    assert im_zeroed.parent_length == mm_zeroed.parent_length
+
+
+def test_indelmap_to_feature_map():
+    spans = [LostSpan(2), Span(2, 4), LostSpan(2), Span(4, 8), LostSpan(2)]
+    kwargs = dict(spans=spans, parent_length=6)
+    im = IndelMap(**kwargs)
+    mm = im.to_feature_map()
+    assert mm.get_coordinates() == im.get_coordinates()
+
+
+def test_indelmap_strict_nucleic_reversed():
+    spans = [LostSpan(2), Span(2, 4), LostSpan(2), Span(4, 8), LostSpan(2)]
+    kwargs = dict(spans=spans, parent_length=12)
+    orig = IndelMap(**kwargs)
+    rev = orig.strict_nucleic_reversed()
+    rev_spans = tuple(rev.spans)
+    assert rev_spans[1].reverse == rev_spans[3].reverse == False
+    old = orig.nucleic_reversed()
+    old_spans = tuple(old.spans)
+    assert old_spans[1].reverse == old_spans[3].reverse == True
+    assert rev.get_coordinates() == [
+        tuple(sorted(a)) for a in reversed(old.get_coordinates())
+    ]
+
+
+def test_indelmap_with_reverse_span():
+    spans = [
+        LostSpan(2),
+        Span(8, 4, reverse=True),
+        LostSpan(2),
+        Span(4, 2, reverse=True),
+        LostSpan(2),
+    ]
+    imap = IndelMap(spans=spans, parent_length=12)
+    assert (imap.start, imap.end) == (8, 2)
+
+
+def test_indelmap_no_gaps():
+    imap = IndelMap(locations=(), parent_length=6)
+    gaps = imap.gaps()
+    assert not gaps
diff --git a/tests/test_core/test_maps.py b/tests/test_core/test_maps.py
index 6ccb21b31..275876598 100644
--- a/tests/test_core/test_maps.py
+++ b/tests/test_core/test_maps.py
@@ -1,7 +1,7 @@
 import unittest
 
 from cogent3 import DNA, make_aligned_seqs
-from cogent3.core.location import Map, Span
+from cogent3.core.location import FeatureMap, Span
 
 
 class MapTest(unittest.TestCase):
@@ -9,7 +9,7 @@ class MapTest(unittest.TestCase):
 
     def test_spans(self):
         # a simple two part map of length 10
-        map = Map([(0, 5), (5, 10)], parent_length=10)
+        map = FeatureMap([(0, 5), (5, 10)], parent_length=10)
         # try different spans on the above map
         for (start, end), expected in [
             ((0, 4), "[0:4]"),