Skip to content

Commit f61eec3

Browse files
committed
Fix boundary read evidence counts
1 parent 10eea7a commit f61eec3

5 files changed

Lines changed: 229 additions & 17 deletions

File tree

isovar/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13-
__version__ = "1.4.21"
13+
__version__ = "1.4.22"
1414

1515

1616
from .allele_read import AlleleRead

isovar/read_collector.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ def locus_read_from_pysam_aligned_segment(
152152
if ref_pos is not None
153153
}
154154

155+
aligned_subsequence_start = pysam_aligned_segment.query_alignment_start
156+
aligned_subsequence_end = pysam_aligned_segment.query_alignment_end
157+
155158
reference_interval_size = base0_end_exclusive - base0_start_inclusive
156159
if reference_interval_size < 0:
157160
raise ValueError("Unexpected interval start after interval end")
@@ -194,16 +197,22 @@ def locus_read_from_pysam_aligned_segment(
194197
read_base0_before_insertion = reference_position_to_read_position.get(
195198
reference_position_before_insertion
196199
)
197-
if read_base0_before_insertion is None:
198-
return None
199-
200200
read_base0_after_insertion = reference_position_to_read_position.get(
201201
reference_position_after_insertion
202202
)
203-
if read_base0_after_insertion is None:
204-
return None
205203

206-
if read_base0_after_insertion - read_base0_before_insertion == 1:
204+
if (
205+
read_base0_before_insertion is None
206+
and read_base0_after_insertion is None
207+
):
208+
return None
209+
elif read_base0_before_insertion is None:
210+
read_base0_start_inclusive = aligned_subsequence_start
211+
read_base0_end_exclusive = read_base0_after_insertion
212+
elif read_base0_after_insertion is None:
213+
read_base0_start_inclusive = read_base0_before_insertion + 1
214+
read_base0_end_exclusive = aligned_subsequence_end
215+
elif read_base0_after_insertion - read_base0_before_insertion == 1:
207216
read_base0_start_inclusive = read_base0_end_exclusive = (
208217
read_base0_before_insertion + 1
209218
)
@@ -269,8 +278,6 @@ def locus_read_from_pysam_aligned_segment(
269278
# than the sequence, qualities, and alignment positions
270279
# we've extracted, so slice through those to get rid of
271280
# soft-clipped ends of the read
272-
aligned_subsequence_start = pysam_aligned_segment.query_alignment_start
273-
aligned_subsequence_end = pysam_aligned_segment.query_alignment_end
274281
sequence = sequence[aligned_subsequence_start:aligned_subsequence_end]
275282
base0_reference_positions = base0_reference_positions[
276283
aligned_subsequence_start:aligned_subsequence_end

tests/test_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def test_cli_allele_reads():
7777
df = run_cli_fn(isovar_allele_reads, return_dataframe=True)
7878
assert set(["prefix", "allele", "suffix", "name", "sequence", "gene"]).issubset(df.columns)
7979
assert "ref_reads" not in df.columns
80-
assert len(df) == 293
80+
assert len(df) == 294
8181

8282

8383
def test_cli_reference_contexts():

tests/test_locus_reads.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -235,13 +235,9 @@ def test_locus_reads_dataframe():
235235
for line in f:
236236
if line.startswith("HWI"):
237237
n_reads_expected += 1
238-
# we know from inspecting the file that *one* of the reads overlapping this
239-
# variant has a CIGAR string of N at the location before and thus we'll
240-
# be missing that read.
241-
#
242-
# TODO: figure out what to do when the variant nucleotide is at the start or
243-
# end of an exon, since that won't have mapping positions on both its left
244-
# and right
238+
# We know from inspecting the file that one fetched read is spliced across
239+
# this locus and therefore does not actually overlap the queried interval.
240+
# The widened fetch window pulls it in, and get_overlap() correctly drops it.
245241
n_reads_expected -= 1
246242

247243
print("Found %d sequences in %s" % (n_reads_expected, sam_path_single_variant))
@@ -410,3 +406,51 @@ def test_locus_read_insertion_locus_with_insertion_in_read():
410406
assert result is not None, "Read with insertion should be returned"
411407
eq_(result.read_base0_start_inclusive, 4)
412408
eq_(result.read_base0_end_exclusive, 5)
409+
410+
411+
def test_locus_read_insertion_locus_with_insertion_at_start_of_read():
412+
"""
413+
An insertion-supporting read can begin at the insertion locus and still
414+
provide a non-empty inserted interval.
415+
416+
Regression test for GitHub issue #49.
417+
"""
418+
pysam_read = make_pysam_read(
419+
seq="GT",
420+
cigar="1I1M",
421+
mdtag="1",
422+
reference_start=3,
423+
)
424+
read_collector = ReadCollector()
425+
result = read_collector.locus_read_from_pysam_aligned_segment(
426+
pysam_read,
427+
base0_start_inclusive=3,
428+
base0_end_exclusive=3,
429+
)
430+
assert result is not None, "Insertion at the start of a read should be kept"
431+
eq_(result.read_base0_start_inclusive, 0)
432+
eq_(result.read_base0_end_exclusive, 1)
433+
434+
435+
def test_locus_read_insertion_locus_with_insertion_at_end_of_read():
436+
"""
437+
An insertion-supporting read can end at the insertion locus and still
438+
provide a non-empty inserted interval.
439+
440+
Regression test for GitHub issue #49.
441+
"""
442+
pysam_read = make_pysam_read(
443+
seq="AG",
444+
cigar="1M1I",
445+
mdtag="1",
446+
reference_start=2,
447+
)
448+
read_collector = ReadCollector()
449+
result = read_collector.locus_read_from_pysam_aligned_segment(
450+
pysam_read,
451+
base0_start_inclusive=3,
452+
base0_end_exclusive=3,
453+
)
454+
assert result is not None, "Insertion at the end of a read should be kept"
455+
eq_(result.read_base0_start_inclusive, 1)
456+
eq_(result.read_base0_end_exclusive, 2)

tests/test_variant_reads_with_dummy_samfile.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,164 @@ def test_partitioned_read_sequences_deletion():
133133
allele="",
134134
suffix="G")
135135
eq_(variant_read, expected)
136+
137+
138+
def test_read_evidence_counts_snv_reads_at_read_boundaries():
139+
"""
140+
Reads whose SNV locus is the first or last aligned base should still be
141+
counted.
142+
143+
Regression test for GitHub issue #55.
144+
"""
145+
chromosome = "1"
146+
variant = Variant(
147+
chromosome,
148+
4,
149+
"T",
150+
"G",
151+
grch38,
152+
normalize_contig_names=False)
153+
154+
reads = [
155+
make_pysam_read(
156+
seq="GAAA",
157+
cigar="4M",
158+
mdtag="0T3",
159+
name="alt-start",
160+
reference_start=3),
161+
make_pysam_read(
162+
seq="TAAA",
163+
cigar="4M",
164+
mdtag="4",
165+
name="ref-start",
166+
reference_start=3),
167+
make_pysam_read(
168+
seq="AAAG",
169+
cigar="4M",
170+
mdtag="3T0",
171+
name="alt-end",
172+
reference_start=0),
173+
make_pysam_read(
174+
seq="AAAT",
175+
cigar="4M",
176+
mdtag="4",
177+
name="ref-end",
178+
reference_start=0),
179+
]
180+
181+
read_creator = ReadCollector()
182+
read_evidence = read_creator.read_evidence_for_variant(
183+
variant=variant,
184+
alignment_file=MockAlignmentFile(references=(chromosome,), reads=reads))
185+
186+
eq_(read_evidence.alt_read_names, {"alt-start", "alt-end"})
187+
eq_(read_evidence.ref_read_names, {"ref-start", "ref-end"})
188+
189+
190+
def test_read_evidence_counts_insertion_reads_at_read_boundaries():
191+
"""
192+
Insertion-supporting reads that begin or end at the insertion locus should
193+
be counted as alt reads instead of being dropped.
194+
195+
Regression test for GitHub issue #49. Also serves as an indel-count
196+
regression for GitHub issue #23.
197+
"""
198+
chromosome = "1"
199+
variant = Variant(
200+
chromosome,
201+
3,
202+
"A",
203+
"AG",
204+
grch38,
205+
normalize_contig_names=False)
206+
207+
reads = [
208+
make_pysam_read(
209+
seq="GT",
210+
cigar="1I1M",
211+
mdtag="1",
212+
name="alt-start",
213+
reference_start=3),
214+
make_pysam_read(
215+
seq="T",
216+
cigar="1M",
217+
mdtag="1",
218+
name="ref-start",
219+
reference_start=3),
220+
make_pysam_read(
221+
seq="AG",
222+
cigar="1M1I",
223+
mdtag="1",
224+
name="alt-end",
225+
reference_start=2),
226+
make_pysam_read(
227+
seq="A",
228+
cigar="1M",
229+
mdtag="1",
230+
name="ref-end",
231+
reference_start=2),
232+
]
233+
234+
read_creator = ReadCollector()
235+
read_evidence = read_creator.read_evidence_for_variant(
236+
variant=variant,
237+
alignment_file=MockAlignmentFile(references=(chromosome,), reads=reads))
238+
239+
eq_(read_evidence.alt_read_names, {"alt-start", "alt-end"})
240+
eq_(read_evidence.ref_read_names, {"ref-start", "ref-end"})
241+
242+
243+
def test_partitioned_read_sequences_snv_at_last_exonic_base_before_splice():
244+
"""
245+
A variant on the last nucleotide of an exon should still be recovered from
246+
a spliced read.
247+
248+
Regression test for GitHub issue #24.
249+
"""
250+
chromosome = "1"
251+
variant = Variant(
252+
chromosome,
253+
4,
254+
"T",
255+
"G",
256+
grch38,
257+
normalize_contig_names=False)
258+
read = make_pysam_read(
259+
seq="ACCGTGGA",
260+
cigar="4M100N4M",
261+
name="splice-before",
262+
reference_start=0)
263+
read_creator = ReadCollector()
264+
variant_reads = read_creator.allele_reads_supporting_variant(
265+
variant=variant,
266+
alignment_file=MockAlignmentFile(references=(chromosome,), reads=[read]))
267+
assert len(variant_reads) == 1
268+
eq_(variant_reads[0].allele, "G")
269+
270+
271+
def test_partitioned_read_sequences_snv_at_first_exonic_base_after_splice():
272+
"""
273+
A variant on the first nucleotide of an exon should still be recovered from
274+
a spliced read.
275+
276+
Regression test for GitHub issue #24.
277+
"""
278+
chromosome = "1"
279+
variant = Variant(
280+
chromosome,
281+
105,
282+
"T",
283+
"G",
284+
grch38,
285+
normalize_contig_names=False)
286+
read = make_pysam_read(
287+
seq="ACCTGGGA",
288+
cigar="4M100N4M",
289+
name="splice-after",
290+
reference_start=0)
291+
read_creator = ReadCollector()
292+
variant_reads = read_creator.allele_reads_supporting_variant(
293+
variant=variant,
294+
alignment_file=MockAlignmentFile(references=(chromosome,), reads=[read]))
295+
assert len(variant_reads) == 1
296+
eq_(variant_reads[0].allele, "G")

0 commit comments

Comments
 (0)