Repository: acorg/dark-matter

Issue:
Add filtering for percentage similarity on DIAMOND results

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 38154ac..58a052d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 3.1.26 Dec 3, 2019
+
+Added `--percentagePositiveCutoff` argument to `alignment-panel-civ.py`
+and `noninteractive-alignment-panel.py` and all that implies, down to
+reading DIAMOND output with the `ppos` value, storing it, restoring it,
+and filtering on it in `dark.alignments.ReadsAlignmentsFilter`, plus tests.
+
 ## 3.1.25 Dec 3, 2019
 
 Added `--minProteinCount` argument to `proteins-to-pathogens-civ.py` and
diff --git a/bin/alignment-panel-civ.py b/bin/alignment-panel-civ.py
index decc2c9..7132561 100755
--- a/bin/alignment-panel-civ.py
+++ b/bin/alignment-panel-civ.py
@@ -142,6 +142,14 @@ if __name__ == '__main__':
         help=('A float percentage identity (0.0 to 100.0). Matches with '
               'percent identity scores less than this will be ignored.'))
 
+    parser.add_argument(
+        '--percentagePositiveCutoff', type=float,
+        help=('A float percentage (0.0 to 100.0). Matches with percent '
+              'positive scores less than this will be ignored. A single AA '
+              'match is considered positive (by DIAMOND) if its entry in the '
+              'substitution (scoring) matrix is positive. DIAMOND uses '
+              'the BLOSUM62 matrix by default.'))
+
     parser.add_argument(
         '--maxHspsPerHit', type=int,
         help='A numeric max number of HSPs to show for each hit on hitId.')
@@ -340,6 +348,7 @@ if __name__ == '__main__':
         maxHspsPerHit=args.maxHspsPerHit,
         scoreCutoff=args.scoreCutoff,
         percentageIdenticalCutoff=args.percentageIdenticalCutoff,
+        percentagePositiveCutoff=args.percentagePositiveCutoff,
         whitelist=whitelist, blacklist=blacklist,
         whitelistFile=args.whitelistFile, blacklistFile=args.blacklistFile,
         titleRegex=args.titleRegex, negativeTitleRegex=args.negativeTitleRegex,
diff --git a/bin/noninteractive-alignment-panel.py b/bin/noninteractive-alignment-panel.py
index 77a4297..b745b3d 100755
--- a/bin/noninteractive-alignment-panel.py
+++ b/bin/noninteractive-alignment-panel.py
@@ -161,6 +161,14 @@ if __name__ == '__main__':
         help=('A float percentage identity (0.0 to 100.0). Matches with '
               'percent identity scores less than this will be ignored.'))
 
+    parser.add_argument(
+        '--percentagePositiveCutoff', type=float,
+        help=('A float percentage (0.0 to 100.0). Matches with percent '
+              'positive scores less than this will be ignored. A single AA '
+              'match is considered positive (by DIAMOND) if its entry in the '
+              'substitution (scoring) matrix is positive. DIAMOND uses '
+              'the BLOSUM62 matrix by default.'))
+
     parser.add_argument(
         '--maxHspsPerHit', type=int,
         help='A numeric max number of HSPs to show for each hit on hitId.')
@@ -390,6 +398,7 @@ if __name__ == '__main__':
         maxHspsPerHit=args.maxHspsPerHit,
         scoreCutoff=args.scoreCutoff,
         percentageIdenticalCutoff=args.percentageIdenticalCutoff,
+        percentagePositiveCutoff=args.percentagePositiveCutoff,
         whitelist=whitelist, blacklist=blacklist,
         whitelistFile=args.whitelistFile, blacklistFile=args.blacklistFile,
         titleRegex=args.titleRegex, negativeTitleRegex=args.negativeTitleRegex,
diff --git a/dark/__init__.py b/dark/__init__.py
index 7c9ee7d..c1a0f13 100644
--- a/dark/__init__.py
+++ b/dark/__init__.py
@@ -7,4 +7,4 @@ if sys.version_info < (2, 7):
 # will not be found by the version() function in ../setup.py
 #
 # Remember to update ../CHANGELOG.md describing what's new in each version.
-__version__ = '3.1.25'
+__version__ = '3.1.26'
diff --git a/dark/alignments.py b/dark/alignments.py
index 89aa032..37ae8db 100644
--- a/dark/alignments.py
+++ b/dark/alignments.py
@@ -108,6 +108,10 @@ class ReadsAlignmentsFilter(object):
     @param percentageIdenticalCutoff: A C{float} percentage identity (i.e.,
         0.0 to 100.0 NOT a fraction). Matches with percentage identity less
         than this will be ignored.
+    @param percentagePositiveCutoff: A C{float} percentage (i.e.,
+        0.0 to 100.0 NOT a fraction). Matches with a percentage positive less
+        than this will be ignored. An AA match is considered positive (by
+        DIAMOND) if its entry in the substitution (scoring) matrix is positive.
     @param whitelist: If not C{None}, a set of exact titles that are always
         acceptable (though the match info for a whitelist title may rule it
         out for other reasons).
@@ -134,6 +138,7 @@ class ReadsAlignmentsFilter(object):
                  minStart=None, maxStop=None,
                  oneAlignmentPerRead=False, maxHspsPerHit=None,
                  scoreCutoff=None, percentageIdenticalCutoff=None,
+                 percentagePositiveCutoff=None,
                  whitelist=None, blacklist=None,
                  whitelistFile=None, blacklistFile=None,
                  titleRegex=None, negativeTitleRegex=None,
@@ -149,6 +154,7 @@ class ReadsAlignmentsFilter(object):
         self.maxHspsPerHit = maxHspsPerHit
         self.scoreCutoff = scoreCutoff
         self.percentageIdenticalCutoff = percentageIdenticalCutoff
+        self.percentagePositiveCutoff = percentagePositiveCutoff
 
         # If we've been asked to filter on matched sequence titles in any way,
         # build a title filter.
@@ -302,20 +308,27 @@ class ReadsAlignmentsFilter(object):
             else:
                 return False
 
-        # Throw out HSPs whose percentage identical is not good enough.
+        # Throw out HSPs whose percentage identical or positive is not good
+        # enough.
         #
-        # Note that if the percentIdentical is None it means that we did
-        # not run DIAMOND or BLAST in a way that generated that output, so
-        # we cannot eliminate such an HSP.
-        if self.percentageIdenticalCutoff is not None:
-            cutoff = self.percentageIdenticalCutoff
+        # Note that if percentIdentical or percentPositive is None it means
+        # that we did not run DIAMOND or BLAST in a way that generated that
+        # output, so we cannot eliminate such an HSP.
+        if (self.percentageIdenticalCutoff is not None or
+                self.percentagePositiveCutoff is not None):
+
+            piCutoff = self.percentageIdenticalCutoff
+            ppCutoff = self.percentagePositiveCutoff
             wantedAlignments = []
+
             for alignment in readAlignments:
                 hsps = alignment.hsps
                 wantedHsps = []
                 for hsp in hsps:
-                    percentIdentical = hsp.percentIdentical
-                    if percentIdentical is None or percentIdentical >= cutoff:
+                    pi = hsp.percentIdentical
+                    pp = hsp.percentPositive
+                    if ((pi is None or pi >= piCutoff) and
+                            (pp is None or pp >= ppCutoff)):
                         wantedHsps.append(hsp)
                 if wantedHsps:
                     alignment.hsps = wantedHsps
diff --git a/dark/diamond/conversion.py b/dark/diamond/conversion.py
index 4e0ecdf..173b36d 100644
--- a/dark/diamond/conversion.py
+++ b/dark/diamond/conversion.py
@@ -131,14 +131,14 @@ class DiamondTabularFormatReader(object):
         qtitle stitle bitscore evalue qframe qseq qstart qend sseq sstart send
             slen btop
 
-        14 arg version:
-        qtitle stitle bitscore evalue qframe qseq qstart qend sseq sstart send
-            slen btop pident
-
         15 arg version:
         qtitle stitle bitscore evalue qframe qseq qstart qend sseq sstart send
             slen btop nident positive
 
+        17 arg version:
+        qtitle stitle bitscore evalue qframe qseq qstart qend sseq sstart send
+            slen btop nident pident positive ppos
+
     @param filename: A C{str} filename or an open file pointer, containing
         DIAMOND tabular records.
 
@@ -178,21 +178,21 @@ class DiamondTabularFormatReader(object):
                 if nFields == 13:
                     (qtitle, stitle, bitscore, evalue, qframe, qseq, qstart,
                      qend, sseq, sstart, send, slen, btop) = fields
-                    nident = positive = pident = None
-                elif nFields == 14:
-                    (qtitle, stitle, bitscore, evalue, qframe, qseq, qstart,
-                     qend, sseq, sstart, send, slen, btop, pident) = fields
-                    nident = positive = None
+                    nident = pident = positive = ppos = None
                 elif nFields == 15:
                     (qtitle, stitle, bitscore, evalue, qframe, qseq, qstart,
                      qend, sseq, sstart, send, slen, btop, nident,
                      positive) = fields
-                    pident = None
+                    pident = ppos = None
+                elif nFields == 17:
+                    (qtitle, stitle, bitscore, evalue, qframe, qseq, qstart,
+                     qend, sseq, sstart, send, slen, btop, nident, pident,
+                     positive, ppos) = fields
                 else:
                     raise ValueError(
                         'Could not make sense of DIAMOND output. You must use '
-                        '--outfmt 6 with 13, 14, or 15 arguments. See %s for '
-                        'their names.' % __file__)
+                        '--outfmt 6 with 13, 15, or 17 arguments. See %s for '
+                        'the expected names and order.' % __file__)
 
                 hsp = {
                     'bits': float(bitscore),
@@ -204,6 +204,8 @@ class DiamondTabularFormatReader(object):
                         None if pident is None else float(pident)),
                     'positiveCount': (
                         None if positive is None else int(positive)),
+                    'percentPositive': (
+                        None if ppos is None else float(ppos)),
                     'query': qseq,
                     'query_start': int(qstart),
                     'query_end': int(qend),
@@ -361,16 +363,17 @@ class JSONRecordsReader(object):
                     readMatchedSequence=diamondHsp['query'],
                     subjectMatchedSequence=diamondHsp['sbjct'],
                     # Use diamondHsp.get on identicalCount, positiveCount,
-                    # and percentIdentical because they were either added
-                    # in version 2.0.3 or we didn't start using them until
-                    # much later and so will not be present in any of our
-                    # JSON output generated before that. Those values will
-                    # be None when reading those JSON files, but that's
-                    # much better than no longer being able to read all
-                    # that earlier data.
+                    # percentPositive, and percentIdentical because they
+                    # were either added in version 2.0.3 or we didn't start
+                    # using them until much later and so will not be
+                    # present in any of our JSON output generated before
+                    # that. Those values will be None when reading those
+                    # JSON files, but that's much better than no longer
+                    # being able to read all that earlier data.
                     identicalCount=diamondHsp.get('identicalCount'),
                     positiveCount=diamondHsp.get('positiveCount'),
-                    percentIdentical=diamondHsp.get('percentIdentical'))
+                    percentIdentical=diamondHsp.get('percentIdentical'),
+                    percentPositive=diamondHsp.get('percentPositive'))
 
                 alignment.addHsp(hsp)
 
diff --git a/dark/hsp.py b/dark/hsp.py
index 742108c..0fbd001 100644
--- a/dark/hsp.py
+++ b/dark/hsp.py
@@ -69,7 +69,8 @@ class _Base(object):
                  readEndInSubject=None, readFrame=None, subjectStart=None,
                  subjectEnd=None, subjectFrame=None, readMatchedSequence=None,
                  subjectMatchedSequence=None, identicalCount=None,
-                 positiveCount=None, percentIdentical=None):
+                 percentIdentical=None, positiveCount=None,
+                 percentPositive=None):
         self.readStart = readStart
         self.readEnd = readEnd
         self.readStartInSubject = readStartInSubject
@@ -81,8 +82,9 @@ class _Base(object):
         self.readMatchedSequence = readMatchedSequence
         self.subjectMatchedSequence = subjectMatchedSequence
         self.identicalCount = identicalCount
-        self.positiveCount = positiveCount
         self.percentIdentical = percentIdentical
+        self.positiveCount = positiveCount
+        self.percentPositive = percentPositive
 
     def __lt__(self, other):
         return self.score < other.score
@@ -117,8 +119,9 @@ class _Base(object):
             'readMatchedSequence': self.readMatchedSequence,
             'subjectMatchedSequence': self.subjectMatchedSequence,
             'identicalCount': self.identicalCount,
-            'positiveCount': self.positiveCount,
             'percentIdentical': self.percentIdentical,
+            'positiveCount': self.positiveCount,
+            'percentPositive': self.percentPositive,
         }
 
 


Test patch:
diff --git a/test/blast/test_titles.py b/test/blast/test_titles.py
index b4aa345..d9acb57 100644
--- a/test/blast/test_titles.py
+++ b/test/blast/test_titles.py
@@ -315,6 +315,7 @@ class TestTitlesAlignments(TestCase):
                                         'identicalCount': None,
                                         'percentIdentical': None,
                                         'positiveCount': None,
+                                        'percentPositive': None,
                                         'readEnd': 68,
                                         'readEndInSubject': 1405,
                                         'readFrame': 1,
@@ -340,6 +341,7 @@ class TestTitlesAlignments(TestCase):
                                         'identicalCount': None,
                                         'percentIdentical': None,
                                         'positiveCount': None,
+                                        'percentPositive': None,
                                         'readEnd': 68,
                                         'readEndInSubject': 1405,
                                         'readFrame': 1,
@@ -372,6 +374,7 @@ class TestTitlesAlignments(TestCase):
                                         'identicalCount': None,
                                         'percentIdentical': None,
                                         'positiveCount': None,
+                                        'percentPositive': None,
                                         'readEnd': 68,
                                         'readEndInSubject': 11405,
                                         'readFrame': 1,
@@ -404,6 +407,7 @@ class TestTitlesAlignments(TestCase):
                                         'identicalCount': None,
                                         'percentIdentical': None,
                                         'positiveCount': None,
+                                        'percentPositive': None,
                                         'readEnd': 68,
                                         'readEndInSubject': 10405,
                                         'readFrame': 1,
@@ -436,6 +440,7 @@ class TestTitlesAlignments(TestCase):
                                         'identicalCount': None,
                                         'percentIdentical': None,
                                         'positiveCount': None,
+                                        'percentPositive': None,
                                         'readEnd': 68,
                                         'readEndInSubject': 15405,
                                         'readFrame': 1,
@@ -468,6 +473,7 @@ class TestTitlesAlignments(TestCase):
                                         'identicalCount': None,
                                         'percentIdentical': None,
                                         'positiveCount': None,
+                                        'percentPositive': None,
                                         'readEnd': 68,
                                         'readEndInSubject': 12405,
                                         'readFrame': 1,
diff --git a/test/diamond/test_alignments.py b/test/diamond/test_alignments.py
index 9ac2e4e..d6b5f79 100644
--- a/test/diamond/test_alignments.py
+++ b/test/diamond/test_alignments.py
@@ -850,6 +850,73 @@ class TestDiamondReadsAlignmentsFiltering(TestCase):
             self.assertEqual(1, len(result[0]))
             self.assertEqual('Merkel2', result[0][0].subjectTitle)
 
+    def testPercentPositiveCutoffRemovesEntireAlignment(self):
+        """
+        If the L{DiamondReadsAlignments} filter function is supposed to filter
+        on a percent positive cut-off and the cut-off value results in an
+        alignment with no HSPs, then the alignment must be removed entirely.
+        """
+        record = {
+            "query": "H6E8I1T01BFUH9",
+            "alignments": [
+                {
+                    "length": 961,
+                    "hsps": [
+                        {
+                            "sbjct_end": 869,
+                            "expect": 1.25854e-10,
+                            "sbjct": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+                            "sbjct_start": 836,
+                            "query": ("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAA"),
+                            "frame": 1,
+                            "query_end": 462,
+                            "bits": 150,
+                            "btop": "",
+                            "query_start": 362,
+                            "percentPositive": 40.0,
+                        },
+                    ],
+                    "title": "Merkel1"
+                },
+                {
+                    "length": 740,
+                    "hsps": [
+                        {
+                            "sbjct_end": 647,
+                            "expect": 1.25e-43,
+                            "sbjct": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+                            "sbjct_start": 614,
+                            "query": ("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAA"),
+                            "frame": 1,
+                            "query_end": 462,
+                            "bits": 180,
+                            "btop": "",
+                            "query_start": 362,
+                            "percentPositive": 45.0,
+                        }
+                    ],
+                    "title": "Merkel2"
+                }
+            ]
+        }
+
+        mockOpener = mockOpen(read_data=dumps(PARAMS) + '\n' +
+                              dumps(record) + '\n')
+        with patch.object(builtins, 'open', mockOpener):
+            reads = Reads()
+            reads.add(Read('H6E8I1T01BFUH9', 'A' * 500))
+            readsAlignments = DiamondReadsAlignments(
+                reads, 'file.json', databaseFilename='database.fasta')
+            result = list(readsAlignments.filter(
+                percentagePositiveCutoff=41.0))
+            self.assertEqual(1, len(result))
+            self.assertEqual(1, len(result[0]))
+            self.assertEqual('Merkel2', result[0][0].subjectTitle)
+
     def testScoreCutoffRemovesHsps_Bits(self):
         """
         If the L{DiamondRecords} records function is supposed to filter on
@@ -1195,6 +1262,182 @@ class TestDiamondReadsAlignmentsFiltering(TestCase):
             self.assertEqual(1, len(result[0][1].hsps))
             self.assertEqual(80.0, result[0][1].hsps[0].percentIdentical)
 
+    def testPercentPositiveCutoffRemovesHsps(self):
+        """
+        If the L{DiamondRecords} records function is supposed to filter on
+        percentage positive cut-off and the cut-off value results in some HSPs
+        being invalid, then those HSPs must be removed entirely.
+        """
+        record = {
+            "query": "H6E8I1T01BFUH9",
+            "alignments": [
+                {
+                    "length": 961,
+                    "hsps": [
+                        {
+                            "sbjct_end": 869,
+                            "expect": 1.25854e-10,
+                            "sbjct": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+                            "sbjct_start": 836,
+                            "query": ("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAA"),
+                            "frame": 1,
+                            "query_end": 462,
+                            "bits": 150,
+                            "btop": "",
+                            "query_start": 362,
+                            "percentPositive": 30.0,
+                        },
+                        {
+                            "sbjct_end": 869,
+                            "expect": 1.25e-20,
+                            "sbjct": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+                            "sbjct_start": 836,
+                            "query": ("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAA"),
+                            "frame": 1,
+                            "query_end": 462,
+                            "bits": 170,
+                            "btop": "",
+                            "query_start": 362,
+                            "percentPositive": 70.0,
+                        }
+                    ],
+                    "title": "Merkel1"
+                },
+                {
+                    "length": 740,
+                    "hsps": [
+                        {
+                            "sbjct_end": 647,
+                            "expect": 1.25e-43,
+                            "sbjct": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+                            "sbjct_start": 614,
+                            "query": ("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAA"),
+                            "frame": 1,
+                            "query_end": 462,
+                            "bits": 180,
+                            "btop": "",
+                            "query_start": 362,
+                            "percentPositive": 80.0,
+                        }
+                    ],
+                    "title": "Merkel2"
+                }
+            ]
+        }
+
+        mockOpener = mockOpen(read_data=dumps(PARAMS) + '\n' +
+                              dumps(record) + '\n')
+        with patch.object(builtins, 'open', mockOpener):
+            reads = Reads()
+            reads.add(Read('H6E8I1T01BFUH9', 'A' * 500))
+            readsAlignments = DiamondReadsAlignments(
+                reads, 'file.json', databaseFilename='database.fasta')
+            result = list(readsAlignments.filter(
+                percentagePositiveCutoff=50.0))
+
+            # There should only be one HSP left in the alignments for the
+            # first read, and it should have the right score (bit score).
+            self.assertEqual(1, len(result[0][0].hsps))
+            self.assertEqual(70.0, result[0][0].hsps[0].percentPositive)
+
+            # The second alignment should also be present.
+            self.assertEqual(1, len(result[0][1].hsps))
+            self.assertEqual(80.0, result[0][1].hsps[0].percentPositive)
+
+    def testPercentPositiveCutoffIgnoresNoneValues(self):
+        """
+        If the L{DiamondRecords} records function is supposed to filter on
+        percentage positive cut-off and the cut-off value in some HSPs
+        are None, then those HSPs must not be removed.
+        """
+        record = {
+            "query": "H6E8I1T01BFUH9",
+            "alignments": [
+                {
+                    "length": 961,
+                    "hsps": [
+                        {
+                            "sbjct_end": 869,
+                            "expect": 1.25854e-10,
+                            "sbjct": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+                            "sbjct_start": 836,
+                            "query": ("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAA"),
+                            "frame": 1,
+                            "query_end": 462,
+                            "bits": 150,
+                            "btop": "",
+                            "query_start": 362,
+                            "percentPositive": None,
+                        },
+                        {
+                            "sbjct_end": 869,
+                            "expect": 1.25e-20,
+                            "sbjct": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+                            "sbjct_start": 836,
+                            "query": ("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAA"),
+                            "frame": 1,
+                            "query_end": 462,
+                            "bits": 170,
+                            "btop": "",
+                            "query_start": 362,
+                            "percentPositive": 70.0,
+                        }
+                    ],
+                    "title": "Merkel1"
+                },
+                {
+                    "length": 740,
+                    "hsps": [
+                        {
+                            "sbjct_end": 647,
+                            "expect": 1.25e-43,
+                            "sbjct": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+                            "sbjct_start": 614,
+                            "query": ("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+                                      "AAAAAAAAAAAAAAAAAAAAAAAAAA"),
+                            "frame": 1,
+                            "query_end": 462,
+                            "bits": 180,
+                            "btop": "",
+                            "query_start": 362,
+                            "percentPositive": 80.0,
+                        }
+                    ],
+                    "title": "Merkel2"
+                }
+            ]
+        }
+
+        mockOpener = mockOpen(read_data=dumps(PARAMS) + '\n' +
+                              dumps(record) + '\n')
+        with patch.object(builtins, 'open', mockOpener):
+            reads = Reads()
+            reads.add(Read('H6E8I1T01BFUH9', 'A' * 500))
+            readsAlignments = DiamondReadsAlignments(
+                reads, 'file.json', databaseFilename='database.fasta')
+            result = list(readsAlignments.filter(
+                percentagePositiveCutoff=75.0))
+
+            # Only one HSP should be left in the alignments for the first
+            # read, and it should have a None percent identity score.
+            self.assertEqual(1, len(result[0][0].hsps))
+            self.assertIs(None, result[0][0].hsps[0].percentPositive)
+
+            # The second alignment should also be present.
+            self.assertEqual(1, len(result[0][1].hsps))
+            self.assertEqual(80.0, result[0][1].hsps[0].percentPositive)
+
     def testTitleByRegexCaseInvariant(self):
         """
         Filtering with a title regex must work independent of case.
diff --git a/test/diamond/test_conversion.py b/test/diamond/test_conversion.py
index 4238b35..ee8a11c 100644
--- a/test/diamond/test_conversion.py
+++ b/test/diamond/test_conversion.py
@@ -30,15 +30,15 @@ from dark.reads import Reads, AARead
 DIAMOND_RECORDS = """\
 ACC94	INSV	29.6	0.003	1	EFII	178	295	SSSEV	175	285	295	4	0	1
 ACC94	CASV	28.1	0.008	1	KLL	7	37	ITRV	9	39	300	3	1	2
-ACC94	GoldenGate	28.1	0.009	1	IKSKL	7	35	EETSR	9	37	293	5	2	3
-ACC94	GoldenGate	23.5	0.21	1	TIMSVV	177	240	DDMV	179	235	293	6	3	4
-ACC94	InfluenzaC	25.0	0.084	1	LHVNYL	1	203	DEELKA	2	210	290	6	4	5
-ACC94	InfluenzaC	18.5	9.1	1	SEIICEVLK	226	257	VETVAQ	20	45	290	9	5	6
-ACC94	FERV	24.6	0.11	1	YSCFT-NSEK	176	276	LGKRMFC	152	243	270	10	6	7
-AKAV	AKAV	634	0.0	1	GEPFSVYG	1	306	NIYGEP	1	306	306	8	7	8
-AKAV	WYOV	401	7e-143	1	PFSVYGRF	1	306	GEPMS	1	294	294	8	8	9
-BHAV	TAIV	28.1	0.008	1	PKELHGLI	14	118	SLKSKE	15	131	307	8	9	10
-BHAV	SouthBay	28.1	0.009	1	CRPTF	4	293	EFVFIY	6	342	343	5	10	11
+ACC94	Golden	28.1	0.009	1	IKSKL	7	35	EETSR	9	37	293	5	2	3
+ACC94	Golden	23.5	0.21	1	TIMSVV	177	240	DDMV	179	235	293	6	3	4
+ACC94	HCoV	25.0	0.084	1	LHVNYL	1	203	DEELKA	2	210	290	6	4	5
+ACC94	HCoV	18.5	9.1	1	SEIICE	226	257	VETVAQ	20	45	290	9	5	6
+ACC94	FERV	24.6	0.11	1	YSCFT	176	276	LGKRMFC	152	243	270	10	6	7
+AKAV	AKAV	634	0.0	1	GEPFSV	1	306	NIYGEP	1	306	306	8	7	8
+AKAV	WYOV	401	7e-143	1	PFSVYG	1	306	GEPMS	1	294	294	8	8	9
+BHAV	TAIV	28.1	0.008	1	PKELHG	14	118	SLKSKE	15	131	307	8	9	10
+BHAV	SBAY	28.1	0.009	1	CRPTF	4	293	EFVFIY	6	342	343	5	10	11
 """
 
 # The 13 fields expected in the DIAMOND output before we added identities
@@ -53,37 +53,38 @@ BHAV	SouthBay	28.1	0.009	1	CRPTF	4	293	EFVFIY	6	342	343	5	10	11
 DIAMOND_RECORDS_WITHOUT_NIDENT_AND_POSITIVE = """\
 ACC94	INSV	29.6	0.003	1	EFII	178	295	SSSEV	175	285	295	4
 ACC94	CASV	28.1	0.008	1	KLL	7	37	ITRV	9	39	300	3
-ACC94	GoldenGate	28.1	0.009	1	IKSKL	7	35	EETSR	9	37	293	5
-ACC94	GoldenGate	23.5	0.21	1	TIMSVV	177	240	DDMV	179	235	293	6
-ACC94	InfluenzaC	25.0	0.084	1	LHVNYL	1	203	DEELKA	2	210	290	6
-ACC94	InfluenzaC	18.5	9.1	1	SEIICEVLK	226	257	VETVAQ	20	45	290	9
-ACC94	FERV	24.6	0.11	1	YSCFT-NSEK	176	276	LGKRMFC	152	243	270	10
-AKAV	AKAV	634	0.0	1	GEPFSVYG	1	306	NIYGEP	1	306	306	8
-AKAV	WYOV	401	7e-143	1	PFSVYGRF	1	306	GEPMS	1	294	294	8
-BHAV	TAIV	28.1	0.008	1	PKELHGLI	14	118	SLKSKE	15	131	307	8
-BHAV	SouthBay	28.1	0.009	1	CRPTF	4	293	EFVFIY	6	342	343	5
+ACC94	Golden	28.1	0.009	1	IKSKL	7	35	EETSR	9	37	293	5
+ACC94	Golden	23.5	0.21	1	TIMSVV	177	240	DDMV	179	235	293	6
+ACC94	HCoV	25.0	0.084	1	LHVNYL	1	203	DEELKA	2	210	290	6
+ACC94	HCoV	18.5	9.1	1	SEIICE	226	257	VETVAQ	20	45	290	9
+ACC94	FERV	24.6	0.11	1	YSCFT	176	276	LGKRMFC	152	243	270	10
+AKAV	AKAV	634	0.0	1	GEPFSV	1	306	NIYGEP	1	306	306	8
+AKAV	WYOV	401	7e-143	1	PFSVYG	1	306	GEPMS	1	294	294	8
+BHAV	TAIV	28.1	0.008	1	PKELHG	14	118	SLKSKE	15	131	307	8
+BHAV	SBAY	28.1	0.009	1	CRPTF	4	293	EFVFIY	6	342	343	5
 """
 
-# The 14 fields expected in the DIAMOND output with pident (percent identical).
+# The 17 fields expected in the DIAMOND output with nident, pident (number
+# and percent identical) and positive & ppos (number and percent positive).
 #
 # qtitle, stitle, bitscore, evalue, qframe, qseq, qstart, qend, sseq, sstart,
-# send, slen, btop, pident
+# send, slen, btop, nident, pident, positive, ppos
 #
 # See the --outfmt section of 'diamond help' for detail on these directives.
 #
 # Note that the fields below must be separated by TABs.
-DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL = """\
-ACC94	INSV	29.6	0.003	1	EFII	178	295	SSSEV	175	285	295	4	5.0
-ACC94	CASV	28.1	0.008	1	KLL	7	37	ITRV	9	39	300	3	10.0
-ACC94	GoldenGate	28.1	0.009	1	IKSKL	7	35	EETSR	9	37	293	5	15.0
-ACC94	GoldenGate	23.5	0.21	1	TIMSVV	177	240	DDMV	179	235	293	6	20.0
-ACC94	InfluenzaC	25.0	0.084	1	LHVNYL	1	203	DEELKA	2	210	290	6	25.0
-ACC94	InfluenzaC	18.5	9.1	1	SEIICEVLK	226	257	VETVAQ	20	45	290	9	30.0
-ACC94	FERV	24.6	0.11	1	YSCFT-NSEK	176	276	LGKRMFC	152	243	270	10	35.0
-AKAV	AKAV	634	0.0	1	GEPFSVYG	1	306	NIYGEP	1	306	306	8	40.0
-AKAV	WYOV	401	7e-143	1	PFSVYGRF	1	306	GEPMS	1	294	294	8	45.0
-BHAV	TAIV	28.1	0.008	1	PKELHGLI	14	118	SLKSKE	15	131	307	8	50.0
-BHAV	SouthBay	28.1	0.009	1	CRPTF	4	293	EFVFIY	6	342	343	5	55.0
+DIAMOND_RECORDS_WITH_PERCENTS = """\
+ACC94	INSV	29.6	0.003	1	EFII	178	295	SSSEV	175	285	295	4	2	5.0	1	4.0
+ACC94	CASV	28.1	0.008	1	KLL	7	37	ITRV	9	39	300	3	3	10.0	2	9.0
+ACC94	Golden	28.1	0.009	1	IKSKL	7	35	EETSR	9	37	293	5	4	15.0	3	14.0
+ACC94	Golden	23.5	0.21	1	TIMSVV	177	240	DDMV	179	235	293	6	5	20.0	4	19.0
+ACC94	HCoV	25.0	0.084	1	LHVNYL	1	203	DEELKA	2	210	290	6	6	25.0	5	24.0
+ACC94	HCoV	18.5	9.1	1	SEIICE	226	257	VETVAQ	20	45	290	9	7	30.0	6	29.0
+ACC94	FERV	24.6	0.11	1	YSCFT	176	276	LGKRMFC	152	243	270	10	8	35.0	7	34.0
+AKAV	AKAV	634	0.0	1	GEPFSV	1	306	NIYGEP	1	306	306	8	9	40.0	8	39.0
+AKAV	WYOV	401	7e-143	1	PFSVYG	1	306	GEPMS	1	294	294	8	10	45.0	9	44.0
+BHAV	TAIV	28.1	0.008	1	PKELHG	14	118	SLKSKE	15	131	307	8	11	50.0	10	49.0
+BHAV	SBAY	28.1	0.009	1	CRPTF	4	293	EFVFIY	6	342	343	5	12	55.0	11	54.0
 """
 
 DIAMOND_RECORD_WITH_SPACES_IN_TITLES = """\
@@ -111,6 +112,7 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                         "identicalCount": 0,
                         "percentIdentical": None,
                         "positiveCount": 1,
+                        "percentPositive": None,
                         "query": "EFII",
                         "query_end": 295,
                         "query_start": 178,
@@ -132,6 +134,7 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                         "identicalCount": 1,
                         "percentIdentical": None,
                         "positiveCount": 2,
+                        "percentPositive": None,
                         "query": "KLL",
                         "query_end": 37,
                         "query_start": 7,
@@ -153,6 +156,7 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                         "identicalCount": 2,
                         "percentIdentical": None,
                         "positiveCount": 3,
+                        "percentPositive": None,
                         "query": "IKSKL",
                         "query_end": 35,
                         "query_start": 7,
@@ -168,6 +172,7 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                         "identicalCount": 3,
                         "percentIdentical": None,
                         "positiveCount": 4,
+                        "percentPositive": None,
                         "query": "TIMSVV",
                         "query_end": 240,
                         "query_start": 177,
@@ -177,7 +182,7 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                     }
                 ],
                 "length": 293,
-                "title": "GoldenGate"
+                "title": "Golden"
             },
             {
                 "hsps": [
@@ -189,6 +194,7 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                         "identicalCount": 4,
                         "percentIdentical": None,
                         "positiveCount": 5,
+                        "percentPositive": None,
                         "query": "LHVNYL",
                         "query_end": 203,
                         "query_start": 1,
@@ -204,7 +210,8 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                         "identicalCount": 5,
                         "percentIdentical": None,
                         "positiveCount": 6,
-                        "query": "SEIICEVLK",
+                        "percentPositive": None,
+                        "query": "SEIICE",
                         "query_end": 257,
                         "query_start": 226,
                         "sbjct": "VETVAQ",
@@ -213,7 +220,7 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                     }
                 ],
                 "length": 290,
-                "title": "InfluenzaC"
+                "title": "HCoV"
             },
             {
                 "hsps": [
@@ -225,7 +232,8 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                         "identicalCount": 6,
                         "percentIdentical": None,
                         "positiveCount": 7,
-                        "query": "YSCFT-NSEK",
+                        "percentPositive": None,
+                        "query": "YSCFT",
                         "query_end": 276,
                         "query_start": 176,
                         "sbjct": "LGKRMFC",
@@ -251,7 +259,8 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                         "identicalCount": 7,
                         "percentIdentical": None,
                         "positiveCount": 8,
-                        "query": "GEPFSVYG",
+                        "percentPositive": None,
+                        "query": "GEPFSV",
                         "query_end": 306,
                         "query_start": 1,
                         "sbjct": "NIYGEP",
@@ -272,7 +281,8 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                         "identicalCount": 8,
                         "percentIdentical": None,
                         "positiveCount": 9,
-                        "query": "PFSVYGRF",
+                        "percentPositive": None,
+                        "query": "PFSVYG",
                         "query_end": 306,
                         "query_start": 1,
                         "sbjct": "GEPMS",
@@ -298,7 +308,8 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                         "identicalCount": 9,
                         "percentIdentical": None,
                         "positiveCount": 10,
-                        "query": "PKELHGLI",
+                        "percentPositive": None,
+                        "query": "PKELHG",
                         "query_end": 118,
                         "query_start": 14,
                         "sbjct": "SLKSKE",
@@ -319,6 +330,7 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                         "identicalCount": 10,
                         "percentIdentical": None,
                         "positiveCount": 11,
+                        "percentPositive": None,
                         "query": "CRPTF",
                         "query_end": 293,
                         "query_start": 4,
@@ -328,7 +340,7 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
                     }
                 ],
                 "length": 343,
-                "title": "SouthBay"
+                "title": "SBAY"
             }
         ],
         "query": "BHAV"
@@ -336,7 +348,7 @@ DIAMOND_RECORDS_DUMPED = '\n'.join([
 ]) + '\n'
 
 
-DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
+DIAMOND_RECORDS_WITH_PERCENTS_DUMPED = '\n'.join([
     dumps({
         "application": "DIAMOND",
         "reference": ("Buchfink et al., Fast and Sensitive "
@@ -354,9 +366,10 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                         "btop": "4",
                         "expect": 0.003,
                         "frame": 1,
-                        "identicalCount": None,
+                        "identicalCount": 2,
                         "percentIdentical": 5.0,
-                        "positiveCount": None,
+                        "percentPositive": 4.0,
+                        "positiveCount": 1,
                         "query": "EFII",
                         "query_end": 295,
                         "query_start": 178,
@@ -375,9 +388,10 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                         "btop": "3",
                         "expect": 0.008,
                         "frame": 1,
-                        "identicalCount": None,
+                        "identicalCount": 3,
                         "percentIdentical": 10.0,
-                        "positiveCount": None,
+                        "percentPositive": 9.0,
+                        "positiveCount": 2,
                         "query": "KLL",
                         "query_end": 37,
                         "query_start": 7,
@@ -396,9 +410,10 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                         "btop": "5",
                         "expect": 0.009,
                         "frame": 1,
-                        "identicalCount": None,
+                        "identicalCount": 4,
                         "percentIdentical": 15.0,
-                        "positiveCount": None,
+                        "percentPositive": 14.0,
+                        "positiveCount": 3,
                         "query": "IKSKL",
                         "query_end": 35,
                         "query_start": 7,
@@ -411,9 +426,10 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                         "btop": "6",
                         "expect": 0.21,
                         "frame": 1,
-                        "identicalCount": None,
+                        "identicalCount": 5,
                         "percentIdentical": 20.0,
-                        "positiveCount": None,
+                        "percentPositive": 19.0,
+                        "positiveCount": 4,
                         "query": "TIMSVV",
                         "query_end": 240,
                         "query_start": 177,
@@ -423,7 +439,7 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                     }
                 ],
                 "length": 293,
-                "title": "GoldenGate"
+                "title": "Golden"
             },
             {
                 "hsps": [
@@ -432,9 +448,10 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                         "btop": "6",
                         "expect": 0.084,
                         "frame": 1,
-                        "identicalCount": None,
+                        "identicalCount": 6,
                         "percentIdentical": 25.0,
-                        "positiveCount": None,
+                        "percentPositive": 24.0,
+                        "positiveCount": 5,
                         "query": "LHVNYL",
                         "query_end": 203,
                         "query_start": 1,
@@ -447,10 +464,11 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                         "btop": "9",
                         "expect": 9.1,
                         "frame": 1,
-                        "identicalCount": None,
+                        "identicalCount": 7,
                         "percentIdentical": 30.0,
-                        "positiveCount": None,
-                        "query": "SEIICEVLK",
+                        "percentPositive": 29.0,
+                        "positiveCount": 6,
+                        "query": "SEIICE",
                         "query_end": 257,
                         "query_start": 226,
                         "sbjct": "VETVAQ",
@@ -459,7 +477,7 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                     }
                 ],
                 "length": 290,
-                "title": "InfluenzaC"
+                "title": "HCoV"
             },
             {
                 "hsps": [
@@ -468,10 +486,11 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                         "btop": "10",
                         "expect": 0.11,
                         "frame": 1,
-                        "identicalCount": None,
+                        "identicalCount": 8,
                         "percentIdentical": 35.0,
-                        "positiveCount": None,
-                        "query": "YSCFT-NSEK",
+                        "percentPositive": 34.0,
+                        "positiveCount": 7,
+                        "query": "YSCFT",
                         "query_end": 276,
                         "query_start": 176,
                         "sbjct": "LGKRMFC",
@@ -494,10 +513,11 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                         "btop": "8",
                         "expect": 0.0,
                         "frame": 1,
-                        "identicalCount": None,
+                        "identicalCount": 9,
                         "percentIdentical": 40.0,
-                        "positiveCount": None,
-                        "query": "GEPFSVYG",
+                        "percentPositive": 39.0,
+                        "positiveCount": 8,
+                        "query": "GEPFSV",
                         "query_end": 306,
                         "query_start": 1,
                         "sbjct": "NIYGEP",
@@ -515,10 +535,11 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                         "btop": "8",
                         "expect": 7e-143,
                         "frame": 1,
-                        "identicalCount": None,
+                        "identicalCount": 10,
                         "percentIdentical": 45.0,
-                        "positiveCount": None,
-                        "query": "PFSVYGRF",
+                        "percentPositive": 44.0,
+                        "positiveCount": 9,
+                        "query": "PFSVYG",
                         "query_end": 306,
                         "query_start": 1,
                         "sbjct": "GEPMS",
@@ -541,10 +562,11 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                         "btop": "8",
                         "expect": 0.008,
                         "frame": 1,
-                        "identicalCount": None,
+                        "identicalCount": 11,
                         "percentIdentical": 50.0,
-                        "positiveCount": None,
-                        "query": "PKELHGLI",
+                        "percentPositive": 49.0,
+                        "positiveCount": 10,
+                        "query": "PKELHG",
                         "query_end": 118,
                         "query_start": 14,
                         "sbjct": "SLKSKE",
@@ -562,9 +584,10 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                         "btop": "5",
                         "expect": 0.009,
                         "frame": 1,
-                        "identicalCount": None,
+                        "identicalCount": 12,
                         "percentIdentical": 55.0,
-                        "positiveCount": None,
+                        "percentPositive": 54.0,
+                        "positiveCount": 11,
                         "query": "CRPTF",
                         "query_end": 293,
                         "query_start": 4,
@@ -574,7 +597,7 @@ DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED = '\n'.join([
                     }
                 ],
                 "length": 343,
-                "title": "SouthBay"
+                "title": "SBAY"
             }
         ],
         "query": "BHAV"
@@ -655,13 +678,13 @@ class TestDiamondTabularFormatReader(TestCase):
         A DiamondTabularFormatReader must be able to save itself as JSON
         when the percentIdentical field is present.
         """
-        mockOpener = mockOpen(read_data=DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL)
+        mockOpener = mockOpen(read_data=DIAMOND_RECORDS_WITH_PERCENTS)
         with patch.object(builtins, 'open', mockOpener):
             reader = DiamondTabularFormatReader('file.txt')
             fp = StringIO()
             reader.saveAsJSON(fp)
             self.maxDiff = None
-            self.assertEqual(DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED,
+            self.assertEqual(DIAMOND_RECORDS_WITH_PERCENTS_DUMPED,
                              fp.getvalue())
 
     def testSaveAsJSONBzip2(self):
@@ -685,7 +708,7 @@ class TestDiamondTabularFormatReader(TestCase):
         A DiamondTabularFormatReader must be able to save itself as bzip2'd
         JSON when the percentIdentical field is present.
         """
-        mockOpener = mockOpen(read_data=DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL)
+        mockOpener = mockOpen(read_data=DIAMOND_RECORDS_WITH_PERCENTS)
         with patch.object(builtins, 'open', mockOpener):
             reader = DiamondTabularFormatReader('file.txt')
             data = BytesIO()
@@ -693,8 +716,7 @@ class TestDiamondTabularFormatReader(TestCase):
             reader.saveAsJSON(fp, writeBytes=True)
             fp.close()
             self.assertEqual(
-                compress(DIAMOND_RECORDS_WITH_PERCENT_IDENTICAL_DUMPED.encode(
-                    'UTF-8')),
+                compress(DIAMOND_RECORDS_WITH_PERCENTS_DUMPED.encode('UTF-8')),
                 data.getvalue())
 
     def testSpacesMustBePreserved(self):
diff --git a/test/test_hsp.py b/test/test_hsp.py
index b9b645e..c2ca9d4 100644
--- a/test/test_hsp.py
+++ b/test/test_hsp.py
@@ -64,7 +64,7 @@ class TestHSP(TestCase):
                   subjectStart=5, subjectEnd=6,
                   readMatchedSequence='aaa', subjectMatchedSequence='ccc',
                   readFrame=7, subjectFrame=8, identicalCount=9,
-                  positiveCount=10, percentIdentical=99.3)
+                  percentIdentical=99.3, positiveCount=10, percentPositive=3.0)
 
         self.assertEqual(
             {
@@ -78,10 +78,11 @@ class TestHSP(TestCase):
                 'readFrame': 7,
                 'subjectFrame': 8,
                 'identicalCount': 9,
+                'percentIdentical': 99.3,
                 'positiveCount': 10,
+                'percentPositive': 3.0,
                 'readMatchedSequence': 'aaa',
                 'subjectMatchedSequence': 'ccc',
-                'percentIdentical': 99.3,
             },
             hsp.toDict())
 
@@ -147,7 +148,8 @@ class TestLSP(TestCase):
                   subjectStart=5, subjectEnd=6,
                   readMatchedSequence='aaa', subjectMatchedSequence='ccc',
                   readFrame=7, subjectFrame=8, identicalCount=9,
-                  positiveCount=10, percentIdentical=99.3)
+                  percentIdentical=99.3, positiveCount=10,
+                  percentPositive=9.9)
 
         self.assertEqual(
             {
@@ -161,9 +163,10 @@ class TestLSP(TestCase):
                 'readFrame': 7,
                 'subjectFrame': 8,
                 'identicalCount': 9,
+                'percentIdentical': 99.3,
                 'positiveCount': 10,
+                'percentPositive': 9.9,
                 'readMatchedSequence': 'aaa',
                 'subjectMatchedSequence': 'ccc',
-                'percentIdentical': 99.3,
             },
             lsp.toDict())
diff --git a/test/test_titles.py b/test/test_titles.py
index 53c0a2c..fe5e858 100644
--- a/test/test_titles.py
+++ b/test/test_titles.py
@@ -58,13 +58,15 @@ class TestTitleAlignment(TestCase):
                    subjectStart=5, subjectEnd=6,
                    readMatchedSequence='aaa', subjectMatchedSequence='ccc',
                    readFrame=7, subjectFrame=8, identicalCount=9,
-                   positiveCount=10, percentIdentical=31.3)
+                   percentIdentical=31.3, positiveCount=10,
+                   percentPositive=4.5)
         hsp2 = HSP(10, readStart=11, readEnd=12,
                    readStartInSubject=13, readEndInSubject=14,
                    subjectStart=15, subjectEnd=16,
                    readMatchedSequence='ggg', subjectMatchedSequence='ttt',
                    readFrame=17, subjectFrame=18, identicalCount=19,
-                   positiveCount=20, percentIdentical=32.3)
+                   percentIdentical=32.3, positiveCount=20,
+                   percentPositive=4.6)
         titleAlignment = TitleAlignment(read, [hsp1, hsp2])
 
         self.assertEqual(
@@ -81,10 +83,11 @@ class TestTitleAlignment(TestCase):
                         'readFrame': 7,
                         'subjectFrame': 8,
                         'identicalCount': 9,
+                        'percentIdentical': 31.3,
                         'positiveCount': 10,
+                        'percentPositive': 4.5,
                         'readMatchedSequence': 'aaa',
                         'subjectMatchedSequence': 'ccc',
-                        'percentIdentical': 31.3,
                     },
                     {
                         'score': 10,
@@ -97,10 +100,11 @@ class TestTitleAlignment(TestCase):
                         'readFrame': 17,
                         'subjectFrame': 18,
                         'identicalCount': 19,
+                        'percentIdentical': 32.3,
                         'positiveCount': 20,
+                        'percentPositive': 4.6,
                         'readMatchedSequence': 'ggg',
                         'subjectMatchedSequence': 'ttt',
-                        'percentIdentical': 32.3,
                     },
                 ],
                 'read': {
@@ -901,13 +905,15 @@ class TestTitleAlignments(WarningTestMixin, TestCase):
                    subjectStart=5, subjectEnd=6,
                    readMatchedSequence='aaa', subjectMatchedSequence='ccc',
                    readFrame=7, subjectFrame=8, identicalCount=9,
-                   positiveCount=10, percentIdentical=17.9)
+                   percentIdentical=17.9, positiveCount=10,
+                   percentPositive=3.9)
         hsp2 = HSP(10, readStart=11, readEnd=12,
                    readStartInSubject=13, readEndInSubject=14,
                    subjectStart=15, subjectEnd=16,
                    readMatchedSequence='ggg', subjectMatchedSequence='ttt',
                    readFrame=17, subjectFrame=18, identicalCount=19,
-                   positiveCount=20, percentIdentical=27.9)
+                   percentIdentical=27.9, positiveCount=20,
+                   percentPositive=3.8)
         titleAlignment = TitleAlignment(read, [hsp1, hsp2])
         titleAlignments = TitleAlignments('subject title', 10)
         titleAlignments.addAlignment(titleAlignment)
@@ -930,10 +936,11 @@ class TestTitleAlignments(WarningTestMixin, TestCase):
                                 'readFrame': 7,
                                 'subjectFrame': 8,
                                 'identicalCount': 9,
+                                'percentIdentical': 17.9,
                                 'positiveCount': 10,
+                                'percentPositive': 3.9,
                                 'readMatchedSequence': 'aaa',
                                 'subjectMatchedSequence': 'ccc',
-                                'percentIdentical': 17.9,
                             },
                             {
                                 'score': 10,
@@ -946,10 +953,11 @@ class TestTitleAlignments(WarningTestMixin, TestCase):
                                 'readFrame': 17,
                                 'subjectFrame': 18,
                                 'identicalCount': 19,
+                                'percentIdentical': 27.9,
                                 'positiveCount': 20,
+                                'percentPositive': 3.8,
                                 'readMatchedSequence': 'ggg',
                                 'subjectMatchedSequence': 'ttt',
-                                'percentIdentical': 27.9,
                             },
                         ],
                         'read': {