import argparse
import os
import random
import subprocess
import sys
from collections import defaultdict
GENOTYPE_SEP_CHARS = "|/"
def parse_args():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--vcf", required=True, help="GIAB stvar VCF (bgzipped, tabix-indexed)")
p.add_argument("--high-conf-bed", required=True, help="Benchmark high-confidence regions BED")
p.add_argument("--fai", help="Reference .fai, used to clip windows to chromosome bounds")
p.add_argument("--flank", type=int, default=500, help="Bases of context on each side of the SV")
p.add_argument("--min-len", type=int, default=50, help="Minimum |SVLEN| to include")
p.add_argument("--max-len", type=int, default=0, help="Maximum |SVLEN| to include (0 = no cap)")
p.add_argument(
"--svtype",
default="INS,DEL",
help="Comma-separated SVTYPE values to include (default: INS,DEL)",
)
p.add_argument(
"--include-ambiguous-gt",
action="store_true",
help="Also emit windows for no-call/hemizygous genotypes (excluded by default)",
)
p.add_argument("--max-windows", type=int, default=0, help="Cap on emitted windows (0 = no cap)")
p.add_argument("--seed", type=int, default=1, help="RNG seed used when --max-windows subsamples")
p.add_argument("--out-windows", required=True, help="Output windows.bed path")
p.add_argument("--out-truth", required=True, help="Output truth.tsv path")
p.add_argument("--bcftools", default="bcftools", help="bcftools binary (default: on PATH)")
return p.parse_args()
def load_high_conf_regions(bed_path):
regions = defaultdict(list)
with open(bed_path) as f:
for line in f:
line = line.rstrip("\n")
if not line or line.startswith("#"):
continue
fields = line.split("\t")
chrom, start, end = fields[0], int(fields[1]), int(fields[2])
regions[chrom].append((start, end))
for chrom in regions:
regions[chrom].sort()
return regions
def load_fai_lengths(fai_path):
lengths = {}
if not fai_path:
return lengths
if not os.path.isfile(fai_path):
print(f"warning: --fai path not found, skipping chromosome-bound clipping: {fai_path}", file=sys.stderr)
return lengths
with open(fai_path) as f:
for line in f:
fields = line.rstrip("\n").split("\t")
lengths[fields[0]] = int(fields[1])
return lengths
def fully_contained(regions_for_chrom, start, end):
for r_start, r_end in regions_for_chrom:
if r_start <= start and end <= r_end:
return True
if r_start > start:
break
return False
def classify_genotype(gt):
alleles = None
for sep in GENOTYPE_SEP_CHARS:
if sep in gt:
alleles = gt.split(sep)
break
if alleles is None:
return gt, None, "hemizygous"
a1, a2 = alleles[0], alleles[1]
if a1 == "." or a2 == ".":
return a1, a2, "no_call"
if a1 == a2:
return a1, a2, "hom"
return a1, a2, "het"
def query_vcf_records(vcf_path, bcftools_bin):
fmt = "%CHROM\t%POS\t%REF\t%ALT\t%INFO/SVTYPE\t%INFO/SVLEN\t[%GT]\n"
cmd = [bcftools_bin, "query", "-f", fmt, "-i", 'INFO/SVTYPE!="."', vcf_path]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True)
assert proc.stdout is not None
for line in proc.stdout:
fields = line.rstrip("\n").split("\t")
if len(fields) != 7:
continue
chrom, pos, ref, alt, svtype, svlen, gt = fields
yield chrom, int(pos), ref, alt, svtype, int(svlen), gt
ret = proc.wait()
if ret != 0:
sys.exit(f"bcftools query failed with exit code {ret}")
def main():
args = parse_args()
wanted_svtypes = set(args.svtype.split(","))
high_conf = load_high_conf_regions(args.high_conf_bed)
chrom_lengths = load_fai_lengths(args.fai)
kept = []
seen_names = set()
n_seen = 0
n_wrong_type_or_len = 0
n_ambiguous_gt = 0
n_not_contained = 0
for chrom, pos, ref, alt, svtype, svlen, gt in query_vcf_records(args.vcf, args.bcftools):
n_seen += 1
abs_len = abs(svlen)
if svtype not in wanted_svtypes or abs_len < args.min_len:
n_wrong_type_or_len += 1
continue
if args.max_len > 0 and abs_len > args.max_len:
n_wrong_type_or_len += 1
continue
a1, a2, genotype_class = classify_genotype(gt)
if genotype_class in ("no_call", "hemizygous") and not args.include_ambiguous_gt:
n_ambiguous_gt += 1
continue
bed_start = pos - 1
bed_end = bed_start + len(ref)
if not fully_contained(high_conf.get(chrom, []), bed_start, bed_end):
n_not_contained += 1
continue
window_start = max(0, bed_start - args.flank)
window_end = bed_end + args.flank
chrom_len = chrom_lengths.get(chrom)
if chrom_len is not None:
window_end = min(window_end, chrom_len)
name = f"{svtype}_{chrom}_{pos}_{abs_len}"
if name in seen_names:
name = f"{name}_{n_seen}"
seen_names.add(name)
kept.append(
{
"name": name,
"chrom": chrom,
"svtype": svtype,
"svlen": abs_len,
"ref_len": len(ref),
"alt_len": len(alt),
"genotype": gt,
"genotype_class": genotype_class,
"bed_start": bed_start,
"bed_end": bed_end,
"window_start": window_start,
"window_end": window_end,
}
)
if args.max_windows > 0 and len(kept) > args.max_windows:
rng = random.Random(args.seed)
kept = rng.sample(kept, args.max_windows)
kept.sort(key=lambda r: (r["chrom"], r["window_start"]))
with open(args.out_windows, "w") as bed_out:
for r in kept:
bed_out.write(f"{r['chrom']}\t{r['window_start']}\t{r['window_end']}\t{r['name']}\n")
truth_cols = [
"name",
"chrom",
"svtype",
"svlen",
"ref_len",
"alt_len",
"genotype",
"genotype_class",
"bed_start",
"bed_end",
"window_start",
"window_end",
]
with open(args.out_truth, "w") as truth_out:
truth_out.write("\t".join(truth_cols) + "\n")
for r in kept:
truth_out.write("\t".join(str(r[c]) for c in truth_cols) + "\n")
print(
f"scanned {n_seen} SV-classified records; kept {len(kept)}; "
f"dropped {n_wrong_type_or_len} (type/length), {n_ambiguous_gt} (ambiguous GT), "
f"{n_not_contained} (outside high-confidence regions)",
file=sys.stderr,
)
if __name__ == "__main__":
main()