Skip to content

Commit e72c52e

Browse files
committed
fix: mask sites before dN and dS calculation
1 parent fa5f624 commit e72c52e

4 files changed

Lines changed: 73 additions & 3 deletions

File tree

workflow/envs/bedtools.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
channels:
2+
- conda-forge
3+
- bioconda
4+
dependencies:
5+
- bedtools==2.31.1

workflow/rules/evolution.smk

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,27 @@ rule filter_genbank_features:
1414
"../scripts/filter_genbank_features.py"
1515

1616

17+
rule build_problematic_bed:
18+
conda:
19+
"../envs/bedtools.yaml"
20+
input:
21+
vcf = lambda wildcards: select_problematic_vcf(),
22+
output:
23+
bed = temp(OUTDIR / "sites_masked.bed"),
24+
log:
25+
LOGDIR / "build_problematic_bed" / "log.txt",
26+
shell:
27+
"bedtools merge -i {input.vcf} >{output.bed} 2>{log}"
28+
29+
1730
rule n_s_sites:
1831
threads: 1
1932
conda: "../envs/biopython.yaml"
2033
params:
2134
gb_qualifier_display = "gene",
2235
input:
2336
fasta = OUTDIR/f"{OUTPUT_NAME}.ancestor.fasta",
37+
masked = OUTDIR / "sites_masked.bed",
2438
gb = OUTDIR/"reference.cds.gb",
2539
genetic_code = Path(config["GENETIC_CODE_JSON"]).resolve(),
2640
output:
@@ -35,6 +49,7 @@ rule calculate_dnds:
3549
conda: "../envs/renv.yaml"
3650
input:
3751
n_s_sites = OUTDIR/f"{OUTPUT_NAME}.ancestor.n_s.sites.csv",
52+
masked = OUTDIR / "sites_masked.bed",
3853
variants = OUTDIR/f"{OUTPUT_NAME}.variants.tsv",
3954
metadata = config["METADATA"]
4055
output:

workflow/scripts/calculate_dnds.R

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ sink(log, type = "output")
88
library(dplyr)
99
library(readr)
1010
library(tidyr)
11+
library(purrr)
1112
library(logger)
1213

1314
log_threshold(INFO)
@@ -37,6 +38,21 @@ metadata <- read_delim(snakemake@input[["metadata"]]) %>%
3738
select(ID, interval) %>%
3839
rename(SAMPLE = ID)
3940

41+
log_info("Reading masked sites BED")
42+
masked <- read_tsv(
43+
snakemake@input$masked,
44+
col_names = c("chrom", "start", "end"),
45+
col_types = "cii",
46+
comment = "#"
47+
) %>%
48+
mutate(POS = map2(start + 1, end, seq.int)) %>%
49+
unnest(POS) %>%
50+
pull(POS)
51+
52+
log_info("Filtering variants on masked sites")
53+
variants <- variants %>%
54+
filter(!POS %in% masked, !is.na(SYNONYMOUS))
55+
4056
log_debug("Adding metadata to variants table")
4157
variants <- left_join(variants, metadata)
4258

workflow/scripts/n_s_sites_from_fasta.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,50 @@
77

88
import pandas as pd
99
from Bio import SeqIO
10-
from Bio.SeqRecord import SeqRecord
10+
from Bio.SeqRecord import SeqRecord, SeqFeature
1111
from Bio.Seq import Seq
1212

1313

1414
NTS = ("A", "C", "G", "T")
15+
MASK_CHR = "N"
1516

1617

1718
def read_monofasta(path: str) -> SeqRecord:
1819
return SeqIO.read(path, format="fasta")
1920

2021

22+
def read_masked_positions(bed_path: str) -> set:
23+
masked = set()
24+
with open(bed_path) as fh:
25+
for line in fh:
26+
if line.startswith("#") or not line.strip():
27+
continue
28+
parts = line.strip().split("\t")
29+
start, end = int(parts[1]), int(parts[2])
30+
masked.update(range(start, end))
31+
return masked
32+
33+
34+
def mask_feature(feature: SeqFeature, record: SeqRecord, masked_positions: set) -> SeqRecord:
35+
extracted = feature.extract(record)
36+
seq_chars = list(str(extracted.seq).upper())
37+
# Handle reverse strand and compound locations
38+
genomic_positions = []
39+
for part in feature.location.parts:
40+
positions = list(range(int(part.start), int(part.end)))
41+
if part.strand == -1:
42+
positions.reverse()
43+
genomic_positions.extend(positions)
44+
# Mask sites on sequence
45+
for i, pos in enumerate(genomic_positions):
46+
if seq_chars[i] not in NTS or pos in masked_positions:
47+
seq_chars[i] = MASK_CHR
48+
extracted.seq = Seq("".join(seq_chars))
49+
return extracted
50+
51+
2152
def split_into_codons(seq: Seq) -> list:
22-
"""Split the complete CDS feature in to a list of codons"""
53+
"""Split the complete CDS feature in to a list of codons (if ACGT only)"""
2354
return [
2455
seq[i:i + 3] for i in range(0, len(seq)-2, 3) if all(char in NTS for char in seq[i:i + 3])
2556
]
@@ -79,6 +110,9 @@ def main():
79110
with open(snakemake.input.genetic_code) as f:
80111
genetic_code = json.load(f)
81112

113+
logging.info("Reading masked sites BED")
114+
masked = read_masked_positions(snakemake.input.masked)
115+
82116
logging.info("Reading GenBank file")
83117
gb = SeqIO.read(snakemake.input.gb, format="gb")
84118

@@ -95,7 +129,7 @@ def main():
95129
logging.warning(f"Identifier '{identifier}' is already among coding records and will not be replaced by feature at {feature.location}")
96130
else:
97131
logging.debug(f"Adding feature")
98-
coding_records[identifier] = feature.extract(record)
132+
coding_records[identifier] = mask_feature(feature, record, masked)
99133

100134
logging.info(f"Splitting {len(coding_records)} records into codons")
101135
codons = get_feature_codons(coding_records)

0 commit comments

Comments
 (0)