614ca430db1c23f496ccc5655d27b469e1c53443 lrnassar Fri Jul 31 16:38:30 2026 -0700 Apply Cardiomyopathy VCEP feedback: BP7 splice-only, drop PS4 tracks, evidence-first Variant Evidence Summary. refs #37446 Per the CM VCEP's answers to the interpretation questions: - BP7: remove the conservation (phyloP) requirement and set the SpliceAI cutoff to < 0.1, per Walker 2023 (PMID 37352859). - PS4: remove the Walsh 2017 gene-level OR and Atlas EF display tracks (the VCEP computes PS4 from current cohorts). Scripts and data are retained in the tree; the tracks are dropped from the hub trackDb. - Rename the former provisional/computable-codes track to "Variant Evidence Summary" and switch its mouseover to an evidence-first layout: each item leads with the data and is tagged with the ACMG criterion it would support; no overall classification is computed. - Confirmed and reflected in the docs: EvRepo is the PS1/PM5 reference (Q1); the PM4 last-exon / within-50-nt rule (Q2); CardioBoost stays informational (Q6); the PM1 codon ranges are HCM-only (Q8); no functional-score dataset is currently VCEP-approved (Q4). diff --git src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPProvisionalClass.py src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPProvisionalClass.py index eead6c79071..0007e034422 100644 --- src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPProvisionalClass.py +++ src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPProvisionalClass.py @@ -1,561 +1,556 @@ #!/usr/bin/env python3 """ -B.11: Computable ACMG Criteria Summary track (NOT a VCEP classification). +B.11: Variant Evidence Summary track (NOT a VCEP classification). For every gnomAD-observed variant in the 8 cardiomyopathy gene CDS regions +/-20 nt -splice padding, lists the subset of ACMG/AMP evidence codes a hub can compute -automatically. No overall classification is calculated: +splice padding, lists, per variant, the computable evidence and the ACMG/AMP criterion +each item would support. No overall classification is calculated: - BA1 / BS1 / PM2_Supporting (gnomAD v4.1 FAF95; B.3) - PP3 / BP4 (REVEL; B.4; missense only, per hgVai consequence) - PM1 (B.1 hotspot regions; HCM-scoped; NOT combined with PM5) - PS1 / PM5 (EvRepo P/LP reference, LEAVE-ONE-OUT; see caveat below) - PM4 (NMD-escaping truncating variants, non-MYBPC3; CSpec disease-specific) - - BP7 (synonymous + SpliceAI no-impact + not conserved) - - Splice safety net (SpliceAI >= 0.20 overrides a benign-leaning call to VUS) + - BP7 (synonymous + SpliceAI < 0.1, per Walker 2023; conservation not required) + - Splice flag (SpliceAI >= 0.20 shown as possible splice impact; informational) - HCM/DCM tag (MYH7, TNNT2: PM1 is HCM-calibrated) CONSEQUENCE/CODON/AA come from the Phase-1 hgVai annotation TSV (cmpVCEPAnnotate). HONESTY / KNOWN LIMITS (surfaced to the VCEP, not hidden): * This mockup CANNOT compute the clinical/functional codes (PS2, PS3, PS4, PP1, PP4, BS3, BS4). Many true P/LP calls rest on those, so this track structurally under-calls pathogenicity. It is best read as a benign/VUS-axis + "flag for expert review" aid, NOT an accuracy claim. No concordance metric is asserted. - * PS1/PM5 reference set: the CSpec names NO database ("apply per Richards 2015"). - We use the VCEP EvRepo P/LP set with LEAVE-ONE-OUT (a variant cannot earn PS1/PM5 - from its own EvRepo entry). The choice of reference DB is an open VCEP question. - * PM4 strength (MOD vs SUP) and BP7 conservation metric/threshold are not fixed by - the CSpec - surfaced as VCEP questions; provisional choices are flagged in-line. + * PS1/PM5 reference set: the VCEP confirmed EvRepo as the source. We use the VCEP EvRepo + P/LP set with LEAVE-ONE-OUT (a variant cannot earn PS1/PM5 from its own EvRepo entry). + * BP7 is applied per the CM VCEP: SpliceAI < 0.1, no conservation requirement (Walker 2023, + PMID 37352859). PM4 strength (MOD vs SUP) remains a per-variant judgement per the CSpec. Outputs: cmpVCEPProvisionalClass/cmpVCEPProvisionalClass.as cmpVCEPProvisionalClass/cmpVCEPProvisionalClassHg{38,19}.bed + .bb """ import argparse, json, os, re, subprocess, sys from collections import defaultdict sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from cmpVCEPClinDomains import parse_mane_record OUR_GENES = ['MYH7', 'MYBPC3', 'TNNT2', 'TNNI3', 'TPM1', 'ACTC1', 'MYL2', 'MYL3'] WORKDIR = '/hive/users/lrnassar/claude/RM37446' B3_BED = f'{WORKDIR}/cmpVCEPAFfrequencies/cmpVCEPAFfrequenciesHg38.bed' B4_BED = f'{WORKDIR}/cmpVCEPRevel/cmpVCEPRevelHg38.bed' B1_BED = f'{WORKDIR}/cmpVCEPClinDomains/cmpVCEPClinDomainsHg38.bed' EVREPO_JSON = f'{WORKDIR}/cmp_downloads/erepo/cardiomyopathyVCEP_classifications.json' ANNOT_TSV = f'{WORKDIR}/cmpVCEPAnnotate/cmpVCEPAnnotations.hg38.tsv' SPLICEAI_BB = '/gbdb/hg38/bbi/spliceAi.bb' -PHYLOP_BW = '/gbdb/hg38/multiz470way/phyloP470way.bw' # Per-gene thresholds (from CSpec - NOT invented here) BS1_THRESHOLDS = {'MYBPC3': 0.0002} DEFAULT_BS1 = 0.0001 BA1_THRESHOLD = 0.001 PM2_SUPPORTING_THRESHOLD = 0.00004 -SPLICE_SAFETY_THRESHOLD = 0.20 # SpliceAI delta for safety-net override (standard recall threshold) -# --- provisional operationalizations the CSpec leaves open (flagged as VCEP questions) --- -BP7_SPLICE_MAX = 0.20 # SpliceAI "no predicted impact" (standard recall threshold) -BP7_PHYLOP_MAX = 0.0 # phyloP470way <= 0 == not under purifying selection (PROVISIONAL - VCEP to confirm metric+cutoff) +SPLICE_SAFETY_THRESHOLD = 0.20 # SpliceAI delta flagged as possible splice impact (informational) +# BP7: synonymous with no predicted splice impact. The CM VCEP set this at SpliceAI < 0.1 and +# removed the conservation requirement, per Walker 2023 (PMID 37352859). +BP7_SPLICE_MAX = 0.10 # NC_ accession (hg38) -> chrom, for parsing EvRepo genomic HGVS (leave-one-out keys) NC_HG38 = { 'NC_000001.11': 'chr1', 'NC_000003.12': 'chr3', 'NC_000011.10': 'chr11', 'NC_000012.12': 'chr12', 'NC_000014.9': 'chr14', 'NC_000015.10': 'chr15', 'NC_000019.10': 'chr19', } # Variants with established splicing impact - excluded from the PS1/PM5 reference per GN002 PS1. PS1_SPLICE_EXCLUDE = {'NM_000256.3:c.2308G>A'} NC_G_RE = re.compile(r'^(NC_\d+\.\d+):g\.(\d+)([ACGT]+)>([ACGT]+)$') PROT_MISSENSE_RE = re.compile(r'p\.([A-Z][a-z]{2})(\d+)([A-Z][a-z]{2})') AA3TO1 = { 'Ala': 'A', 'Arg': 'R', 'Asn': 'N', 'Asp': 'D', 'Cys': 'C', 'Gln': 'Q', 'Glu': 'E', 'Gly': 'G', 'His': 'H', 'Ile': 'I', 'Leu': 'L', 'Lys': 'K', 'Met': 'M', 'Phe': 'F', 'Pro': 'P', 'Ser': 'S', 'Thr': 'T', 'Trp': 'W', 'Tyr': 'Y', 'Val': 'V', 'Ter': '*', } TRACK_COLOR = '91,107,122' # neutral slate; no classification encoded (evidence-only track) 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 cmpVCEPProvisionalClass -"Computable ACMG criteria per variant (evidence summary; NOT a VCEP classification)" +"Variant evidence summary per variant (computable evidence + supported ACMG criteria; NOT a VCEP classification)" ( string chrom; "Chromosome" uint chromStart; "Position" uint chromEnd; "End" string name; "Display name" uint score; "0" char[1] strand; "Strand" uint thickStart; "Same" uint thickEnd; "Same" uint itemRgb; "Display color (neutral; no classification encoded)" string gene; "Gene" string refAllele; "Ref" string altAllele; "Alt" string variantKind; "Predicted consequence (hgVai)" - string appliedCodes; "Computable ACMG codes triggered (semicolon-separated, with strengths)" + string appliedCodes; "ACMG criteria supported by the evidence (semicolon-separated, with strengths)" string diseaseTag; "HCM/DCM phenotype scoping note (MYH7, TNNT2)" string codeNotes; "Suppressed/contested codes (e.g. PM5 not combined with PM1)" string splice_safety; "yes if SpliceAI >= 0.20 (possible splice impact; informational)" lstring _mouseOver; "Tooltip" ) """ # ============================================================ # Combination rules - transcribed verbatim from CSpec GN002 # ============================================================ # RETIRED 2026-07-08 (per CM VCEP chair L. Bronicki): this track no longer computes an overall # ACMG classification, only the computable codes that fire. The GN002 combining logic below is # kept for reference and is intentionally NOT called. def classify(codes): """Apply the Cardiomyopathy CSpec (GN002) combining rules. `codes` is an iterable of code strings carrying explicit strengths where relevant, e.g. {'PM1_Moderate', 'PP3_Supporting', 'PS1_Strong', 'BA1', 'BS1_Strong'}. Returns (classification, rule_match). Strong: PS1, PS2, PS3, PS4, PP1_Strong Moderate: PS3_Moderate, PS4_Moderate, PM1, PM4(_Moderate), PM5, PM6, PP1_Moderate Supporting: PS3_Supporting, PS4_Supporting, PM2_Supporting, PM5_Supporting, PP1, PP3, PM4_Supporting Benign: BA1 (stand-alone); BS1/BS3/BS4 (strong); BP4/BP7 (supporting) """ p_s = p_m = p_sup = 0 b_sa = b_s = b_sup = 0 for code in codes: c, _, strength = code.partition('_') if not strength: if c in ('PS1', 'PS2', 'PS3', 'PS4'): strength = 'Strong' elif c in ('PM1', 'PM5', 'PM6'): strength = 'Moderate' elif c in ('PM2', 'PM4'): strength = 'Supporting' # CSpec downgrades elif c in ('PP1', 'PP3'): strength = 'Supporting' elif c == 'BA1': strength = 'StandAlone' elif c in ('BS1', 'BS3', 'BS4'): strength = 'Strong' elif c in ('BP4', 'BP7'): strength = 'Supporting' else: continue if c[0] == 'P': if strength == 'Strong': p_s += 1 elif strength == 'Moderate': p_m += 1 elif strength == 'Supporting': p_sup += 1 else: if strength == 'StandAlone': b_sa += 1 elif strength == 'Strong': b_s += 1 elif strength == 'Supporting': b_sup += 1 # Benign side if b_sa >= 1: return 'Benign', 'BA1 stand-alone' if b_s >= 2: return 'Benign', f'{b_s} Strong-benign' if b_s == 1 and b_sup >= 1: return 'Likely Benign', f'1 Strong + {b_sup} Supporting (benign)' if b_sup >= 2: return 'Likely Benign', f'{b_sup} Supporting (benign)' # CSpec GN002 BS1 carve-out: "BS1 may only be used as standalone evidence to classify a # variant as Likely Benign in the absence of conflicting data." if b_s == 1 and (p_s + p_m + p_sup) == 0: return 'Likely Benign', 'BS1 standalone (CSpec carve-out; no conflicting pathogenic data)' # Pathogenic (CSpec GN002): >=2 S; 1S+>=3M; 1S+2M+>=2Sup; 1S+1M+>=4Sup if p_s >= 2: return 'Pathogenic', '>=2 Strong' if p_s == 1 and p_m >= 3: return 'Pathogenic', '1 Strong + >=3 Moderate' if p_s == 1 and p_m == 2 and p_sup >= 2: return 'Pathogenic', '1 Strong + 2 Moderate + >=2 Supporting' if p_s == 1 and p_m == 1 and p_sup >= 4: return 'Pathogenic', '1 Strong + 1 Moderate + >=4 Supporting' # Likely Pathogenic (CSpec GN002): 1S+1-2M; 1S+>=2Sup; >=3M; 2M+>=2Sup; 1M+>=4Sup if p_s == 1 and p_m >= 1: return 'Likely Pathogenic', f'1 Strong + {p_m} Moderate' if p_s == 1 and p_sup >= 2: return 'Likely Pathogenic', '1 Strong + >=2 Supporting' if p_m >= 3: return 'Likely Pathogenic', f'{p_m} Moderate' if p_m == 2 and p_sup >= 2: return 'Likely Pathogenic', '2 Moderate + >=2 Supporting' if p_m == 1 and p_sup >= 4: return 'Likely Pathogenic', '1 Moderate + >=4 Supporting' return 'Uncertain Significance', f'no rule fires (P:{p_s}S+{p_m}M+{p_sup}Sup; B:{b_sa}SA+{b_s}S+{b_sup}Sup)' # ============================================================ # Source loaders # ============================================================ def load_b3_variants(): rows = [] for line in open(B3_BED): f = line.rstrip('\n').split('\t') if len(f) < 18: continue rows.append({'chrom': f[0], 'start': int(f[1]), 'end': int(f[2]), 'strand': f[5], 'gene': f[9], 'ref': f[10], 'alt': f[11], 'faf95': float(f[12]), 'af_code': f[15]}) print(f' B.3 universe: {len(rows)} variants', file=sys.stderr) return rows def load_b4_revel_lookup(): lookup = {} for line in open(B4_BED): f = line.rstrip('\n').split('\t') if len(f) < 14: continue lookup[(f[0], int(f[1]), f[10])] = (f[12], f[11]) # (chrom,start,alt) -> (code, REVEL score) print(f' REVEL lookup: {len(lookup)} keys', file=sys.stderr) return lookup def load_pm1_intervals(): intervals = [] for line in open(B1_BED): f = line.rstrip('\n').split('\t') intervals.append((f[0], int(f[1]), int(f[2]))) print(f' PM1 intervals: {len(intervals)}', file=sys.stderr) return intervals def in_pm1_region(chrom, pos0, intervals): for c, s, e in intervals: if c == chrom and s <= pos0 < e: return True return False def load_annotation(): """Phase-1 hgVai annotations keyed by (chrom, pos1, ref, alt).""" ann = {} with open(ANNOT_TSV) as fh: for line in fh: if line.startswith('#'): continue f = line.rstrip('\n').split('\t') (chrom, pos, ref, alt, gene, so, ppos, aaref, aaalt, codon, exn, ext, hgvsp, cdna) = f[:14] ann[(chrom, int(pos), ref, alt)] = { 'gene': gene, 'so': set(so.split(',')) if so else set(), 'codon': int(ppos) if ppos.isdigit() else None, 'aaRef': aaref, 'aaAlt': aaalt, 'exonNum': int(exn) if exn.isdigit() else None, 'exonTotal': int(ext) if ext.isdigit() else None, 'hgvsp': hgvsp, 'cdnaPos': int(cdna) if cdna.isdigit() else None, } print(f' annotations: {len(ann)}', file=sys.stderr) return ann def load_evrepo_reference(): """EvRepo P/LP MISSENSE reference for PS1/PM5, each with its genomic key (for leave-one-out). Returns list of dicts {gkey, gene, codon, alt_aa1}.""" data = json.load(open(EVREPO_JSON)) ref = [] for v in data['variantInterpretations']: outcome = v['guidelines'][0]['outcome']['label'] if outcome not in ('Pathogenic', 'Likely Pathogenic'): continue gene = v['gene']['label'] hgvs_list = v['hgvs'] # CSpec GN002 PS1 caveat: variants with an established splicing impact must NOT seed # PS1/PM5 for other variants with the same amino-acid change (the named example is # MYBPC3 c.2308G>A p.Asp770Asn). Exclude such entries from the reference. if any(s in h for h in hgvs_list for s in PS1_SPLICE_EXCLUDE): continue # genomic key (hg38) from NC_ HGVS gkey = None for h in hgvs_list: m = NC_G_RE.match(h) if m and m.group(1) in NC_HG38: gkey = (NC_HG38[m.group(1)], int(m.group(2)), m.group(3), m.group(4)) break # protein change (missense) from p. HGVS codon = alt_aa1 = None for h in hgvs_list: m = PROT_MISSENSE_RE.search(h) if m and m.group(3) in AA3TO1: codon = int(m.group(2)) alt_aa1 = AA3TO1[m.group(3)] break if codon is not None and alt_aa1 is not None: ref.append({'gkey': gkey, 'gene': gene, 'codon': codon, 'alt_aa1': alt_aa1}) print(f' EvRepo P/LP missense reference: {len(ref)} entries', file=sys.stderr) return ref def batch_spliceai(regions): """Per gene region: (chrom, pos1, ref, alt) -> max SpliceAI delta (bed9+4: AIscore=col9, name='ref>alt').""" sa = {} for chrom, start, end in regions: try: out = subprocess.check_output(['bigBedToBed', f'-chrom={chrom}', f'-start={start}', f'-end={end}', SPLICEAI_BB, 'stdout'], text=True, stderr=subprocess.DEVNULL) except subprocess.CalledProcessError: continue for line in out.splitlines(): f = line.split('\t') if len(f) < 10 or '>' not in f[3]: continue ref, alt = f[3].split('>', 1) pos1 = int(f[2]) # chromEnd == 1-based SNV pos try: score = float(f[9]) except ValueError: continue k = (chrom, pos1, ref, alt) if score > sa.get(k, -1): sa[k] = score print(f' SpliceAI entries: {len(sa)}', file=sys.stderr) return sa -def batch_phylop(regions): - """Per gene region: (chrom, pos1) -> phyloP470way value.""" - pp = {} - for chrom, start, end in regions: - try: - out = subprocess.check_output(['bigWigToBedGraph', PHYLOP_BW, 'stdout', - f'-chrom={chrom}', f'-start={start}', f'-end={end}'], - text=True, stderr=subprocess.DEVNULL) - except subprocess.CalledProcessError: - continue - for line in out.splitlines(): - c, s, e, val = line.split('\t') - s, e, val = int(s), int(e), float(val) - for p0 in range(s, e): - pp[(chrom, p0 + 1)] = val # 1-based - print(f' phyloP positions: {len(pp)}', file=sys.stderr) - return pp - - TRUNCATING_SO = {'stop_gained', 'frameshift_variant'} def variant_kind(so): """Most-relevant consequence label for display.""" for k in ('stop_gained', 'frameshift_variant', 'stop_lost', 'splice_acceptor_variant', 'splice_donor_variant', 'missense_variant', 'inframe_deletion', 'inframe_insertion', 'initiator_codon_variant', 'splice_region_variant', 'synonymous_variant', 'intron_variant', '5_prime_UTR_variant', '3_prime_UTR_variant'): if k in so: return k return ','.join(sorted(so)) if so else 'unknown' def main(): ap = argparse.ArgumentParser() ap.add_argument('--db', action='append', required=True, choices=['hg38', 'hg19']) ap.add_argument('--output-dir', required=True) - ap.add_argument('--no-spliceai', action='store_true', help='Skip SpliceAI/BP7-conservation (debug only)') + ap.add_argument('--no-spliceai', action='store_true', help='Skip SpliceAI (debug only)') args = ap.parse_args() out_dir = os.path.join(args.output_dir, 'cmpVCEPProvisionalClass') os.makedirs(out_dir, exist_ok=True) - print(' [B.11 Computable ACMG Criteria Summary]') + print(' [B.11 Variant Evidence Summary]') b3 = load_b3_variants() revel = load_b4_revel_lookup() pm1 = load_pm1_intervals() ann = load_annotation() evref = load_evrepo_reference() regions = [] nmd_junction = {} # gene -> cDNA coord of the last exon-exon junction (transcript len - last exon len) for gene in OUR_GENES: m = parse_mane_record(gene) regions.append((m['chrom'], m['chromStart'], m['chromEnd'])) bs = m['blockSizes'] last_exon = bs[0] if m['strand'] == '-' else bs[-1] # 3'-most transcript exon nmd_junction[gene] = sum(bs) - last_exon - if args.no_spliceai: - spliceai, phylop = {}, {} - else: - spliceai = batch_spliceai(regions) - phylop = batch_phylop(regions) + spliceai = {} if args.no_spliceai else batch_spliceai(regions) n_features = 0 code_counts = defaultdict(int) bed_lines = [] for v in b3: gene = v['gene'] if gene not in OUR_GENES: continue chrom, pos1, ref, alt = v['chrom'], v['start'] + 1, v['ref'], v['alt'] a = ann.get((chrom, pos1, ref, alt), {}) so = a.get('so', set()) is_missense = 'missense_variant' in so is_synonymous = 'synonymous_variant' in so codes = set() - code_why = {} notes = [] faf = v['faf95'] + # captured evidence values for the evidence-first mouseover + af_ev = revel_ev = pm4_ev = None + ps1_codon = pm5_codon = None # gnomAD AF (B.3) if v['af_code'] == 'BA1': codes.add('BA1') - code_why['BA1'] = f'gnomAD FAF95 (popmax) {faf:.2e} ≥ 0.001' + af_ev = ('BA1', f'FAF95 {faf:.2e}, ≥ 0.001') elif v['af_code'] == 'BS1': codes.add('BS1_Strong') thr = '0.0002' if gene == 'MYBPC3' else '0.0001' - code_why['BS1_Strong'] = f'gnomAD FAF95 (popmax) {faf:.2e} ≥ {thr}' + af_ev = ('BS1_Strong', f'FAF95 {faf:.2e}, ≥ {thr}') elif v['af_code'] == 'PM2_supporting': codes.add('PM2_Supporting') - code_why['PM2_Supporting'] = f'gnomAD FAF95 (popmax) {faf:.2e} ≤ 4e-05 (rare)' + af_ev = ('PM2_Supporting', f'FAF95 {faf:.2e}, rare (≤ 4e-05)') # REVEL PP3/BP4 - missense only if is_missense: rc = revel.get((chrom, v['start'], alt)) if rc: code, score = rc codes.add(code) thr = '≥ 0.70' if code.startswith('PP3') else '≤ 0.40' - code_why[code] = f'REVEL {score} ({thr})' + revel_ev = (code, f'{score} ({thr})') # PM1 hotspot (HCM-calibrated) pm1_hit = in_pm1_region(chrom, v['start'], pm1) if pm1_hit: codes.add('PM1_Moderate') - code_why['PM1_Moderate'] = f'in the {gene} PM1 hotspot region (HCM-calibrated)' # PS1 / PM5 - EvRepo P/LP reference, LEAVE-ONE-OUT (exclude self by genomic key) if is_missense and a.get('codon') and a.get('aaAlt'): codon, aaalt = a['codon'], a['aaAlt'] gkey = (chrom, pos1, ref, alt) ps1 = any(e['gene'] == gene and e['codon'] == codon and e['alt_aa1'] == aaalt and e['gkey'] != gkey for e in evref) pm5 = any(e['gene'] == gene and e['codon'] == codon and e['alt_aa1'] != aaalt and e['gkey'] != gkey for e in evref) if ps1: codes.add('PS1_Strong') - code_why['PS1_Strong'] = f'same amino-acid change as a VCEP EvRepo P/LP variant at codon {codon}' + ps1_codon = codon if pm5: codes.add('PM5_Moderate') - code_why['PM5_Moderate'] = f'a different missense at codon {codon} is classified P/LP in the VCEP EvRepo set' + pm5_codon = codon # PM4 - NMD-escaping truncating, non-MYBPC3 (CSpec disease-specific; PVS1 N/A for these genes). # NMD escapes if the PTC is in the last exon OR within 50 nt of the last exon-exon junction # (J = transcript length - last exon length); cDNA position is in transcript orientation. if gene != 'MYBPC3' and (so & TRUNCATING_SO): cdna, J = a.get('cdnaPos'), nmd_junction.get(gene) nmd_escape = (a.get('exonNum') is not None and a.get('exonNum') == a.get('exonTotal')) \ or (cdna is not None and J is not None and cdna > J - 50) if nmd_escape: codes.add('PM4_Supporting') last = a.get('exonNum') == a.get('exonTotal') where = 'last exon' if last else 'within 50 nt of the last exon-exon junction' - code_why['PM4_Supporting'] = f'NMD-escaping truncating variant ({where})' + pm4_ev = f'truncating, escapes NMD ({where})' elif gene != 'MYBPC3' and 'stop_lost' in so: codes.add('PM4_Supporting') - code_why['PM4_Supporting'] = 'stop-loss variant' + pm4_ev = 'stop-loss variant' - # BP7 - synonymous + SpliceAI no-impact + not conserved (conservation cutoff PROVISIONAL) + # BP7 - synonymous with no predicted splice impact (SpliceAI < 0.1, per Walker 2023 + # PMID 37352859). The CM VCEP removed the conservation requirement, so no phyloP gate. sa_score = spliceai.get((chrom, pos1, ref, alt), 0.0) if is_synonymous and not args.no_spliceai: - phy = phylop.get((chrom, pos1)) - if sa_score < BP7_SPLICE_MAX and phy is not None and phy <= BP7_PHYLOP_MAX: + if sa_score < BP7_SPLICE_MAX: codes.add('BP7_Supporting') - code_why['BP7_Supporting'] = (f'synonymous; SpliceAI {sa_score:.2f} < {BP7_SPLICE_MAX}; ' - f'phyloP {phy:.2f} ≤ 0 (conservation cutoff provisional)') # CSpec exclusion: PM1 must NOT be combined with PM5. GN002 PM5: "use of PM5 is most # appropriate since it is variant specific" -> keep PM5, drop PM1. + pm1_suppressed = False if 'PM1_Moderate' in codes and 'PM5_Moderate' in codes: codes.discard('PM1_Moderate') - notes.append('PM1 suppressed: CSpec says PM5 (variant-specific) is preferred over PM1') - # CSpec is silent on PM1+PS1; flag as possible double-count for VCEP (do not suppress) + pm1_suppressed = True + # CSpec is silent on PM1+PS1; flag as a possible double-count (do not suppress). if 'PM1_Moderate' in codes and 'PS1_Strong' in codes: - notes.append('Note: PM1+PS1 co-occur — possible double-counting (VCEP question)') + notes.append('PM1 and PS1 co-occur here, a possible double-count (open VCEP question)') # Splice signal (informational): SpliceAI >= 0.20 flags possible splice impact. - # No longer overrides a call - this track computes no overall classification. - splice_safety = 'no' - if sa_score >= SPLICE_SAFETY_THRESHOLD: - notes.append(f'Splice flag: SpliceAI {sa_score:.2f} >= {SPLICE_SAFETY_THRESHOLD} (possible splice impact; informational)') - splice_safety = 'yes' + splice_safety = 'yes' if sa_score >= SPLICE_SAFETY_THRESHOLD else 'no' n_features += 1 for c in codes: code_counts[c] += 1 color = TRACK_COLOR disease_tag = '' if gene in ('MYH7', 'TNNT2'): disease_tag = 'PM1 HCM-calibrated' if pm1_hit else 'HCM/DCM' applied_str = ';'.join(sorted(codes)) or 'no codes' - notes_str = ' | '.join(notes) kind = variant_kind(so) - mo = [f'Computable ACMG Criteria Summary (NOT a VCEP classification)
', - f'{gene} {chrom}:{pos1} {ref}>{alt}'] + # ---- evidence-first mouseover: the data leads; the ACMG code is a subordinate tag ---- + ev = [] + if af_ev: + ev.append(f'Population frequency (gnomAD v4.1): {af_ev[1]} → supports {af_ev[0]}') + if revel_ev: + ev.append(f'Missense predictor (REVEL): {revel_ev[1]} → supports {revel_ev[0]}') + if ps1_codon: + ev.append(f'Known variants at residue {ps1_codon} (EvRepo): the same amino-acid change is ' + f'VCEP P/LP → supports PS1_Strong') + if pm5_codon: + ev.append(f'Known variants at residue {pm5_codon} (EvRepo): a different VCEP P/LP change ' + f'exists at this residue → supports PM5_Moderate') + if pm1_hit: + if pm1_suppressed: + ev.append(f'PM1 hotspot region: within the {gene} hotspot (HCM only); not counted here, ' + f'PM5 (variant-specific) is preferred per CSpec') + else: + ev.append(f'PM1 hotspot region: within the {gene} hotspot (HCM only) → supports PM1_Moderate') + if pm4_ev: + ev.append(f'Protein-truncating: {pm4_ev} → supports PM4_Supporting') + if 'BP7_Supporting' in codes: + ev.append(f'Synonymous, splicing (SpliceAI): {sa_score:.2f}, no predicted impact ' + f'(≤ 0.1, per Walker 2023) → supports BP7_Supporting') + if splice_safety == 'yes': + ev.append(f'Splicing (SpliceAI): {sa_score:.2f}, possible splice impact (informational)') + + mo = ['Variant evidence (not a VCEP classification)
', + f'{gene} · {chrom}:{pos1} {ref}>{alt}'] if a.get('hgvsp'): - mo.append(f'  {a["hgvsp"]}') - mo.append(f'
Consequence: {kind}
') - if codes: - mo.append('Computable codes triggered:
') - for c in sorted(codes): - why = code_why.get(c, '') - mo.append(f'  {c}: {why}
' if why else f'  {c}
') + mo.append(f' · {a["hgvsp"]}') + mo.append(f' · {kind}
') + if ev: + for line in ev: + mo.append(line + '
') else: - mo.append('Computable codes triggered: none
') - if notes_str: - mo.append(f'{notes_str}
') - mo.append('
Shows only the computable ACMG codes that are triggered. Excludes clinical/functional ' - 'PS2/PS3/PS4/PP1/PP4/BS3/BS4.') + mo.append('No computable evidence at this position.
') + if notes: + mo.append('' + ' | '.join(notes) + '
') + mo.append('
Shows the computable evidence and the ACMG criteria it would support. ' + 'Not a VCEP review or classification; clinical/functional evidence ' + '(PS2/PS3/PS4/PP1/PP4/BS3/BS4) is not included.') mouseover = ''.join(mo) name = f'{gene}_{pos1}_{ref}>{alt}' bed_lines.append('\t'.join([ chrom, str(v['start']), str(v['end']), name, '0', v['strand'], str(v['start']), str(v['end']), color, gene, ref, alt, kind, - applied_str, disease_tag, notes_str, splice_safety, mouseover, + applied_str, disease_tag, ' | '.join(notes), splice_safety, mouseover, ])) print(f' features: {n_features}') print(f' code firing counts: {dict(sorted(code_counts.items()))}') bed_lines.sort(key=lambda l: (l.split('\t')[0], int(l.split('\t')[1]))) as_path = os.path.join(out_dir, 'cmpVCEPProvisionalClass.as') with open(as_path, 'w') as f: f.write(AUTOSQL) hg38_bed = os.path.join(out_dir, 'cmpVCEPProvisionalClassHg38.bed') with open(hg38_bed, 'w') as f: f.write('\n'.join(bed_lines) + '\n') print(f' wrote {len(bed_lines)} BED features -> {hg38_bed}') if 'hg38' in args.db: hg38_bb = os.path.join(out_dir, 'cmpVCEPProvisionalClassHg38.bb') subprocess.run(['bedToBigBed', '-tab', '-type=bed9+9', '-as=' + as_path, hg38_bed, CHROM_SIZES['hg38'], hg38_bb], check=True) print(f' hg38 bigBed: {hg38_bb}') if 'hg19' in args.db: hg19_bed = os.path.join(out_dir, 'cmpVCEPProvisionalClassHg19.bed') unmapped = hg19_bed + '.unmapped' subprocess.run(['liftOver', '-bedPlus=9', '-tab', hg38_bed, LIFTOVER_HG38_TO_HG19, hg19_bed, unmapped], check=True) if os.path.getsize(unmapped) > 0: n = sum(1 for line in open(unmapped) if not line.startswith('#')) print(f' WARNING: {n} unmapped in hg19 liftOver: {unmapped}', file=sys.stderr) hg19_bb = os.path.join(out_dir, 'cmpVCEPProvisionalClassHg19.bb') subprocess.run(['bedToBigBed', '-tab', '-type=bed9+9', '-as=' + as_path, hg19_bed, CHROM_SIZES['hg19'], hg19_bb], check=True) print(f' hg19 bigBed: {hg19_bb}') if __name__ == '__main__': main()