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/cmpVCEPClinDomains.py src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPClinDomains.py index aab352f9a8a..1da7a393ed8 100644 --- src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPClinDomains.py +++ src/hg/makeDb/scripts/cardiomyopathyVCEP/cmpVCEPClinDomains.py @@ -1,339 +1,339 @@ #!/usr/bin/env python3 """ -B.1 — Cardiomyopathy VCEP PM1 Hotspot Regions track builder. +B.1 - Cardiomyopathy VCEP PM1 Hotspot Regions track builder. Renders PM1 hotspot codon regions per the per-gene CSpec for the 4 of 8 genes where PM1 is applicable (MYH7, MYBPC3, TNNT2, TNNI3). PM1 is NOT specified for ACTC1, MYBPC3*, MYL2, MYL3 (where * = MYBPC3 has PM1 applicable but only on missense). PM1 ranges (per CSpec, see ../doc/Cardiomyopathy.txt §A.10): - MYH7 167–931 (NM_000257.4 / Walsh 2019 calibration) - MYBPC3 485–502 + 1248–1266 (NM_000256.3) - TNNT2 89–189 (NM_001276345.2) - TNNI3 141–209 (NM_000363.5) + MYH7 167-931 (NM_000257.4 / Walsh 2019 calibration) + MYBPC3 485-502 + 1248-1266 (NM_000256.3) + TNNT2 89-189 (NM_001276345.2) + TNNI3 141-209 (NM_000363.5) Reads MANE Select bigGenePred from /gbdb/hg38/mane/mane.bb to extract CDS exon structure; converts amino-acid ranges to genomic coordinates with explicit unit tests against the first/last codon of each gene (catches the InSiGHT PMS2 off-by-one bug class). Outputs (per --output-dir): cmpVCEPClinDomains/cmpVCEPClinDomains.as cmpVCEPClinDomains/cmpVCEPClinDomainsHg38.bed cmpVCEPClinDomains/cmpVCEPClinDomainsHg38.bb cmpVCEPClinDomains/cmpVCEPClinDomainsHg19.bed (via liftOver) cmpVCEPClinDomains/cmpVCEPClinDomainsHg19.bb Usage: python3 cmpVCEPClinDomains.py --db hg38 --db hg19 \ --output-dir /hive/users/lrnassar/claude/RM37446 """ import argparse, os, subprocess, sys, tempfile # PM1 hotspot regions (gene, transcript, [(aa_start, aa_end, label), ...]) PM1_REGIONS = { 'MYH7': ('NM_000257.4', [(167, 931, 'Head/neck/converter')]), 'MYBPC3': ('NM_000256.3', [(485, 502, 'Hotspot 1'), (1248, 1266, 'Hotspot 2')]), 'TNNT2': ('NM_001276345.2', [(89, 189, 'Hotspot')]), 'TNNI3': ('NM_000363.5', [(141, 209, 'Hotspot')]), } -PM1_COLOR = '230,3,131' # magenta-rose — matches InSiGHT clinDomains + TP53 clinical-domains convention +PM1_COLOR = '230,3,131' # magenta-rose - matches InSiGHT clinDomains + TP53 clinical-domains convention MANE_BB = '/gbdb/hg38/mane/mane.bb' LIFTOVER_HG38_TO_HG19 = '/cluster/data/hg38/bed/liftOver/hg38ToHg19.over.chain.gz' CHROM_SIZES = { 'hg38': '/cluster/data/hg38/chrom.sizes', 'hg19': '/cluster/data/hg19/chrom.sizes', } AUTOSQL = """table cmpVCEPClinDomains "Cardiomyopathy VCEP PM1 hotspot regions per ClinGen CSpec" ( string chrom; "Chromosome" uint chromStart; "Start position" uint chromEnd; "End position" string name; "Display name (gene + region label)" uint score; "Score (always 0)" char[1] strand; "Strand" uint thickStart; "Same as chromStart" uint thickEnd; "Same as chromEnd" uint itemRgb; "Display color" string gene; "Gene symbol" string transcript; "MANE Select transcript" string aaRange; "Amino acid range (e.g. 167-931)" string exonInfo; "Exon containing this segment" lstring _mouseOver; "Tooltip HTML" ) """ # ------------------------------------------------------------------ # Codon-to-genomic conversion (clean rewrite of InSiGHT helper). # ------------------------------------------------------------------ def parse_mane_record(gene_symbol): """Extract bigGenePred fields for one gene from /gbdb/hg38/mane/mane.bb.""" out = subprocess.check_output(['bigBedToBed', MANE_BB, 'stdout'], text=True) for line in out.splitlines(): f = line.split('\t') # bigGenePred col 19 (0-indexed 18) is geneName2 (HGNC symbol) if len(f) >= 20 and f[18] == gene_symbol: return { 'chrom': f[0], 'chromStart': int(f[1]), 'chromEnd': int(f[2]), 'name': f[3], 'strand': f[5], 'thickStart': int(f[6]), 'thickEnd': int(f[7]), 'blockSizes': [int(s) for s in f[10].rstrip(',').split(',')], 'chromStarts': [int(s) for s in f[11].rstrip(',').split(',')], 'refSeqAcc': f[21], } raise KeyError(f"Gene {gene_symbol} not found in {MANE_BB}") def cds_exons(mane): """Return list of (genomic_start, genomic_end) for CDS portions of each exon, - in genomic order (low → high coord). BED half-open semantics.""" + in genomic order (low -> high coord). BED half-open semantics.""" exons = [] for size, rstart in zip(mane['blockSizes'], mane['chromStarts']): es = mane['chromStart'] + rstart ee = es + size # clip to CDS if ee <= mane['thickStart'] or es >= mane['thickEnd']: continue exons.append((max(es, mane['thickStart']), min(ee, mane['thickEnd']))) return exons def aa_to_genomic_segments(aa_start, aa_end, mane): """Convert 1-based inclusive amino-acid range [aa_start, aa_end] to genomic BED half-open intervals. Algorithm: 1. Compute mRNA-CDS nucleotide range (1-based inclusive): [nt_lo, nt_hi] nt_lo = (aa_start - 1) * 3 + 1 nt_hi = aa_end * 3 2. Walk CDS exons in TRANSCRIPT order (high-to-low genomic for minus strand, low-to-high for plus strand), tracking cumulative mRNA position. 3. For each exon, find overlap with [nt_lo, nt_hi]; project back to genomic coords using the appropriate strand mapping. Returns list of (genomic_start, genomic_end, exon_idx_in_transcript_order). """ nt_lo = (aa_start - 1) * 3 + 1 nt_hi = aa_end * 3 exons_genomic = cds_exons(mane) # genomic-order if mane['strand'] == '+': exons_tx = list(enumerate(exons_genomic, start=1)) # tx-order = genomic-order else: # tx-order is reverse genomic (highest coord = first in transcript) exons_tx = list(enumerate(reversed(exons_genomic), start=1)) segments = [] mrna_consumed = 0 # nt count already walked through earlier exons in transcript-order for exon_idx, (gs, ge) in exons_tx: exon_len = ge - gs exon_mrna_lo = mrna_consumed + 1 # 1-based inclusive exon_mrna_hi = mrna_consumed + exon_len # 1-based inclusive mrna_consumed = exon_mrna_hi if exon_mrna_hi < nt_lo or exon_mrna_lo > nt_hi: continue # this exon entirely outside our range # overlap region in mRNA-coords ov_lo = max(nt_lo, exon_mrna_lo) ov_hi = min(nt_hi, exon_mrna_hi) # offset within the exon (0-based, inclusive lo, inclusive hi) off_lo = ov_lo - exon_mrna_lo off_hi = ov_hi - exon_mrna_lo if mane['strand'] == '+': # tx-order = genomic-order: low offset -> low genomic seg_start = gs + off_lo seg_end = gs + off_hi + 1 else: # tx-order = reverse genomic: high genomic = low transcript-offset seg_end = ge - off_lo seg_start = ge - off_hi - 1 segments.append((seg_start, seg_end, exon_idx)) return segments # ------------------------------------------------------------------ # Unit tests on every minus-strand gene + the plus-strand gene # ------------------------------------------------------------------ def unit_test_codon_conversion(): """Verify codon-to-genomic conversion against known landmarks for each gene. Tests: - First codon (codon 1) maps to first 3 nt of CDS - Last codon maps to the stop codon (last 3 nt of CDS) - Length sanity: aa_to_genomic_segments() total bp = (aa_end - aa_start + 1) * 3 All 7 minus-strand genes + TPM1 (only plus-strand) tested. """ print(' [B.1 unit tests]') test_genes = ['MYH7', 'MYBPC3', 'TNNT2', 'TNNI3', 'TPM1', 'ACTC1', 'MYL2', 'MYL3'] failures = 0 for gene in test_genes: mane = parse_mane_record(gene) cds_total_nt = sum(ee - es for es, ee in cds_exons(mane)) last_aa = cds_total_nt // 3 # includes stop # Test 1: codon 1 spans 3 bp segs1 = aa_to_genomic_segments(1, 1, mane) bp1 = sum(e - s for s, e, _ in segs1) ok1 = bp1 == 3 # Test 2: last codon spans 3 bp segs_last = aa_to_genomic_segments(last_aa, last_aa, mane) bp_last = sum(e - s for s, e, _ in segs_last) ok_last = bp_last == 3 # Test 3: full CDS segs_full = aa_to_genomic_segments(1, last_aa, mane) bp_full = sum(e - s for s, e, _ in segs_full) ok_full = bp_full == cds_total_nt # Test 4 (minus-strand only): first codon should be at the HIGH end of the CDS if mane['strand'] == '-': ok_strand = max(s for s, e, _ in segs1) > min(s for s, e, _ in segs_last) else: ok_strand = min(s for s, e, _ in segs1) < min(s for s, e, _ in segs_last) status = '✓' if (ok1 and ok_last and ok_full and ok_strand) else '✗' print(f' {status} {gene} ({mane["strand"]}): cds_nt={cds_total_nt} last_aa={last_aa} ' f'codon1={bp1}bp last={bp_last}bp full={bp_full}bp strand_order={"OK" if ok_strand else "WRONG"}') if not (ok1 and ok_last and ok_full and ok_strand): failures += 1 if failures: print(f' [B.1 unit tests FAILED for {failures} gene(s)]', file=sys.stderr) sys.exit(1) print(' [B.1 unit tests PASSED for all 8 genes]') # ------------------------------------------------------------------ # Build BED + bigBed # ------------------------------------------------------------------ def emit_bed(output_path): """Build hg38 BED from PM1 region definitions.""" bed_lines = [] for gene, (transcript, ranges) in PM1_REGIONS.items(): mane = parse_mane_record(gene) if mane['refSeqAcc'] != transcript: print(f' WARNING: {gene} MANE refSeqAcc {mane["refSeqAcc"]} != expected {transcript}', file=sys.stderr) for aa_start, aa_end, label in ranges: segments = aa_to_genomic_segments(aa_start, aa_end, mane) for seg_start, seg_end, exon_idx in segments: name = f'{gene}_{label.replace(" ", "_")}_aa{aa_start}-{aa_end}_ex{exon_idx}' aa_range = f'{aa_start}-{aa_end}' exon_info = f'exon-{exon_idx}-of-tx' mouseover = ( f'<b>PM1 hotspot</b> - Cardiomyopathy VCEP<br>' f'<b>Gene:</b> {gene} ({transcript})<br>' f'<b>Region:</b> codons {aa_range} ({label})<br>' f'<b>Source:</b> ClinGen Cardiomyopathy CSpec; calibrated by Walsh 2019 (PMID 30696458)' ) bed_lines.append('\t'.join([ mane['chrom'], str(seg_start), str(seg_end), name, '0', mane['strand'], str(seg_start), str(seg_end), PM1_COLOR, gene, transcript, aa_range, exon_info, mouseover, ])) # sort by chrom + start bed_lines.sort(key=lambda l: (l.split('\t')[0], int(l.split('\t')[1]))) with open(output_path, 'w') as f: for line in bed_lines: f.write(line + '\n') - print(f' wrote {len(bed_lines)} BED features → {output_path}') + print(f' wrote {len(bed_lines)} BED features -> {output_path}') return len(bed_lines) def make_bigbed(bed_path, db, as_path, bb_path): """Convert BED to bigBed using the autoSql schema.""" cmd = ['bedToBigBed', '-tab', '-type=bed9+5', '-as=' + as_path, bed_path, CHROM_SIZES[db], bb_path] print(f' $ {" ".join(cmd)}') subprocess.run(cmd, check=True) def liftover_to_hg19(hg38_bed, hg19_bed): """liftOver hg38 BED to hg19; warn on dropped features.""" unmapped = hg19_bed + '.unmapped' # Use bedExtraFieldsToBigBed style: liftOver supports -bedPlus to keep extra fields 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 = sum(1 for l in open(unmapped) if not l.startswith('#')) print(f' WARNING: {n} features failed liftOver to hg19; see {unmapped}', file=sys.stderr) def main(): ap = argparse.ArgumentParser() ap.add_argument('--db', action='append', required=True, choices=['hg38', 'hg19'], help='Assembly to build (specify --db hg38 --db hg19 for both)') ap.add_argument('--output-dir', required=True) args = ap.parse_args() out_dir = os.path.join(args.output_dir, 'cmpVCEPClinDomains') os.makedirs(out_dir, exist_ok=True) - # Run unit tests first — must pass before emitting any features + # Run unit tests first - must pass before emitting any features unit_test_codon_conversion() # Write autoSql schema as_path = os.path.join(out_dir, 'cmpVCEPClinDomains.as') with open(as_path, 'w') as f: f.write(AUTOSQL) # Build hg38 first (native), then hg19 via liftOver hg38_bed = os.path.join(out_dir, 'cmpVCEPClinDomainsHg38.bed') hg38_bb = os.path.join(out_dir, 'cmpVCEPClinDomainsHg38.bb') n_features = emit_bed(hg38_bed) if 'hg38' in args.db: make_bigbed(hg38_bed, 'hg38', as_path, hg38_bb) print(f' hg38 bigBed: {hg38_bb}') if 'hg19' in args.db: hg19_bed = os.path.join(out_dir, 'cmpVCEPClinDomainsHg19.bed') hg19_bb = os.path.join(out_dir, 'cmpVCEPClinDomainsHg19.bb') liftover_to_hg19(hg38_bed, hg19_bed) make_bigbed(hg19_bed, 'hg19', as_path, hg19_bb) print(f' hg19 bigBed: {hg19_bb}') # Cross-assembly parity if 'hg38' in args.db and 'hg19' in args.db: n_hg38 = sum(1 for _ in open(hg38_bed)) n_hg19 = sum(1 for _ in open(os.path.join(out_dir, 'cmpVCEPClinDomainsHg19.bed'))) if n_hg38 != n_hg19: - print(f' WARNING: cross-assembly parity FAILED — hg38={n_hg38} hg19={n_hg19}', + print(f' WARNING: cross-assembly parity FAILED - hg38={n_hg38} hg19={n_hg19}', file=sys.stderr) else: print(f' cross-assembly parity OK: {n_hg38} features each') if __name__ == '__main__': main()