aa5669fe641fb39d7711eb81ec05092d14f267fb lrnassar Tue Jun 30 15:20:56 2026 -0700 Adding Cardiomyopathy VCEP track hub build scripts and makedoc. refs #37446 Adds the 12 per-track build scripts under src/hg/makeDb/scripts/cardiomyopathyVCEP/ (gnomAD v4.1 allele frequencies, REVEL, CardioBoost, the hgVai consequence/HGVSp annotation layer, ClinGen EvRepo, ClinVar submitter 506161, Walsh 2019 curations, PM1 clinical-domain hotspots, MYBPC3 PVS1 caveats, Walsh 2017 PS4 odds-ratio track, Atlas PS4 per-variant OR, and the NON-FINAL provisional classifier) plus the build documentation at src/hg/makeDb/doc/Cardiomyopathy.txt. All ACMG thresholds are taken directly from the ClinGen Cardiomyopathy CSpecs (8 genes, affiliation 50002); no thresholds are invented in the build. diff --git src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPAFfrequencies.py src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPAFfrequencies.py new file mode 100644 index 00000000000..fcd524866fa --- /dev/null +++ src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPAFfrequencies.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +B.3 — gnomAD v4.1 Allele Frequencies track builder. + +For each variant in the 8 cardiomyopathy gene coding regions (±20 nt splice padding), +parse gnomAD v4.1 exomes for the per-variant FAF95 (filtering allele frequency, 95% CI +lower bound, popmax) and apply per-gene CSpec thresholds: + + BA1 if FAF95 >= 0.001 (all 8 genes) + BS1 if FAF95 >= 0.0001 (≥0.0002 for MYBPC3 only) + PM2_supporting if FAF95 <= 0.00004 + no-code otherwise (still emitted, useful baseline) + +Source: /hive/data/outside/gnomAD.4/v4.1/exomes/gnomad.exomes.v4.1.sites.chr{N}.vcf.bgz +Field: fafmax_faf95_max (max FAF95 across genetic ancestry groups) + +Outputs: + cmpVCEPAFfrequencies/cmpVCEPAFfrequencies.as + cmpVCEPAFfrequencies/cmpVCEPAFfrequenciesHg{38,19}.bed + .bb +""" + +import argparse, os, re, subprocess, sys + +OUR_GENES = ['MYH7', 'MYBPC3', 'TNNT2', 'TNNI3', 'TPM1', 'ACTC1', 'MYL2', 'MYL3'] + +# Gene-specific BS1 threshold (per CSpec — MYBPC3 outlier) +BS1_THRESHOLDS = { + 'MYBPC3': 0.0002, +} +DEFAULT_BS1 = 0.0001 +BA1_THRESHOLD = 0.001 +PM2_SUPPORTING_THRESHOLD = 0.00004 +SPLICE_PADDING = 20 # nt up/downstream of CDS exons for splice-region inclusion + +GNOMAD_VCF_PATTERN = '/hive/data/outside/gnomAD.4/v4.1/exomes/gnomad.exomes.v4.1.sites.{chrom}.vcf.bgz' + +TABIX = '/cluster/bin/x86_64/tabix' + +# Colors (matching plan) +COLORS = { + 'BA1': '0,160,0', # dark green — strong benign frequency + 'BS1': '120,200,120', # light green — benign frequency + 'PM2_supporting': '250,160,160', # salmon — rarity (weak pathogenic). Salmon (not the old + # fuchsia) keeps AF distinct; PM1 regions use magenta-rose 230,3,131. + 'no-code': '180,180,180', # light gray — no AF-based code +} + +CHROM_SIZES = { + 'hg38': '/cluster/data/hg38/chrom.sizes', + 'hg19': '/cluster/data/hg19/chrom.sizes', +} + +LIFTOVER_HG38_TO_HG19 = '/cluster/data/hg38/bed/liftOver/hg38ToHg19.over.chain.gz' + +AUTOSQL = """table cmpVCEPAFfrequencies +"gnomAD v4.1 allele frequencies in cardiomyopathy gene coding regions, with applied ACMG codes" + ( + string chrom; "Chromosome" + uint chromStart; "Position (BED 0-based)" + uint chromEnd; "End" + string name; "Display name (gene + REF>ALT + code)" + uint score; "0" + char[1] strand; "Strand" + uint thickStart; "Same as chromStart" + uint thickEnd; "Same as chromEnd" + uint itemRgb; "Color by applied code" + string gene; "Gene symbol" + string refAllele; "Reference allele" + string altAllele; "Alternate allele" + double faf95; "fafmax_faf95_max from gnomAD v4.1" + double af; "Overall AF" + string grpmax; "Population with max AF" + string acmgCode; "Applied ACMG code: BA1 / BS1 / PM2_supporting / no-code" + string thresholdNote; "BS1 threshold for this gene (0.0002 MYBPC3, else 0.0001)" + lstring _mouseOver; "Tooltip HTML" + ) +""" + +# Re-use B.1's MANE parsing +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from cmpVCEPClinDomains import parse_mane_record, cds_exons + + +def parse_vcf_info(info_str): + """Parse VCF INFO field into dict. Returns dict[key] = first-value-as-string.""" + result = {} + for kv in info_str.split(';'): + if '=' in kv: + k, v = kv.split('=', 1) + result[k] = v + return result + + +def fetch_gene_variants(gene, mane): + """tabix-query gnomAD VCF for gene CDS region (±SPLICE_PADDING). Returns list of variant dicts.""" + chrom = mane['chrom'] + exons = cds_exons(mane) + # Build region list: each CDS exon ± SPLICE_PADDING + regions = [] + for ex_start, ex_end in exons: + regions.append((max(0, ex_start - SPLICE_PADDING), ex_end + SPLICE_PADDING)) + # Merge overlapping regions + regions.sort() + merged = [] + for s, e in regions: + if merged and s <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], e)) + else: + merged.append((s, e)) + + vcf = GNOMAD_VCF_PATTERN.format(chrom=chrom) + variants = [] + for s, e in merged: + # tabix uses 1-based inclusive; bed is 0-based half-open + region = f'{chrom}:{s+1}-{e}' + try: + out = subprocess.check_output([TABIX, vcf, region], + text=True, stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError: + continue + for line in out.splitlines(): + if line.startswith('#'): + continue + f = line.split('\t') + if len(f) < 8: + continue + pos1 = int(f[1]) + ref = f[3] + alt = f[4] + filt = f[6] + info = parse_vcf_info(f[7]) + # Skip non-PASS variants (filtered) + if filt != 'PASS': + continue + # FAF95 popmax + faf95 = float(info.get('fafmax_faf95_max', '0') or '0') + af = float(info.get('AF', '0') or '0') + grpmax = info.get('grpmax', '') + variants.append({ + 'chrom': chrom, + 'pos': pos1, + 'ref': ref, + 'alt': alt, + 'faf95': faf95, + 'af': af, + 'grpmax': grpmax, + }) + return variants + + +def apply_code(faf95, gene): + """Return (acmgCode, color) for the given FAF95 + gene.""" + bs1_thresh = BS1_THRESHOLDS.get(gene, DEFAULT_BS1) + if faf95 >= BA1_THRESHOLD: + return 'BA1', COLORS['BA1'], bs1_thresh + if faf95 >= bs1_thresh: + return 'BS1', COLORS['BS1'], bs1_thresh + if faf95 <= PM2_SUPPORTING_THRESHOLD: + return 'PM2_supporting', COLORS['PM2_supporting'], bs1_thresh + return 'no-code', COLORS['no-code'], bs1_thresh + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--db', action='append', required=True, choices=['hg38', 'hg19']) + ap.add_argument('--output-dir', required=True) + args = ap.parse_args() + + out_dir = os.path.join(args.output_dir, 'cmpVCEPAFfrequencies') + os.makedirs(out_dir, exist_ok=True) + + print(' [B.3 gnomAD v4.1 Allele Frequencies]') + print(f' thresholds: BA1≥{BA1_THRESHOLD}, BS1≥{DEFAULT_BS1} (or 0.0002 MYBPC3), PM2_sup≤{PM2_SUPPORTING_THRESHOLD}') + + bed_lines = [] + counts = {'BA1': 0, 'BS1': 0, 'PM2_supporting': 0, 'no-code': 0} + + for gene in OUR_GENES: + mane = parse_mane_record(gene) + print(f' fetching {gene} ({mane["chrom"]} {mane["strand"]})...') + variants = fetch_gene_variants(gene, mane) + print(f' {len(variants)} PASS variants in CDS ±{SPLICE_PADDING} nt') + + for v in variants: + code, color, bs1_thresh = apply_code(v['faf95'], gene) + counts[code] += 1 + start_bed = v['pos'] - 1 + end_bed = v['pos'] - 1 + len(v['ref']) + mouseover = ( + f'gnomAD v4.1 - {code}
' + f'{gene} {v["chrom"]}:{v["pos"]} {v["ref"]}>{v["alt"]}
' + f'FAF95 (popmax): {v["faf95"]:.2e}
' + f'AF (overall): {v["af"]:.2e}
' + f'Popmax group: {v["grpmax"]}
' + f'CSpec thresholds for {gene}: BA1≥{BA1_THRESHOLD}, BS1≥{bs1_thresh}, PM2_sup≤{PM2_SUPPORTING_THRESHOLD}' + ) + name = f'{gene}_{v["ref"]}>{v["alt"]}_{code[:6]}' + bed_lines.append('\t'.join([ + v['chrom'], str(start_bed), str(end_bed), + name, '0', mane['strand'], + str(start_bed), str(end_bed), color, + gene, + v['ref'], + v['alt'], + f'{v["faf95"]:.6e}', + f'{v["af"]:.6e}', + v['grpmax'], + code, + f'BS1≥{bs1_thresh}', + mouseover, + ])) + + print(f' total counts: {counts}') + + bed_lines.sort(key=lambda l: (l.split('\t')[0], int(l.split('\t')[1]))) + + as_path = os.path.join(out_dir, 'cmpVCEPAFfrequencies.as') + with open(as_path, 'w') as f: + f.write(AUTOSQL) + + hg38_bed = os.path.join(out_dir, 'cmpVCEPAFfrequenciesHg38.bed') + with open(hg38_bed, 'w') as f: + for l in bed_lines: + f.write(l + '\n') + print(f' wrote {len(bed_lines)} BED features → {hg38_bed}') + + if 'hg38' in args.db: + hg38_bb = os.path.join(out_dir, 'cmpVCEPAFfrequenciesHg38.bb') + cmd = ['bedToBigBed', '-tab', '-type=bed9+9', '-as=' + as_path, + hg38_bed, CHROM_SIZES['hg38'], hg38_bb] + print(f' $ {" ".join(cmd)}') + subprocess.run(cmd, check=True) + print(f' hg38 bigBed: {hg38_bb}') + + if 'hg19' in args.db: + hg19_bed = os.path.join(out_dir, 'cmpVCEPAFfrequenciesHg19.bed') + unmapped = hg19_bed + '.unmapped' + cmd = ['liftOver', '-bedPlus=9', '-tab', hg38_bed, LIFTOVER_HG38_TO_HG19, hg19_bed, unmapped] + print(f' $ {" ".join(cmd)}') + subprocess.run(cmd, check=True) + if os.path.getsize(unmapped) > 0: + n_unmapped = sum(1 for line in open(unmapped) if not line.startswith('#')) + print(f' WARNING: {n_unmapped} unmapped: {unmapped}', file=sys.stderr) + hg19_bb = os.path.join(out_dir, 'cmpVCEPAFfrequenciesHg19.bb') + cmd = ['bedToBigBed', '-tab', '-type=bed9+9', '-as=' + as_path, + hg19_bed, CHROM_SIZES['hg19'], hg19_bb] + print(f' $ {" ".join(cmd)}') + subprocess.run(cmd, check=True) + print(f' hg19 bigBed: {hg19_bb}') + + +if __name__ == '__main__': + main()