Skip to content

Commit db9e988

Browse files
committed
Merge branch 'neg_control_mask_aligned'
2 parents a1d32b4 + 77ff8da commit db9e988

4 files changed

Lines changed: 227 additions & 0 deletions

File tree

workflow/envs/environment.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies:
1616
- r-ggforce=0.3.1
1717
- bedtools
1818
- samtools
19+
- parasail
1920
- newick_utils
2021
- pip=19.3.1
2122
- minimap2

workflow/rules/analysis.smk

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ rule all_qc_analysis:
77
input:
88
get_qc_analysis_plots
99

10+
rule all_masked_consensus:
11+
input:
12+
get_all_masked_consensus
13+
1014
#
1115
# Make a tree
1216
#
@@ -74,6 +78,21 @@ rule make_lineage_assignments:
7478
shell:
7579
"pangolin -t {threads} --outfile {output} {input}"
7680

81+
# mask all genomes with Ns for any amplicons detected in the negative control
82+
rule make_masked_consensus:
83+
input:
84+
sample_consensus=get_consensus,
85+
amplicons="bed/amplicon_full.bed",
86+
negative_control_report=get_negative_control_report,
87+
reference=get_reference_genome
88+
output:
89+
"masked_fasta/{sample}.masked_consensus.fasta"
90+
threads: 1
91+
params:
92+
masking_script = srcdir("../scripts/mask_genome_amplicons.py")
93+
shell:
94+
"python {params.masking_script} -b {input.amplicons} -n {input.negative_control_report} -r {input.reference} -g {input.sample_consensus} -o {output}"
95+
7796
# make the primary plot, containing the phylogenetic tree and associated mutations
7897
rule make_qc_tree_snps:
7998
input: get_tree_plot_input

workflow/rules/common.smk

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,10 @@ def get_annotated_variants(wildcards):
190190
out = [pattern.format(sample=s) for s in get_sample_names()]
191191
return out
192192

193+
def get_all_masked_consensus(wildcards):
194+
return ["masked_fasta/{sample}.masked_consensus.fasta".format(sample=s) for s in get_sample_names()]
195+
196+
193197
# generate the amplicon-level bed file from the input primer bed
194198
rule make_amplicon_bed:
195199
input:
@@ -203,6 +207,16 @@ rule make_amplicon_bed:
203207
shell:
204208
"{params.script} --primers {input.primers} --offset {params.offset} --bed_type {params.bed_type_opt} --output {output}"
205209

210+
rule make_amplicon_full_bed:
211+
input:
212+
primers=get_primer_bed
213+
output:
214+
"bed/amplicon_full.bed"
215+
params:
216+
script="primers_to_amplicons.py"
217+
shell:
218+
"{params.script} --primers {input.primers} --bed_type full --output {output}"
219+
206220
# make a bed file for the entire reference genome as a single record
207221
# from: https://bioinformatics.stackexchange.com/questions/91/how-to-convert-fasta-to-bed
208222
rule make_genome_bed:
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/usr/bin/env python
2+
3+
import sys
4+
import csv
5+
import pysam
6+
import parasail
7+
import argparse
8+
import textwrap as tw
9+
10+
11+
def get_detected_amplicons(file):
12+
"""
13+
Parse the negative control report and obtain a list of detected amplicons.
14+
"""
15+
amplicons = set()
16+
with open(file, 'r') as ifh:
17+
reader = csv.DictReader(ifh, delimiter='\t')
18+
for record in reader:
19+
if len(record['amplicons_detected']) > 0:
20+
_amplicons = record['amplicons_detected'].split(',')
21+
for amplicon in _amplicons:
22+
amplicons.add(amplicon)
23+
return amplicons
24+
25+
26+
def get_amplicon_dictionary(file, amplicons, column=3, delimiter='_'):
27+
"""
28+
Create a dictionary record for a set of given amplicons.
29+
"""
30+
amplicon_dict = dict()
31+
amplicon_data = list()
32+
with open(file, 'r') as ifh:
33+
for amplicon in ifh:
34+
amplicon = amplicon.strip()
35+
_amplicon_data = amplicon.split('\t')
36+
_id = get_amplicon_id(amplicon=_amplicon_data[column])
37+
if _id in amplicons:
38+
# the input BED is 1-based, switch to 0-based here
39+
amplicon_dict[_id] = {"start" : int(_amplicon_data[1]), "end" : int(_amplicon_data[2])}
40+
else:
41+
continue
42+
return amplicon_dict
43+
44+
45+
def get_amplicon_id(amplicon, column=1, delimiter='_'):
46+
"""
47+
Get the amplicon ID from an amplicon BED entry
48+
"""
49+
if len(amplicon) > 0:
50+
amplicon_id = amplicon.split(delimiter)
51+
return amplicon_id[column]
52+
else:
53+
return None
54+
55+
56+
def mask_genome(genome, amplicons, mask='N'):
57+
"""
58+
Mask a genome FASTA with Ns give a list of positions.
59+
"""
60+
fasta = pysam.FastxFile(genome)
61+
for record in fasta:
62+
sequence = record.sequence
63+
len_before = len(sequence)
64+
for _id in amplicons:
65+
start = int(amplicons[_id]['start'])
66+
end = int(amplicons[_id]['end']) - 1
67+
mask_size = end - start + 1
68+
sequence = sequence[:start] + mask_size * mask + sequence[end+1:]
69+
assert(len(sequence) == len_before)
70+
return {'header' : record.name, 'sequence' : sequence}
71+
72+
def get_sequence(file):
73+
fasta = pysam.FastxFile(file)
74+
reference = None
75+
for record in fasta:
76+
# For this narrow application there should be exactly one entry in the reference file
77+
assert(reference is None)
78+
reference = record
79+
return reference
80+
81+
def get_alignment(reference_genome, input_genome):
82+
83+
# the dna full matrix supports ambiguity codes, although "N"s are not given free mismatches as we might like
84+
# the alignments appear good enough for our purpose however
85+
result = parasail.nw_trace_striped_32(input_genome.sequence, reference_genome.sequence, 10, 1, parasail.dnafull)
86+
traceback = result.traceback
87+
columns = 120
88+
89+
position_map = list()
90+
91+
reference_index = 0
92+
input_index = 0
93+
94+
for (ref, query) in zip(traceback.ref, traceback.query):
95+
if ref != '-' and query != '-':
96+
position_map.append( (reference_index, input_index) )
97+
if ref != '-':
98+
reference_index += 1
99+
if query != '-':
100+
input_index += 1
101+
102+
return position_map
103+
104+
# translate the coordinates of the amplicon set from reference coordinates
105+
# to the coordinate system of the samples we're masking
106+
def translate_amplicons(position_map, amplicon_dict):
107+
108+
out_amplicons = dict()
109+
110+
for amplicon_id, amplicon in amplicon_dict.items():
111+
112+
# Search the position map for the min/max base in the input genome
113+
# that is mapped to a base within this amplicon
114+
reference_start = amplicon['start']
115+
116+
# we set the reference end coordinate to be inclusive (within the amplicon)
117+
# to simplify the logic below and avoid corner cases where there is a deletion
118+
# around the amplicon boundary
119+
# when the new amplicon coordinates are set later we set this back to be exclusive
120+
reference_end = amplicon['end'] - 1
121+
122+
input_start = None
123+
input_end = None
124+
125+
for (reference_position, input_position) in position_map:
126+
if reference_position >= reference_start and reference_position <= reference_end:
127+
128+
# this position is within the amplicon of interest
129+
if input_start is None or input_position < input_start:
130+
input_start = input_position
131+
if input_end is None or input_position > input_end:
132+
input_end = input_position
133+
134+
if input_start is not None and input_end is not None:
135+
out_amplicons[amplicon_id] = { "start":input_start, "end":input_end + 1 }
136+
return out_amplicons
137+
138+
def create_fasta(header, sequence):
139+
"""
140+
Create a FASTA record (includes header and sequence)
141+
"""
142+
fasta_record = list()
143+
fasta_record.append(''.join(['>', header + "_masked"]))
144+
fasta_record = fasta_record + tw.wrap(str(sequence), width=60)
145+
return fasta_record
146+
147+
148+
def write_fasta(record, file):
149+
"""
150+
Write the FASTA sequence to a file
151+
"""
152+
with open(file, 'w') as ofh:
153+
for line in record:
154+
ofh.write(line)
155+
ofh.write("\n")
156+
ofh.close()
157+
158+
def main():
159+
"""
160+
Main method for script
161+
"""
162+
description = 'Mask amplicons detected in negative controls from a consensus genome'
163+
parser = argparse.ArgumentParser(description=description)
164+
parser.add_argument('-g', '--genome', help='consensus genome FASTA file to process')
165+
parser.add_argument('-b', '--bed', help='amplicon BED file')
166+
parser.add_argument('-n', '--negative_control_report', help='the negative control report')
167+
parser.add_argument('-r', '--reference-genome', help='fasta file containing the reference genome')
168+
parser.add_argument('-o', '--output', help='name of FASTA file to write masked genome to')
169+
if len(sys.argv) <= 1:
170+
parser.print_help(sys.stderr)
171+
sys.exit(1)
172+
args = parser.parse_args()
173+
174+
reference_genome = get_sequence(file=args.reference_genome)
175+
input_genome = get_sequence(file=args.genome)
176+
177+
detected_amplicons = get_detected_amplicons(file=args.negative_control_report)
178+
amplicon_dict = get_amplicon_dictionary(file=args.bed, amplicons=detected_amplicons)
179+
180+
if len(input_genome.sequence) > 0:
181+
position_map = get_alignment(reference_genome, input_genome)
182+
amplicon_dict = translate_amplicons(position_map, amplicon_dict)
183+
else:
184+
position_map = list()
185+
amplicon_dict = dict()
186+
187+
sequence = mask_genome(genome=args.genome, amplicons=amplicon_dict)
188+
fasta = create_fasta(header=sequence['header'], sequence=sequence['sequence'])
189+
write_fasta(fasta, args.output)
190+
191+
192+
if __name__ == '__main__':
193+
main()

0 commit comments

Comments
 (0)