6577d5ee1319bbea85988c1c89179436b4a94edf lrnassar Tue Jul 14 11:27:59 2026 -0700 Address code-review feedback on the Cardiomyopathy VCEP build scripts. refs #37446 - cmpVCEPCardioBoost.py: add the standard --db/--output-dir CLI. It previously hardcoded the working directory for both its input TSV and its output (unlike the 11 sibling scripts, and contrary to the makedoc's documented interface); the build loop's flags were silently ignored. Output is unchanged (31,236 variants per assembly). - Decode leftover HTML entities (arrows, >=, <=, +/-, x) in print/stderr diagnostics, comments, and docstrings across all scripts so build logs read cleanly. The mouseOver / bigBed display strings intentionally keep their entities. - cmpVCEPWalsh2019.py: fix the stale docstring that described the ClinVar-unmatched entries as "deferred" (they are mapped via the hgvsToVcf fallback, item L) and drop the unverified "163 rows" count. Per code-review feedback on commit aa5669fe64. No track data changed. diff --git src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPProvisionalClass.py src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPProvisionalClass.py index d712286d124..eead6c79071 100644 --- src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPProvisionalClass.py +++ src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPProvisionalClass.py @@ -1,83 +1,83 @@ #!/usr/bin/env python3 """ B.11: Computable ACMG Criteria Summary track (NOT a VCEP classification). -For every gnomAD-observed variant in the 8 cardiomyopathy gene CDS regions ±20 nt +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: - 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) + - Splice safety net (SpliceAI >= 0.20 overrides a benign-leaning call to VUS) - 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. + the CSpec - surfaced as VCEP questions; provisional choices are flagged in-line. 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) +# 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) +BP7_PHYLOP_MAX = 0.0 # phyloP470way <= 0 == not under purifying selection (PROVISIONAL - VCEP to confirm metric+cutoff) -# NC_ accession (hg38) → chrom, for parsing EvRepo genomic HGVS (leave-one-out keys) +# 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. +# 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' @@ -96,31 +96,31 @@ 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 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 +# 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) @@ -402,96 +402,96 @@ notes = [] faf = v['faf95'] # gnomAD AF (B.3) if v['af_code'] == 'BA1': codes.add('BA1') code_why['BA1'] = f'gnomAD FAF95 (popmax) {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}' elif v['af_code'] == 'PM2_supporting': codes.add('PM2_Supporting') code_why['PM2_Supporting'] = f'gnomAD FAF95 (popmax) {faf:.2e} ≤ 4e-05 (rare)' - # REVEL PP3/BP4 — missense only + # 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})' # 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) + # 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}' 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' - # PM4 — NMD-escaping truncating, non-MYBPC3 (CSpec disease-specific; PVS1 N/A for these genes). + # 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})' elif gene != 'MYBPC3' and 'stop_lost' in so: codes.add('PM4_Supporting') code_why['PM4_Supporting'] = 'stop-loss variant' - # BP7 — synonymous + SpliceAI no-impact + not conserved (conservation cutoff PROVISIONAL) + # BP7 - synonymous + SpliceAI no-impact + not conserved (conservation cutoff PROVISIONAL) 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: 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. + # appropriate since it is variant specific" -> keep PM5, drop PM1. 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) if 'PM1_Moderate' in codes and 'PS1_Strong' in codes: notes.append('Note: PM1+PS1 co-occur — possible double-counting (VCEP question)') # Splice signal (informational): SpliceAI >= 0.20 flags possible splice impact. - # No longer overrides a call — this track computes no overall classification. + # 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' 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'