InSilicoControl / src /pipeline.py
Halper-Stromberg
Add window-based variant placement strategy for Direct Probe Coordinates mode
2a68195
Raw
History Blame Contribute Delete
22.3 kB
import os
import random
import subprocess
import urllib.request
from pathlib import Path
CACHE_DIR = Path.home() / ".cache" / "insilicocontrols"
CACHE_DIR.mkdir(parents=True, exist_ok=True)
MANE_BB = CACHE_DIR / "MANE.bb"
MANE_BED12 = CACHE_DIR / "MANE.bed12"
HG38_FA = CACHE_DIR / "hg38.fa"
HG38_FAI = CACHE_DIR / "hg38.fa.fai"
HG19_FA = CACHE_DIR / "hg19.fa"
HG19_FAI = CACHE_DIR / "hg19.fa.fai"
BIGBEDTOBED_PATH = CACHE_DIR / "bigBedToBed"
def log(msg, log_func=None):
if log_func:
log_func(msg)
else:
print(msg)
def run_cmd(cmd, log_func=None):
log(f"$ {cmd}", log_func)
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.stdout:
log(result.stdout.strip(), log_func)
if result.stderr:
log(result.stderr.strip(), log_func)
return result.returncode == 0
def ensure_bigbedtobed(log_func=None):
if BIGBEDTOBED_PATH.exists():
return str(BIGBEDTOBED_PATH)
log("Downloading bigBedToBed...", log_func)
url = "https://hgdownload.cse.ucsc.edu/admin/exe/linux.x86_64/bigBedToBed"
urllib.request.urlretrieve(url, str(BIGBEDTOBED_PATH))
os.chmod(str(BIGBEDTOBED_PATH), 0o755)
log("bigBedToBed ready.", log_func)
return str(BIGBEDTOBED_PATH)
def ensure_mane(log_func=None):
if MANE_BED12.exists():
log("MANE annotation already cached.", log_func)
return True
log("Downloading MANE annotation...", log_func)
bigbedtobed = ensure_bigbedtobed(log_func)
url = "https://ftp.ncbi.nlm.nih.gov/refseq/MANE/trackhub/data/release_1.0/MANE.GRCh38.v1.0.refseq.bb"
urllib.request.urlretrieve(url, str(MANE_BB))
log("Converting bigBed to BED12...", log_func)
ok = run_cmd(f"{bigbedtobed} {MANE_BB} {MANE_BED12}", log_func)
if not ok:
raise RuntimeError("Failed to convert MANE bigBed")
log("MANE annotation ready.", log_func)
return True
def ensure_reference(genome_version="hg38", log_func=None):
if genome_version == "hg19":
fa_path = HG19_FA
fai_path = HG19_FAI
url = "https://hgdownload.soe.ucsc.edu/goldenPath/hg19/bigZips/hg19.fa.gz"
else:
fa_path = HG38_FA
fai_path = HG38_FAI
url = "https://hgdownload.soe.ucsc.edu/goldenPath/hg38/bigZips/hg38.fa.gz"
if fa_path.exists() and fai_path.exists():
log(f"{genome_version} reference already cached.", log_func)
return fa_path
log(f"Downloading {genome_version}.fa.gz... This will take several minutes.", log_func)
gz_path = CACHE_DIR / f"{genome_version}.fa.gz"
urllib.request.urlretrieve(url, str(gz_path))
log(f"Decompressing {genome_version}.fa.gz...", log_func)
run_cmd(f"gunzip -f {gz_path}", log_func)
log(f"Indexing {genome_version}.fa with samtools faidx...", log_func)
run_cmd(f"samtools faidx {fa_path}", log_func)
log(f"{genome_version} reference ready.", log_func)
return fa_path
def parse_mane_exons(work_dir, log_func=None):
exons_raw = work_dir / "hg38_exons_raw.bed"
exons_sorted = work_dir / "hg38_exons.bed"
log("Parsing MANE BED12 into individual exons...", log_func)
with open(MANE_BED12, "r") as infile, open(exons_raw, "w") as outfile:
for line in infile:
cols = line.strip().split()
if len(cols) < 12:
continue
chrom = cols[0]
chromStart = int(cols[1])
thickStart, thickEnd = int(cols[6]), int(cols[7])
sizes = [int(x) for x in cols[10].strip(",").split(",")]
starts = [int(x) for x in cols[11].strip(",").split(",")]
num_exons = len(sizes)
for i in range(num_exons):
exon_start = chromStart + starts[i]
exon_end = exon_start + sizes[i]
if exon_end > thickStart and exon_start < thickEnd:
cds_start = max(exon_start, thickStart)
cds_end = min(exon_end, thickEnd)
has_left_intron = i > 0
has_right_intron = i < num_exons - 1
outfile.write(
f"{chrom}\t{exon_start}\t{exon_end}\t{cds_start}\t{cds_end}\t{has_left_intron}\t{has_right_intron}\n"
)
run_cmd(f"bedtools sort -i {exons_raw} > {exons_sorted}", log_func)
log("Exon parsing complete.", log_func)
return exons_sorted
def analyze_coverage(work_dir, probes_bed, exons_bed, log_func=None):
merged = work_dir / "merged_probes.bed"
fully = work_dir / "fully_covered_exons.bed"
any_cov = work_dir / "any_covered_exons.bed"
partial = work_dir / "partially_covered_exons.bed"
probes_no_exons = work_dir / "probes_without_exons.bed"
unused = work_dir / "unused_probes.bed"
log("Merging contiguous probes...", log_func)
run_cmd(f"bedtools sort -i {probes_bed} | bedtools merge -i - > {merged}", log_func)
log("Intersecting probes with exons...", log_func)
run_cmd(f"bedtools intersect -a {exons_bed} -b {merged} -f 0.95 -wa -u > {fully}", log_func)
run_cmd(f"bedtools intersect -a {exons_bed} -b {merged} -wa -u > {any_cov}", log_func)
run_cmd(f"bedtools intersect -a {any_cov} -b {fully} -v > {partial}", log_func)
run_cmd(f"bedtools intersect -a {probes_bed} -b {exons_bed} -v > {probes_no_exons}", log_func)
run_cmd(f"bedtools intersect -a {merged} -b {any_cov} -v > {unused}", log_func)
stats = {
"fully_covered": int(subprocess.check_output(f"wc -l < {fully}", shell=True).strip() or 0),
"partially_covered": int(subprocess.check_output(f"wc -l < {partial}", shell=True).strip() or 0),
"probes_no_exons": int(subprocess.check_output(f"wc -l < {probes_no_exons}", shell=True).strip() or 0),
"unused_probes": int(subprocess.check_output(f"wc -l < {unused}", shell=True).strip() or 0),
}
return stats, fully, partial, unused
def generate_target_snvs(work_dir, fully_bed, partial_bed, unused_bed,
include_cds=True, include_intron=True, include_offtarget=True,
mode="mane", probes_bed=None, direct_window_size=0,
log_func=None):
output = work_dir / "igv_variant_navigator.bed"
log("Generating target SNVs...", log_func)
if mode == "direct_bed":
if not probes_bed:
raise ValueError("probes_bed must be provided when mode='direct_bed'")
with open(probes_bed, "r") as infile, open(output, "w") as outfile:
for idx, line in enumerate(infile):
if not line.strip() or line.startswith("#") or line.startswith("track"):
continue
parts = line.strip().split("\t")
if len(parts) < 3:
continue
chrom = parts[0]
start, end = int(parts[1]), int(parts[2])
if direct_window_size > 0:
w_start = start
w_idx = 1
while w_start < end:
w_end = min(w_start + direct_window_size, end)
pos = random.randint(w_start, w_end - 1) if w_end > w_start else w_start
probe_label = f"ProbeLocus_{idx+1}_{chrom}_{start}_{end}_w{w_idx}"
outfile.write(f"{chrom}\t{pos}\t{pos+1}\t{probe_label}_direct\n")
w_start = w_end
w_idx += 1
else:
pos = random.randint(start, end - 1) if end > start else start
probe_label = f"ProbeLocus_{idx+1}_{chrom}_{start}_{end}"
outfile.write(f"{chrom}\t{pos}\t{pos+1}\t{probe_label}_direct\n")
else:
def process_exons(bed_file, outfile, label_prefix):
with open(bed_file, "r") as infile:
for idx, line in enumerate(infile):
if not line.strip():
continue
parts = line.strip().split("\t")
if len(parts) < 7:
continue
chrom = parts[0]
start, end = int(parts[1]), int(parts[2])
cds_start, cds_end = int(parts[3]), int(parts[4])
has_left_intron = parts[5] == "True"
has_right_intron = parts[6] == "True"
exon_label = f"{label_prefix}_{idx+1}_{chrom}_{start}_{end}"
if include_intron and has_left_intron:
left_snv = max(0, start - random.randint(1, 10))
outfile.write(f"{chrom}\t{left_snv}\t{left_snv+1}\t{exon_label}_left_intron\n")
if include_cds and cds_end > cds_start:
exon_snv = random.randint(cds_start, cds_end - 1)
outfile.write(f"{chrom}\t{exon_snv}\t{exon_snv+1}\t{exon_label}_cds\n")
if include_intron and has_right_intron:
right_snv = end + random.randint(1, 10)
outfile.write(f"{chrom}\t{right_snv}\t{right_snv+1}\t{exon_label}_right_intron\n")
with open(output, "w") as outfile:
process_exons(fully_bed, outfile, "FullyCovered")
process_exons(partial_bed, outfile, "PartiallyCovered")
if include_offtarget:
with open(unused_bed, "r") as infile:
for idx, line in enumerate(infile):
if not line.strip():
continue
parts = line.strip().split("\t")
if len(parts) < 3:
continue
chrom = parts[0]
start, end = int(parts[1]), int(parts[2])
midpoint = (start + end) // 2
probe_label = f"UnusedProbe_{idx+1}_{chrom}_{start}_{end}"
outfile.write(f"{chrom}\t{midpoint}\t{midpoint+1}\t{probe_label}_midpoint\n")
total = int(subprocess.check_output(f"wc -l < {output}", shell=True).strip() or 0)
log(f"Generated {total} total SNVs.", log_func)
return output, total
def apply_mutations(read_start, read_length, ref_fasta, chrom, variants_to_apply):
import pysam
try:
ref_seq = ref_fasta.fetch(chrom, read_start, read_start + read_length + 50).upper()
except Exception:
return None, None, 0
cigar = []
out_seq = []
nm = 0
ref_idx = 0
read_idx = 0
variants_to_apply = sorted(variants_to_apply, key=lambda x: x["pos"])
for v in variants_to_apply:
v_ref_idx = v["pos"] - read_start
if v_ref_idx < ref_idx:
continue
if v_ref_idx > ref_idx:
dist = min(v_ref_idx - ref_idx, read_length - read_idx)
if dist > 0:
out_seq.append(ref_seq[ref_idx:ref_idx + dist])
cigar.append([0, dist])
ref_idx += dist
read_idx += dist
if read_idx >= read_length:
break
ref_len = len(v["ref"])
alt_len = len(v["alt"])
if ref_len == 1 and alt_len == 1:
out_seq.append(v["alt"])
cigar.append([0, 1])
ref_idx += 1
read_idx += 1
nm += 1
elif alt_len > ref_len:
ins_seq = v["alt"]
avail = read_length - read_idx
if avail > 0:
added_seq = ins_seq[:avail]
out_seq.append(added_seq)
cigar.append([0, 1])
if len(added_seq) > 1:
cigar.append([1, len(added_seq) - 1])
ref_idx += 1
read_idx += len(added_seq)
nm += len(added_seq) - 1
elif ref_len > alt_len:
out_seq.append(v["alt"])
cigar.append([0, 1])
cigar.append([2, ref_len - 1])
ref_idx += ref_len
read_idx += 1
nm += ref_len - 1
if read_idx < read_length:
rem = read_length - read_idx
out_seq.append(ref_seq[ref_idx:ref_idx + rem])
cigar.append([0, rem])
final_cigar = []
for op, length in cigar:
if length > 0:
if final_cigar and final_cigar[-1][0] == op:
final_cigar[-1][1] += length
else:
final_cigar.append([op, length])
return "".join(out_seq), [tuple(c) for c in final_cigar], nm
def generate_synthetic_bam(
work_dir, snvs_bed, fasta_path, depth, vaf, rg_id, rg_sm,
insert_size, insert_std, indel_interval, read_length=150,
sequencing_mode="hybrid_capture",
log_func=None, progress_func=None
):
import pysam
from collections import defaultdict
output_bam = work_dir / "synthetic.bam"
output_vcf = work_dir / "synthetic.vcf"
log(f"Loading reference {fasta_path}...", log_func)
fasta = pysam.FastaFile(str(fasta_path))
header = {
"HD": {"VN": "1.0", "SO": "unsorted"},
"SQ": [{"SN": chrom, "LN": length} for chrom, length in zip(fasta.references, fasta.lengths)],
"RG": [{"ID": rg_id, "SM": rg_sm, "PL": "ILLUMINA"}],
}
chrom_to_tid = {chrom: i for i, chrom in enumerate(fasta.references)}
groups = defaultdict(list)
variant_count = 0
log("Reading variant positions...", log_func)
with open(snvs_bed, "r") as f:
for line in f:
if line.startswith("#") or not line.strip():
continue
parts = line.strip().split("\t")
if len(parts) < 4:
continue
chrom = parts[0]
pos = int(parts[1])
label = parts[3]
group_key = "_".join(label.split("_")[:5])
try:
ref_base = fasta.fetch(chrom, pos, pos + 1).upper()
except Exception:
continue
if indel_interval > 0 and (variant_count + 1) % indel_interval == 0:
indel_len = random.randint(1, 4)
if random.choice(["INS", "DEL"]) == "INS":
ins_bases = "".join(random.choices(["A", "C", "G", "T"], k=indel_len))
ref_seq, alt_seq = ref_base, ref_base + ins_bases
else:
try:
ref_seq = fasta.fetch(chrom, pos, pos + indel_len + 1).upper()
except Exception:
ref_seq = ref_base
alt_seq = ref_base
else:
alt_bases = [b for b in ["A", "C", "G", "T"] if b != ref_base]
alt_seq = random.choice(alt_bases) if alt_bases else "A"
ref_seq = ref_base
groups[group_key].append({
"chrom": chrom,
"pos": pos,
"vcf_pos": pos + 1,
"ref": ref_seq,
"alt": alt_seq,
})
variant_count += 1
log(f"Generating read clusters across {len(groups)} regions...", log_func)
total_groups = len(groups)
with pysam.AlignmentFile(str(output_bam), "wb", header=header) as out_bam, \
open(output_vcf, "w") as vcf:
vcf.write("##fileformat=VCFv4.2\n")
vcf.write('##INFO=<ID=DP,Number=1,Type=Integer,Description="Target Depth">\n')
vcf.write('##INFO=<ID=AF,Number=A,Type=Float,Description="Target Allele Frequency">\n')
vcf.write("#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n")
for group_idx, (group_key, variants) in enumerate(groups.items()):
if progress_func:
progress_func(group_idx / total_groups, f"Processing region {group_idx+1}/{total_groups}")
chrom = variants[0]["chrom"]
if chrom not in fasta.references and f"chr{chrom}" in fasta.references:
chrom = f"chr{chrom}"
for v in variants:
v["chrom"] = chrom
if chrom not in fasta.references:
continue
tid = chrom_to_tid[chrom]
min_pos = min(v["pos"] for v in variants)
max_pos = max(v["pos"] for v in variants)
center = (min_pos + max_pos) // 2
sigma = max(30.0, (max_pos - min_pos) / 3.0)
variant_coverage = {v["pos"]: 0 for v in variants}
generated_pairs = []
if sequencing_mode == "pcr_amplicon":
# For PCR amplicon mode, fragments start and end exactly at target coordinates
parts = group_key.split("_")
try:
target_start = int(parts[3])
target_end = int(parts[4])
except (IndexError, ValueError):
target_start = min_pos - 50
target_end = max_pos + 50
tlen = target_end - target_start
fwd_start = target_start
current_read_length = tlen
rev_start = fwd_start
# All pairs in this region are identical
for _ in range(depth):
generated_pairs.append({
"fwd_start": fwd_start,
"rev_start": rev_start,
"tlen": tlen,
"alt_positions": set(),
"read_length": current_read_length,
})
else: # hybrid_capture mode
max_pairs = depth * 500
while len(generated_pairs) < max_pairs:
if all(cov >= depth for cov in variant_coverage.values()):
break
frag_center = int(random.gauss(center, sigma))
tlen = max(read_length + 10, int(random.gauss(insert_size, insert_std)))
frag_start = int(frag_center - tlen / 2)
frag_end = frag_start + tlen
fwd_start = frag_start
rev_start = frag_end - read_length
covers_any = False
for v in variants:
pos = v["pos"]
if (fwd_start <= pos < fwd_start + read_length) or (rev_start <= pos < rev_start + read_length):
variant_coverage[pos] += 1
covers_any = True
if covers_any or (min_pos - tlen <= frag_center <= max_pos + tlen):
generated_pairs.append({
"fwd_start": fwd_start,
"rev_start": rev_start,
"tlen": tlen,
"alt_positions": set(),
"read_length": read_length,
})
for v in variants:
pos = v["pos"]
covering_pairs = [
p for p in generated_pairs
if (p["fwd_start"] <= pos < p["fwd_start"] + p.get("read_length", read_length))
or (p["rev_start"] <= pos < p["rev_start"] + p.get("read_length", read_length))
]
num_alts = int(len(covering_pairs) * vaf)
for pair in random.sample(covering_pairs, num_alts):
pair["alt_positions"].add(pos)
if covering_pairs:
vcf.write(f"{chrom}\t{v['vcf_pos']}\t.\t{v['ref']}\t{v['alt']}\t.\tPASS\tDP={len(covering_pairs)};AF={vaf}\n")
for i, pair in enumerate(generated_pairs):
fwd_start = pair["fwd_start"]
rev_start = pair["rev_start"]
tlen = pair["tlen"]
current_read_length = pair.get("read_length", read_length)
active = [v for v in variants if v["pos"] in pair["alt_positions"]]
fwd_seq, fwd_cigar, fwd_nm = apply_mutations(fwd_start, current_read_length, fasta, chrom, active)
rev_seq, rev_cigar, rev_nm = apply_mutations(rev_start, current_read_length, fasta, chrom, active)
if not fwd_seq or not rev_seq:
continue
read_name = f"A00979:882:H7JWLDRX7:1:synth_{group_key}:{i}"
a_fwd = pysam.AlignedSegment()
a_fwd.query_name = read_name
a_fwd.query_sequence = fwd_seq
a_fwd.query_qualities = pysam.qualitystring_to_array("I" * current_read_length)
a_fwd.reference_id = tid
a_fwd.reference_start = fwd_start
a_fwd.mapping_quality = 60
a_fwd.cigartuples = fwd_cigar
a_fwd.next_reference_id = tid
a_fwd.next_reference_start = rev_start
a_fwd.template_length = tlen
a_fwd.set_tags([("RG", rg_id), ("MC", f"{current_read_length}M"), ("AS", current_read_length), ("NM", fwd_nm)])
a_rev = pysam.AlignedSegment()
a_rev.query_name = read_name
a_rev.query_sequence = rev_seq
a_rev.query_qualities = pysam.qualitystring_to_array("I" * current_read_length)
a_rev.reference_id = tid
a_rev.reference_start = rev_start
a_rev.mapping_quality = 60
a_rev.cigartuples = rev_cigar
a_rev.next_reference_id = tid
a_rev.next_reference_start = fwd_start
a_rev.template_length = -tlen
a_rev.set_tags([("RG", rg_id), ("MC", f"{current_read_length}M"), ("AS", current_read_length), ("NM", rev_nm)])
if random.choice([True, False]):
a_fwd.flag = 99
a_rev.flag = 147
else:
a_fwd.flag = 163
a_rev.flag = 83
out_bam.write(a_fwd)
out_bam.write(a_rev)
if progress_func:
progress_func(1.0, "Sorting and indexing BAM...")
sorted_bam = work_dir / "synthetic.sorted.bam"
run_cmd(f"samtools sort {output_bam} -o {sorted_bam}", log_func)
run_cmd(f"samtools index {sorted_bam}", log_func)
output_bam.unlink(missing_ok=True)
log("BAM and VCF generation complete.", log_func)
return sorted_bam, output_vcf