Repository: biopython/biopython

Issue:
Flipping origin-spanning locations with strand=None in circular molecules
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

```python
import sys; print(sys.version)
import platform; print(platform.python_implementation()); print(platform.platform())
import Bio; print(Bio.__version__)
```

```
3.11.6 (main, Nov  2 2023, 04:51:19) [Clang 14.0.0 (clang-1400.0.29.202)]
CPython
macOS-12.6.7-x86_64-i386-64bit
1.81
```
In Biopython and GenBank files, locations that span the origin are represented by a compound location. For example, for a sequence of length 6 a feature that spans two residues before the origin and the first residue after the origin is represented as `join{[4:6](+), [0:1](+)}`. When we flip that feature, it becomes `join{[0:2](-), [5:6](-)}`. The order of the loc.parts and the strand is inverted, so the meaning of the flipping is clear.

However, when a feature without strand spans the origin in the same way (`join{[4:6], [0:1]}`) and gets flipped, the parts get inverted (`join{[0:2], [5:6]}`). It might be confusing to interpret that this is a feature that spans the origin.

The same applies to multi-part features, see example below:

```python
from Bio.SeqFeature import SimpleLocation, SeqFeature
from Bio.SeqRecord import SeqRecord
from Bio.Seq import Seq
from Bio.SeqIO import write
loc = SimpleLocation(4, 6, 1) + SimpleLocation(0, 1, 1)
print(loc) # join{[4:6](+), [0:1](+)}
print(loc._flip(6)) # join{[0:2](-), [5:6](-)}

loc = SimpleLocation(4, 6, None) + SimpleLocation(0, 1, None)
print(loc) # join{[4:6], [0:1]}
print(loc._flip(6)) # join{[0:2], [5:6]}


# Example of a multi-part location that spans the origin of a sequence with length 20
loc = SimpleLocation(15, 16, 1) + SimpleLocation(18, 20, 1) + SimpleLocation(0, 1, 1) + SimpleLocation(5, 10, 1)
print(loc) # join{[15:16](+), [18:20](+), [0:1](+), [5:10](+)}
print(loc._flip(20)) # join{[4:5](-), [0:2](-), [19:20](-), [10:15](-)}
seqr = SeqRecord(Seq('A'*20), features=[SeqFeature(loc, type='misc_feature')], annotations={'molecule_type': 'DNA'})
write([seqr], 'example_strand1_fwd.gb', 'genbank')
seqr_rvs = seqr.reverse_complement()
seqr_rvs.annotations['molecule_type'] = 'DNA'
write([seqr_rvs], 'example_strand1_rvs.gb', 'genbank')

loc = SimpleLocation(15, 16, None) + SimpleLocation(18, 20, None) + SimpleLocation(0, 1, None) + SimpleLocation(5, 10, None)
print(loc) # join{[15:16], [18:20], [0:1], [5:10]}
print(loc._flip(20)) # join{[4:5], [0:2], [19:20], [10:15]}
seqr = SeqRecord(Seq('A'*20), features=[SeqFeature(loc, type='misc_feature')], annotations={'molecule_type': 'DNA'})
seqr_rvs = seqr.reverse_complement()
seqr_rvs.annotations['molecule_type'] = 'DNA'
write([seqr], 'example_strandNone_fwd.gb', 'genbank')
write([seqr_rvs], 'example_strandNone_rvs.gb', 'genbank')

```
In the example, I save the sequences with multi-part features to gb files. As you can see, snapgene interprets properly all cases, except the inverted feature without strand. You can access [gb files here](https://github.com/biopython/biopython/files/14111421/files.zip).

![Screenshot 2024-01-31 at 12 06 38](https://github.com/biopython/biopython/assets/22526102/9bc1e8e6-c3ec-4875-a33b-80de70f5e28d)

If you agree that this is something that should be changed, I can give it a go and propose a solution.








Flipping origin-spanning locations with strand=None in circular molecules
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

```python
import sys; print(sys.version)
import platform; print(platform.python_implementation()); print(platform.platform())
import Bio; print(Bio.__version__)
```

```
3.11.6 (main, Nov  2 2023, 04:51:19) [Clang 14.0.0 (clang-1400.0.29.202)]
CPython
macOS-12.6.7-x86_64-i386-64bit
1.81
```
In Biopython and GenBank files, locations that span the origin are represented by a compound location. For example, for a sequence of length 6 a feature that spans two residues before the origin and the first residue after the origin is represented as `join{[4:6](+), [0:1](+)}`. When we flip that feature, it becomes `join{[0:2](-), [5:6](-)}`. The order of the loc.parts and the strand is inverted, so the meaning of the flipping is clear.

However, when a feature without strand spans the origin in the same way (`join{[4:6], [0:1]}`) and gets flipped, the parts get inverted (`join{[0:2], [5:6]}`). It might be confusing to interpret that this is a feature that spans the origin.

The same applies to multi-part features, see example below:

```python
from Bio.SeqFeature import SimpleLocation, SeqFeature
from Bio.SeqRecord import SeqRecord
from Bio.Seq import Seq
from Bio.SeqIO import write
loc = SimpleLocation(4, 6, 1) + SimpleLocation(0, 1, 1)
print(loc) # join{[4:6](+), [0:1](+)}
print(loc._flip(6)) # join{[0:2](-), [5:6](-)}

loc = SimpleLocation(4, 6, None) + SimpleLocation(0, 1, None)
print(loc) # join{[4:6], [0:1]}
print(loc._flip(6)) # join{[0:2], [5:6]}


# Example of a multi-part location that spans the origin of a sequence with length 20
loc = SimpleLocation(15, 16, 1) + SimpleLocation(18, 20, 1) + SimpleLocation(0, 1, 1) + SimpleLocation(5, 10, 1)
print(loc) # join{[15:16](+), [18:20](+), [0:1](+), [5:10](+)}
print(loc._flip(20)) # join{[4:5](-), [0:2](-), [19:20](-), [10:15](-)}
seqr = SeqRecord(Seq('A'*20), features=[SeqFeature(loc, type='misc_feature')], annotations={'molecule_type': 'DNA'})
write([seqr], 'example_strand1_fwd.gb', 'genbank')
seqr_rvs = seqr.reverse_complement()
seqr_rvs.annotations['molecule_type'] = 'DNA'
write([seqr_rvs], 'example_strand1_rvs.gb', 'genbank')

loc = SimpleLocation(15, 16, None) + SimpleLocation(18, 20, None) + SimpleLocation(0, 1, None) + SimpleLocation(5, 10, None)
print(loc) # join{[15:16], [18:20], [0:1], [5:10]}
print(loc._flip(20)) # join{[4:5], [0:2], [19:20], [10:15]}
seqr = SeqRecord(Seq('A'*20), features=[SeqFeature(loc, type='misc_feature')], annotations={'molecule_type': 'DNA'})
seqr_rvs = seqr.reverse_complement()
seqr_rvs.annotations['molecule_type'] = 'DNA'
write([seqr], 'example_strandNone_fwd.gb', 'genbank')
write([seqr_rvs], 'example_strandNone_rvs.gb', 'genbank')

```
In the example, I save the sequences with multi-part features to gb files. As you can see, snapgene interprets properly all cases, except the inverted feature without strand. You can access [gb files here](https://github.com/biopython/biopython/files/14111421/files.zip).

![Screenshot 2024-01-31 at 12 06 38](https://github.com/biopython/biopython/assets/22526102/9bc1e8e6-c3ec-4875-a33b-80de70f5e28d)

If you agree that this is something that should be changed, I can give it a go and propose a solution.










Discussion:
This is handled by the private ``._flip`` method of the ``CompoundLocation`` class:

https://github.com/biopython/biopython/blob/biopython-183/Bio/SeqFeature.py#L1641

As per the motivating example documented there, the behaviour assumes the original feature's part order is meaningful (which is true for a gene or CDS record, but perhaps not true for your misc_feature?) and therefore deliberately preserves the part order.
> assumes the original feature's part order is meaningful and therefore deliberately preserves the part order

Hi @peterjc, I agree with this when the strand is indicated, but not sure if that should be the case for features without a strand if we consider the origin-spanning case. For instance, say we wanted to indicate a recombination hotspot, or something that does not have an orientation. If it would fall on the origin, then the example could be the same as above:

```python
loc = SimpleLocation(4, 6, None) + SimpleLocation(0, 1, None)
print(loc) # join{[4:6], [0:1]}
print(loc._flip(6)) # join{[0:2], [5:6]}
```

The most important consequence (I think) is that the second feature will not be interpreted correctly by SnapGene or other sequence viewers. The solution for this would be to invert the parts when flipping a feature with `strand = None`. In a way this makes some sense because when Biopython writes features to GenBank, it treats `strand=None` features as plus-strand features, so to be plus-like in the reverse-complemented Seqrecord, the elements should be inverted.

Perhaps the multi-part origin-spanning example without strand is not very meaningful, I struggle to imagine a use-case for it.

I don't feel very strongly about this, but the sequence viewer case makes me prefer the alternative solution. 


So your suggestion would be reverse the parts order if all the parts have no strand? That seems OK to me - if any have a strand then the orientation and order is in someway meaningful.

[And yes, GenBank/EMBL locations sadly do not have an explicit way to say no strand]
Happy to contribute a pull request if you don't think this will be disruptive / cause problems
I think this proposal is narrow enough not to cause many problems - and should fix your use case. I'd be happy to review a pull request on this.
This is handled by the private ``._flip`` method of the ``CompoundLocation`` class:

https://github.com/biopython/biopython/blob/biopython-183/Bio/SeqFeature.py#L1641

As per the motivating example documented there, the behaviour assumes the original feature's part order is meaningful (which is true for a gene or CDS record, but perhaps not true for your misc_feature?) and therefore deliberately preserves the part order.
> assumes the original feature's part order is meaningful and therefore deliberately preserves the part order

Hi @peterjc, I agree with this when the strand is indicated, but not sure if that should be the case for features without a strand if we consider the origin-spanning case. For instance, say we wanted to indicate a recombination hotspot, or something that does not have an orientation. If it would fall on the origin, then the example could be the same as above:

```python
loc = SimpleLocation(4, 6, None) + SimpleLocation(0, 1, None)
print(loc) # join{[4:6], [0:1]}
print(loc._flip(6)) # join{[0:2], [5:6]}
```

The most important consequence (I think) is that the second feature will not be interpreted correctly by SnapGene or other sequence viewers. The solution for this would be to invert the parts when flipping a feature with `strand = None`. In a way this makes some sense because when Biopython writes features to GenBank, it treats `strand=None` features as plus-strand features, so to be plus-like in the reverse-complemented Seqrecord, the elements should be inverted.

Perhaps the multi-part origin-spanning example without strand is not very meaningful, I struggle to imagine a use-case for it.

I don't feel very strongly about this, but the sequence viewer case makes me prefer the alternative solution. 


So your suggestion would be reverse the parts order if all the parts have no strand? That seems OK to me - if any have a strand then the orientation and order is in someway meaningful.

[And yes, GenBank/EMBL locations sadly do not have an explicit way to say no strand]
Happy to contribute a pull request if you don't think this will be disruptive / cause problems
I think this proposal is narrow enough not to cause many problems - and should fix your use case. I'd be happy to review a pull request on this.

Resolving patch:
diff --git a/Bio/SeqFeature.py b/Bio/SeqFeature.py
--- a/Bio/SeqFeature.py
+++ b/Bio/SeqFeature.py
@@ -1641,11 +1641,13 @@ def _shift(self, offset):
     def _flip(self, length):
         """Return a copy of the locations after the parent is reversed (PRIVATE).
 
-        Note that the order of the parts is NOT reversed too. Consider a CDS
-        on the forward strand with exons small, medium and large (in length).
-        Once we change the frame of reference to the reverse complement strand,
-        the start codon is still part of the small exon, and the stop codon
-        still part of the large exon - so the part order remains the same!
+        Note that the order of the parts is NOT reversed unless all parts
+        have strand=None, since the order has meaning for stranded features.
+        Consider a CDS on the forward strand with exons small, medium
+        and large (in length). Once we change the frame of reference
+        to the reverse complement strand, the start codon is still part of
+        the small exon, and the stop codon still part of the large exon -
+        so the part order remains the same!
 
         Here is an artificial example, were the features map to the two upper
         case regions and the lower case runs of n are not used:
@@ -1709,10 +1711,27 @@ def _flip(self, length):
         versions of Biopython which would have given join{[5:29](-), [37:52](-)}
         and the translation would have wrongly been "EXAMPLE*SILLY" instead.
 
+        When all the parts have strand None, the order of the parts is reversed.
+        In principle this does not change the meaning of the location, but improves
+        the representation of feature locations spanning the origin in circular
+        molecules.
+
+        >>> loc = SimpleLocation(4, 6, None) + SimpleLocation(0, 1, None)
+        >>> print(loc)
+        join{[4:6], [0:1]}
+        >>> print(loc._flip(6))
+        join{[5:6], [0:2]}
+
+        join{[0:2], [5:6]} would not be properly represented in SnapGene, Benchling, etc.
         """
-        return CompoundLocation(
-            [loc._flip(length) for loc in self.parts], self.operator
-        )
+        if all(loc.strand is None for loc in self.parts):
+            return CompoundLocation(
+                [loc._flip(length) for loc in self.parts[::-1]], self.operator
+            )
+        else:
+            return CompoundLocation(
+                [loc._flip(length) for loc in self.parts], self.operator
+            )
 
     @property
     def start(self):


Test patch:
diff --git a/Tests/test_SeqIO_features.py b/Tests/test_SeqIO_features.py
--- a/Tests/test_SeqIO_features.py
+++ b/Tests/test_SeqIO_features.py
@@ -590,6 +590,18 @@ def test_exact(self):
             self.assertEqual(_get_location_string(f._flip(200), 200), "101..200")
             self.assertEqual(f._flip(100).location.strand, f.location.strand)
 
+        # Test for compound locations (see https://github.com/biopython/biopython/issues/4611)
+
+        # flip with +1/-1 strands does not invert the order in compound locations
+        loc = SimpleLocation(4, 6, 1) + SimpleLocation(0, 1, 1)
+        self.assertEqual(str(loc._flip(6)), "join{[0:2](-), [5:6](-)}")
+        loc = SimpleLocation(4, 6, -1) + SimpleLocation(0, 1, -1)
+        self.assertEqual(str(loc._flip(6)), "join{[0:2](+), [5:6](+)}")
+
+        # flip with None strand inverts the order in compound locations
+        loc = SimpleLocation(4, 6, None) + SimpleLocation(0, 1, None)
+        self.assertEqual(str(loc._flip(6)), "join{[5:6], [0:2]}")
+
     def test_between(self):
         """GenBank/EMBL write/read simple between locations."""
         # Note we don't use the BetweenPosition any more!