Repository: biopython/biopython

Issue:
Parsing Exonerate text output with SearchIO recovers incorrect hit ranges when frameshifts are present
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:


```
Python 3.8.3 (default, May 19 2020, 13:54:14)
CPython
macOS-10.15.7-x86_64-i386-64bit
BioPython 1.78
```

### Expected behaviour

When parsing the output of an Exonerate search performed using the model protein2genome, SearchIO.parse(<exonerate_output_file>, 'exonerate-text') should correctly recover hit ranges when frameshifts are present in the target nucleotide sequence.


### Actual behaviour

HSP target sequence ranges are incorrect (and can be negative values).

### Steps to reproduce

Using the attached Exonerate output file [exonerate_results.fasta.zip](https://github.com/biopython/biopython/files/7406707/exonerate_results.fasta.zip) the SearchIO command is:

```exonerate_hits_from_alignment = list(SearchIO.parse('exonerate_results.fasta', 'exonerate-text'))```

```
for qresult in exonerate_hits_from_alignment:
    for hsp in qresult.hsps:
        print(hsp)
```

Output:

```
      Query: Morus-gene026 
        Hit: NODE_2_length_1708_cov_48.590765 
Query range: [69:441] (0)
  Hit range: [-754:331] (-1)
Quick stats: evalue ?; bitscore ?
  Fragments: ---  --------------  ----------------------  ----------------------
               #            Span             Query range               Hit range
             ---  --------------  ----------------------  ----------------------
               0            1005                [69:402]              [-635:331]
               1             117               [402:441]             [-754:-637]
```

It looks like this is caused by a typo in the source code when setting the hit postion coordinate [here](https://github.com/biopython/biopython/blob/1b3b186aeecee9b5406f580184df65863d3ed1b5/Bio/SearchIO/ExonerateIO/_base.py#L185); I think ```hpos = min(frag.hit_range) if qstep >= 0 else max(frag.hit_range)``` should be ```hpos = min(frag.hit_range) if hstep >= 0 else max(frag.hit_range)```


Cheers,

Chris


Discussion:
That looks very plausible. Hopefully @bow can take a look soon.

In the meantime, I suggest you submit that one letter change (``qstep`` to ``hstep``) as a pull request, and we'll see if anything breaks. As small test case would be nice to add too.

Resolving patch:
diff --git a/Bio/SearchIO/ExonerateIO/_base.py b/Bio/SearchIO/ExonerateIO/_base.py
--- a/Bio/SearchIO/ExonerateIO/_base.py
+++ b/Bio/SearchIO/ExonerateIO/_base.py
@@ -182,7 +182,7 @@ def _split_fragment(frag):
     qstep = 1 if frag.query_strand >= 0 else -1
     hstep = 1 if frag.hit_strand >= 0 else -1
     qpos = min(frag.query_range) if qstep >= 0 else max(frag.query_range)
-    hpos = min(frag.hit_range) if qstep >= 0 else max(frag.hit_range)
+    hpos = min(frag.hit_range) if hstep >= 0 else max(frag.hit_range)
     abs_pos = 0
     # split according to hit, then query
     while simil:


Test patch:
diff --git a/Tests/Exonerate/README.txt b/Tests/Exonerate/README.txt
--- a/Tests/Exonerate/README.txt
+++ b/Tests/Exonerate/README.txt
@@ -5,6 +5,9 @@ This directory contains various data files for testing the
 Exonerate-related code in Biopython. All files are grouped by
 Exonerate release version, from the most recent first.
 
+Exonerate 2.4
+-------------
+exn_24_m_protein2genome_revcomp_fshifts.exn
 
 Exonerate 2.2
 -------------
diff --git a/Tests/Exonerate/exn_24_m_protein2genome_revcomp_fshifts.exn b/Tests/Exonerate/exn_24_m_protein2genome_revcomp_fshifts.exn
new file mode 100644
--- /dev/null
+++ b/Tests/Exonerate/exn_24_m_protein2genome_revcomp_fshifts.exn
@@ -0,0 +1,108 @@
+Command line: [exonerate -m protein2genome gene026_baits.fasta gene026_contigs.fasta --showcigar no --showvulgar no --bestn 2 --refine full]
+Hostname: [RBGs-MacBook-Air.local]
+
+C4 Alignment:
+------------
+         Query: Morus-gene026
+        Target: NODE_2_length_1708_cov_48.590765 SPAdes NODE_2 contig:[revcomp]
+         Model: protein2genome:local
+     Raw score: 1308
+   Query range: 69 -> 441
+  Target range: 1416 -> 331
+
+   70 : ProGluSerProTrpThrCysSerProLeuGlnThr<-><->ProSerProSerLeuLeuTy :   88
+        |||||||||||||||||| !!|||||||||||||||      ! !|||||||||||||||||
+        ProGluSerProTrpThrSerSerProLeuGlnThrLeuSerHisSerProSerLeuLeuTy
+ 1416 : CCAGAGTCGCCATGGACCAGCTCACCCCTCCAGACCCTTTCCCATTCCCCTTCCCTCCTCTA : 1356
+
+   89 : rHisCysIleAlaSerLeuHisArgHisAspGlyThrIleHisSerIleAlaValSerAsnG :  109
+        |||||||||||||||||||!.!||||||||||||||||||:!!|||||||||..!|||||||
+        rHisCysIleAlaSerLeuArgArgHisAspGlyThrIleTyrSerIleAlaThrSerAsnG
+ 1355 : CCACTGCATCGCCTCGCTCCGCCGTCACGATGGCACTATCTACTCCATCGCCACCTCAAACG : 1293
+
+  110 : lyValValPheThrGlySerAspSerArgArgIleArgAlaTrpArgGlnProAspCysThr :  129
+        ||||||||||||||||||||!  |||||||||:!!||||||||||||||| !!||||||!:!
+        lyValValPheThrGlySerGlySerArgArgValArgAlaTrpArgGlnAlaAspCysSer
+ 1292 : GCGTCGTCTTTACTGGCTCCGGGAGCCGCCGCGTCCGGGCATGGCGTCAAGCTGATTGCAGC : 1233
+
+  130 : GluArgGlyLeuLeuLysAlaLysPheGlyGluValArgAlaIleLeuAlaHisGlyAspTh :  150
+        |||!.!||||||||||||||||||! !|||..!||||||||||||||||||! !||||||||
+        GluHisGlyLeuLeuLysAlaLysCysGlyArgValArgAlaIleLeuAlaLeuGlyAspTh
+ 1232 : GAGCATGGGCTTCTCAAAGCCAAGTGTGGCCGGGTCAGAGCAATCTTAGCCCTTGGAGACAC : 1170
+
+  151 : rLeuPheThrCysHisLysAspAsnLysIleArgValTrpAsnPheAsnAsnPheAlaSerP :  171
+        ||||||||||! !|||!!.|||||||||||||||!.!||||||                !!!
+        rLeuPheThrSerHisAsnAspAsnLysIleArgAlaTrpAsn---------------ArgT
+ 1169 : TCTCTTCACGTCCCACAACGACAACAAAATCCGAGCCTGGAAT---------------CGTT : 1122
+
+  172 : heLysLysValCysSerPheProArgThrSerSerLeuSerPheSerThrLeuPheSerArg :  191
+        :!:!!|||! !||||||.!!|||||||||:!!|||!!!! !!  ! !:!!||||||||||||
+        yrGlnLysGlyCysSerIleProArgThrAlaSerPheMetSerPheSerLeuPheSerArg
+ 1121 : ATCAAAAAGGTTGTTCCATTCCGAGAACGGCCTCGTTCATGTCGTTTTCTTTGTTCTCGAGA : 1062
+
+  192 : AlaLysAsnAspAlaLysArgAsnHisLysGluCysValSerCysLeuAlaTyrTyrHisAl :  212
+        ||||||:!!|||   :!!|||.!.|||||||||||||||||||||:!!||||||||||||||
+        AlaLysHisAsp---GluArgGlnHisLysGluCysValSerCysMetAlaTyrTyrHisAl
+ 1061 : GCCAAGCACGAT---GAACGACAGCACAAGGAGTGCGTTTCATGCATGGCGTATTACCACGC : 1002
+
+  213 : aGluGlyIleLeuTyrThrGlySerHisAspArgThrValLysAlaTrpArgValSerSerG :  233
+        |||||||||||||||||||!.!! !:!!:!!|||||||||:!!!.!|||||||||||||||!
+        aGluGlyIleLeuTyrThrAlaTyrTyrAsnArgThrValGlnValTrpArgValSerSerG
+ 1001 : AGAAGGGATTCTATACACGGCCTATTACAATAGGACGGTCCAAGTTTGGCGAGTGTCGAGTG :  939
+
+  234 : lyLysCysValAspSerPheLeuAlaHisGluAspLysValAsnAspIleLeuValAsnGln :  253
+         !|||||||||||||||||||||||||||||||||! !||||||!  |||||||||||| !!
+        luLysCysValAspSerPheLeuAlaHisGluAspIleValAsnAlaIleLeuValAsn***
+  938 : AAAAATGCGTTGACTCGTTCTTGGCTCACGAGGACATAGTCAATGCGATACTGGTCAACTAA :  879
+
+  254 : GluAspGlyCysValPheThrCysSerSerAspGlySerValLysIleTrpArgArgIleTy :  274
+        ! !:!!|||  !|||||||||||||||! !|||||||||||||||||||||!:!|||||| !
+        GlyAsnGlyAspValPheThrCysSerLeuAspGlySerValLysIleTrpLysArgIleAs
+  878 : GGGAACGGCGATGTGTTTACTTGCTCATTGGACGGCTCTGTAAAGATCTGGAAAAGGATCAA :  816
+
+  275 : rArgAspAsnSerTyrThrLeuThrMetIleLeuArgPheGlnProSerProValAsnAlaI :  295
+        !|||!!:||||||  !||||||||||||! !||||||! !||||||||||||||||||.!!|
+        nArgGluAsnSerArgThrLeuThrMetThrLeuArgCysGlnProSerProValAsnThrI
+  815 : TAGAGAGAACTCCCGCACCCTAACGATGACACTCAGATGCCAACCTTCTCCTGTGAACACCA :  753
+
+  296 : leAlaLeuSerLeuSerSerSerSerSerSerSerThrSerSerSerSerThrSerSerPro :  315
+        ||||||||         ||||||  !|||||||||:!!||||||||||||:!!|||||| !!
+        leAlaLeu---------SerSerValSerSerSerSerSerSerSerSerSerSerSerSer
+  752 : TAGCTTTA---------AGCTCAGTGTCCTCATCTTCTTCCTCTTCCTCTTCGTCTTCTTCG :  702
+
+  316 : PheThrPheGlySerGlyPheLeuTyrSerGlySerSerAspGlyThrIleAsnPheTrpGl :  336
+        |||||||||||||||  !|||||||||||||||!!!|||||||||||||||!:!||||||||
+        PheThrPheGlySerThrPheLeuTyrSerGlyCysSerAspGlyThrIleSerPheTrpGl
+  701 : TTTACTTTTGGTTCTACATTTCTCTACTCTGGCTGTTCAGACGGGACAATAAGCTTCTGGGA :  639
+
+  337 : uArgGluArgThrSerTyrArgPheAsnHisGlyGlyPheLeuGlnGlyHisArgPheSerV :  357
+        |||||||||||||||||||||||||||||||||||||!!.|||! !||||||||||||||||
+        uArgGluArgThrSerTyrArgPheAsnHisGlyGlyLeuLeuLeuGlyHisArgPheSerV
+  638 : AAGAGAGAGAACTTCTTACAGGTTCAACCATGGAGGGTTGTTACTAGGCCATAGATTCTCCG :  576
+
+  358 : alLeuCysLeuValAlaValGluLysMetValPheSerGlySerGluAspThrThrIleArg :  377
+        |||||||||||:!!|||||||||||||||||||||.!!||||||||||||||||||||||||
+        alLeuCysLeuIleAlaValGluLysMetValPheGlyGlySerGluAspThrThrIleArg
+  575 : TGCTTTGCCTCATTGCGGTGGAGAAGATGGTGTTCGGCGGTTCGGAAGATACAACGATTCGG :  516
+
+  378 : IleTrpArgArgGluGluGlySerCysLeuHisGluCysLeuAlaValLeuAspGlyHisAr :  398
+        |||||||||||||||||||||.!!|||!!!|||:!!||||||||||||||||||||||||||
+        IleTrpArgArgGluGluGlyGlyCysPheHisLysCysLeuAlaValLeuAspGlyHisAr
+  515 : ATATGGAGGAGAGAGGAAGGAGGATGTTTTCACAAGTGTCTGGCCGTGTTGGACGGGCACAG :  453
+
+  399 : gGlyProValLys--CysLeuAlaAlaCysLeuGluValGluLysValValMetMetGlyPh :  418
+        |            ##|||||||||||||||! !:!!|||||||||:!!||||||||||||||
+        g------------##CysLeuAlaAlaCys***GlnValGluLysMetValMetMetGlyPh
+  452 : A------------GGTGCTTAGCGGCGTGTTAGCAGGTCGAGAAAATGGTGATGATGGGGTT :  403
+
+  419 : eLeuValTyrSerAlaSerLeuAspGlnThrPheLysValTrpArgValLysValLeuProA :  439
+        ||||:!!||||||!.!|||||||||||||||!!.||||||||||||||||||:!!|||||||
+        eLeuIleTyrSerValSerLeuAspGlnThrLeuLysValTrpArgValLysIleLeuProA
+  402 : TCTGATTTACAGTGTGAGCTTGGACCAGACTTTGAAGGTCTGGAGAGTCAAAATCTTGCCTG :  340
+
+  440 : spGluGlu :  441
+        ||:!!|||
+        spGlnGlu
+  339 : ATCAAGAA :  332
+
+-- completed exonerate analysis
diff --git a/Tests/test_SearchIO_exonerate.py b/Tests/test_SearchIO_exonerate.py
--- a/Tests/test_SearchIO_exonerate.py
+++ b/Tests/test_SearchIO_exonerate.py
@@ -2266,6 +2266,63 @@ def test_exn_22_m_protein2genome(self):
         self.assertEqual(2, len(hsp.hit_all))
         self.assertEqual(2, len(hsp.aln_annotation_all))
 
+    def test_exn_24_m_protein2genome_revcomp_fshifts(self):
+        """Test parsing exonerate output (exn_24_m_protein2genome_revcomp_fshifts.exn)."""
+        exn_file = get_file("exn_24_m_protein2genome_revcomp_fshifts.exn")
+        qresult = read(exn_file, self.fmt)
+
+        # check common attributes
+        for hit in qresult:
+            self.assertEqual(qresult.id, hit.query_id)
+            for hsp in hit:
+                self.assertEqual(hit.id, hsp.hit_id)
+                self.assertEqual(qresult.id, hsp.query_id)
+
+        self.assertEqual("Morus-gene026", qresult.id)
+        self.assertEqual("", qresult.description)
+        self.assertEqual("exonerate", qresult.program)
+        self.assertEqual("protein2genome:local", qresult.model)
+        self.assertEqual(1, len(qresult))
+        # first (only) hit
+        hit = qresult[0]
+        self.assertEqual("NODE_2_length_1708_cov_48.590765", hit.id)
+        self.assertEqual("SPAdes NODE_2 contig", hit.description)
+        self.assertEqual(1, len(hit))
+        # first hit, first hsp
+        hsp = qresult[0][0]
+        self.assertEqual(1308, hsp.score)
+        self.assertEqual([0, 0], hsp.query_strand_all)
+        self.assertEqual([-1, -1], hsp.hit_strand_all)
+        self.assertEqual(69, hsp.query_start)
+        self.assertEqual(331, hsp.hit_start)
+        self.assertEqual(441, hsp.query_end)
+        self.assertEqual(1416, hsp.hit_end)
+        self.assertEqual([(69, 402), (402, 441)], hsp.query_range_all)
+        self.assertEqual([(450, 1416), (331, 448)], hsp.hit_range_all)
+        self.assertEqual([(402, 402)], hsp.query_inter_ranges)
+        self.assertEqual([(448, 450)], hsp.hit_inter_ranges)
+        self.assertEqual([0, 0], hsp.query_frame_all)
+        self.assertEqual([-1, -2], hsp.hit_frame_all)
+        self.assertEqual(2, len(hsp.query_all))
+        self.assertEqual(2, len(hsp.hit_all))
+        self.assertEqual(2, len(hsp.aln_annotation_all))
+        # first block
+        self.assertEqual(
+            "PESPWTCSPLQT--PSPSLLYHCIASLHRHDGTIHSIAVS", hsp[0].query.seq[:40]
+        )
+        self.assertEqual(
+            "PESPWTSSPLQTLSHSPSLLYHCIASLRRHDGTIYSIATS", hsp[0].hit.seq[:40]
+        )
+        self.assertEqual(
+            "VEKMVFSGSEDTTIRIWRREEGSCLHECLAVLDGHRGPVK", hsp[0].query.seq[-40:]
+        )
+        self.assertEqual(
+            "VEKMVFGGSEDTTIRIWRREEGGCFHKCLAVLDGHRXXXX", hsp[0].hit.seq[-40:]
+        )
+        # last block
+        self.assertEqual("CLAACLEVEKVVMMGFLVYSASLDQTFKVWRVKVLPDEE", hsp[-1].query.seq)
+        self.assertEqual("CLAAC*QVEKMVMMGFLIYSVSLDQTLKVWRVKILPDQE", hsp[-1].hit.seq)
+
     def test_exn_22_q_none(self):
         """Test parsing exonerate output (exn_22_q_none.exn)."""
         exn_file = get_file("exn_22_q_none.exn")