Repository: cogent3/cogent3
Pull request #1388 (2023-04-25 04:47:33)

added relative index method


Patch:
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index 8fe91a9ff..f3cba0efa 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -1153,6 +1153,7 @@ class Sequence(_Annotatable, SequenceI):
         # todo gah check this omission
         # if hasattr(index, "get_slice"):
         #     return index.get_slice()
+        # todo: kath preserve the offset?
 
         if isinstance(index, Map):
             new = self._mapped(index)
@@ -1610,14 +1611,71 @@ class SeqView:
     def reversed(self):
         return self.step < 0
 
-    def absolute_index(self, value):
-        # note: this is the positive/positive case...
-        # todo gah handle reversed
-        if self.start + value > self.stop:
-            raise IndexError("Index out of bounds")
+    def absolute_index(self, value: int):
+        """Converts an index relative to the current view to be with respect to the coordinates of the sequence's annotations
 
-        offset = self.start if self.offset is None else self.offset + self.start
-        return offset + (value * self.step)
+        Parameters
+        ----------
+        value   int
+                index relative to the current View, supports +ve or -ve indexing
+
+        Returns
+        -------
+        (positive) index relative to the coordinates of the sequence's annotations
+
+        """
+
+        index, _, _ = self._get_index(value)
+        offset = 0 if self.offset is None else self.offset
+        if self.reversed:
+            # todo: kath, does not account for a step > 1
+            if self.start - abs(value) < self.stop:
+                raise IndexError("Index out of bounds")
+
+            abs_value = offset + len(self.seq) + index
+        else:
+            # todo: kath, does not account for a step > 1
+            if self.start + value > self.stop:
+                raise IndexError("Index out of bounds")
+
+            abs_value = offset + index
+
+        return abs_value
+
+    def relative_index(self, value):
+        """converts an index relative to annotation coordinates to be with respect to the current sequence view"""
+
+        if value < 0:
+            raise IndexError("Index must be +ve and relative to the + strand")
+
+        if self.reversed:
+            offset = 0 if self.offset is None else self.offset
+            if value < offset + len(self.seq) - self.stop * self.step:
+                raise IndexError(
+                    "Index not within current view, cannot convert to relative index"
+                )
+            if (
+                rel_value := ((len(self.seq) - (value - offset)) + self.start)
+            ) % self.step != 0:
+                # todo: should this error be more explicit that it is due to not aligning with the step
+                raise IndexError(
+                    "Index not within current view, cannot convert to relative index"
+                )
+            rel_value = abs(rel_value / self.step)
+
+        else:
+            offset = self.start if self.offset is None else self.offset + self.start
+            if value < offset:
+                raise IndexError(
+                    "Index not within current view, cannot convert to relative index"
+                )
+            if (rel_value := (value - offset)) % self.step != 0:
+                raise IndexError(
+                    "Index not within current view, cannot convert to relative index"
+                )
+            rel_value = rel_value / self.step
+
+        return int(rel_value)
 
     def _get_index(self, val):
         if len(self) == 0:
@@ -1634,7 +1692,7 @@ class SeqView:
             else:
                 val = self.start + len(self) * self.step + val * abs(self.step)
 
-            return self.__class__(self.seq, start=val, stop=val + 1)
+            return val, val + 1, 1
 
         elif self.step < 0:
             if val > 0 and val >= len(self):
@@ -1647,7 +1705,7 @@ class SeqView:
             else:
                 val = self.start + len(self) * self.step + val * self.step
 
-            return self.__class__(self.seq, start=val, stop=val - 1, step=-1)
+            return val, val - 1, -1
 
     def _get_slice(self, segment, step):
         slice_start = segment.start if segment.start is not None else 0
@@ -1813,7 +1871,9 @@ class SeqView:
 
     def __getitem__(self, segment):
         if isinstance(segment, int):
-            return self._get_index(segment)
+            start, stop, step = self._get_index(segment)
+            return self.__class__(self.seq, start=start, stop=stop, step=step)
+
         if len(self) == 0:
             return self
 


Test patch:
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index 2918f68ab..b07f5b226 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -1,5 +1,7 @@
 """Unit tests for Sequence class and its subclasses.
 """
+
+import contextlib
 import json
 import os
 import pathlib
@@ -1939,7 +1941,7 @@ def test_seqview_repr():
     assert repr(view) == expected
 
 
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="function")
 def one_seq():
     from cogent3 import make_seq
 
@@ -2002,3 +2004,142 @@ def test_annotation_from_slice_with_stride():
     raise NotImplementedError(
         "check annotations get correct sequence when there's a stride"
     )
+
+
+def test_absolute_index_base_cases(one_seq):
+    """with no offset or view, the absolute index should remain unchanged"""
+    got = one_seq._seq.absolute_index(5)
+    assert got == 5
+
+    # an index outside the range of the sequence should raise an IndexError
+    with pytest.raises(IndexError):
+        one_seq._seq.absolute_index(20)
+
+    with pytest.raises(IndexError):
+        one_seq._seq.absolute_index(-20)
+
+
+def test_absolute_index_positive(one_seq):
+    # with an offset, the abs index should be offset + index
+    one_seq.annotation_offset = 2
+    got = one_seq._seq.absolute_index(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
+    got = view._seq.absolute_index(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
+    got = view._seq.absolute_index(2)
+    assert got == 2 + 2 + 2 * 2
+
+
+def test_absolute_index_negative(one_seq):
+    # with an offset, the abs index should be offset + length -index
+    one_seq.annotation_offset = 2
+    got = one_seq._seq.absolute_index(-2)
+    assert got == 2 + len(one_seq) - 2
+
+    # with an offset and negative index, the abs index should be offset + abs(index)
+    view = one_seq[::-1]
+    view.annotation_offset = 2
+    got = view._seq.absolute_index(-2)
+    assert got == 3  # note an index of 3 is the 4th position
+
+    # a start position should not change the above example because -ve indexing is relative to the end
+    view = one_seq[-2::-1]
+    view.annotation_offset = 2
+    got = view._seq.absolute_index(-2)
+    assert got == 3  # note an index of 3 is the 4th position
+
+    # a stop position
+    view = one_seq[:1:-1]
+    view.annotation_offset = 2
+    got = view._seq.absolute_index(-2)
+    assert got == 5  # note an index of 5 is the 6th position
+
+
+def test_relative_index_base_cases(one_seq):
+    """with no offset or view, the absolute index should remain unchanged"""
+    got = one_seq._seq.relative_index(5)
+    assert got == 5
+
+    # a -ve index  should raise an IndexError
+    with pytest.raises(IndexError):
+        one_seq._seq.relative_index(-5)
+
+
+@pytest.mark.parametrize("value", (0, 3))
+@pytest.mark.parametrize("offset", (None, 1, 2))
+@pytest.mark.parametrize("start", (None, 1, 2))
+@pytest.mark.parametrize("stop", (None, 10, 11))
+@pytest.mark.parametrize("step", (None, 1, 2))
+def test_absolute_relative_roundtrip(one_seq, value, offset, start, stop, step):
+    # a round trip from relative to absolute then from absolute to relative, should return the same value we began with
+    view = one_seq[start:stop:step]
+    view.annotation_offset = offset
+    abs_val = view._seq.absolute_index(value)
+    rel_val = view._seq.relative_index(abs_val)
+    assert rel_val == value
+
+
+@pytest.mark.parametrize("value", (0, 2, 3))
+@pytest.mark.parametrize("offset", (None, 1, 2))
+@pytest.mark.parametrize("start", (0, 1, 2))
+@pytest.mark.parametrize("stop", (9, 10, 11))
+@pytest.mark.parametrize("step", (1, 2))
+def test_relative_absolute_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
+    with contextlib.suppress(IndexError):
+        rel_val = view._seq.relative_index(value)
+        abs_val = view._seq.absolute_index(rel_val)
+        assert abs_val == value
+
+
+@pytest.fixture(scope="session")
+def integer_seq():
+    return SeqView("0123456789")
+
+
+@pytest.mark.parametrize("value", (0, 2, -2))
+@pytest.mark.parametrize("offset", (None, 1, 2))
+@pytest.mark.parametrize("start", (None, -1, -2))
+@pytest.mark.parametrize(
+    "stop",
+    (None, -10),
+)
+@pytest.mark.parametrize("step", (-1, -2))
+def test_absolute_relative_roundtrip_reverse(
+    integer_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 = integer_seq[start:stop:step]
+    view.offset = offset
+    abs_val = view.absolute_index(value)
+    with contextlib.suppress(IndexError):
+        rel_val = view.relative_index(abs_val)
+        assert view.offset == offset
+        assert (view[rel_val]).value == view[value].value
+
+
+@pytest.mark.parametrize("value", (0, 2, 3))
+@pytest.mark.parametrize("offset", (None, 1, 2))
+@pytest.mark.parametrize("start", (None, -1, -2))
+@pytest.mark.parametrize("stop", (None, -10, -11))
+@pytest.mark.parametrize("step", (-1, 2))
+def test_relative_absolute_roundtrip_reverse(
+    integer_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 = integer_seq[start:stop:step]
+    view.offset = offset
+    with contextlib.suppress(IndexError):
+        rel_val = view.relative_index(value)
+        abs_val = view.absolute_index(rel_val)
+        assert abs_val == value