1478a90404ea80636ae160b7b150fcaa34d80b50 lrnassar Tue Aug 18 15:14:02 2026 -0700 Adding Zanti et al 2025 case-control LRs to the ENIGMA BRCA1/BRCA2 PP4/BP5 track. refs #37886 New BRCAmlaZanti.py rebuilds the BRCAmla track with the case-control likelihood ratios from Zanti et al. 2025 (PMID 40413188) replacing the 20-variant iCOGS case-control component from Parsons et al. 2019, which overlaps the Zanti cohorts. Track grows from 4,436 to 13,481 variants per assembly, with new per-cohort columns (BRIDGES, CARRIERS, UK Biobank). Makedoc documents the build and release steps. Approach and combined-LR caveats reviewed with the ENIGMA collaborators on the ticket. diff --git src/hg/makeDb/scripts/enigma/BRCAmlaZanti.py src/hg/makeDb/scripts/enigma/BRCAmlaZanti.py new file mode 100644 index 00000000000..766a1d6139d --- /dev/null +++ src/hg/makeDb/scripts/enigma/BRCAmlaZanti.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +# Rebuild of the ENIGMA PP4/BP5 (BRCAmla) track with the Zanti et al. 2025 +# case-control LR (ccLR, PMID 40413188) replacing the Parsons iCOGS +# case-control component. Reads the current track bigBeds for the other +# evidence types, merges in the ccLRs from Zanti Supplementary Data 4, and +# writes new .bed/.bb into the work dir. The outputs are copied onto the +# staging filenames served by the hub only at release, see the makedoc +# (makeDb/doc/enigma.txt). refs #37886 + +import openpyxl, re, subprocess, sys + +WORK = "/hive/data/inside/enigmaTracksData/zantiDraft" +XLSX = WORK + "/ZantiSuppData4.xlsx" +CUR38 = "/gbdb/hg38/bbi/enigma/BRCAmfa.bb" # current (Parsons-based) track, hg38 +CUR19 = "/gbdb/hg19/bbi/enigma/BRCAmfa.bb" # current track, hg19 +HG38SIZES = "/cluster/data/hg38/chrom.sizes" +HG19SIZES = "/cluster/data/hg19/chrom.sizes" + +TX = {"BRCA1": "NM_007294.4", "BRCA2": "NM_000059.4"} + +def bash(cmd): + r = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, universal_newlines=True) + if r.returncode != 0: + sys.exit("CMD FAILED: %s\n%s" % (cmd, r.stdout)) + return r.stdout + +def fnum(x): + """Parse a float from a track field; return None if blank/non-numeric.""" + if x is None: + return None + s = str(x).strip() + if s == "" or s.upper() == "NULL": + return None + try: + return float(s) + except ValueError: + return None + +# Gap-free ACMG bands (Tavtigian/ACMG, matching the Zanti thresholds). +def assignACMGcode(lr): + if lr is None: + return "Not informative" + if lr >= 350: return "PP4 - Pathogenic - Very strong" + if lr >= 18.7: return "PP4 - Pathogenic - Strong" + if lr >= 4.33: return "PP4 - Pathogenic - Moderate" + if lr >= 2.08: return "PP4 - Pathogenic - Supporting" + if lr <= 0.0029: return "BP5 - Benign - Very strong" + if lr <= 0.053: return "BP5 - Benign - Strong" + if lr <= 0.231: return "BP5 - Benign - Moderate" + if lr <= 0.48: return "BP5 - Benign - Supporting" + return "Not informative" + +def assignRGB(lr): + if lr is None: return "91,91,91" + if lr >= 2.08: return "128,64,13" # brown -> PP4 + if lr <= 0.48: return "252,157,3" # orange -> BP5 + return "91,91,91" # grey -> no evidence + +# --------------------------------------------------------------------------- +# 1. Current multifactorial track: reuse its already-combined per-evidence LRs +# (family/co-occurrence/segregation/pathology) and coordinates for BOTH +# assemblies. We DROP the existing case-control column (Parsons iCOGS). +# bb columns (1-based): 1-9 bed9, 10 LLR, 11 ACMGcode, 12 famHist, +# 13 cooc, 14 seg, 15 path, 16 caseControl(Parsons), 17 caputo, 18 parsons, +# 19 li, 20 easton, 21 mouseOver. +# --------------------------------------------------------------------------- +def loadCurrent(bb): + d = {} + for line in bash("bigBedToBed %s stdout" % bb).splitlines(): + f = line.split("\t") + name = f[3] + d[name] = { + "chrom": f[0], "start": f[1], "end": f[2], + "famHist": f[11], "cooc": f[12], "seg": f[13], "path": f[14], + "caputo": f[16], "parsons": f[17], "li": f[18], "easton": f[19], + } + return d + +cur38 = loadCurrent(CUR38) +cur19 = loadCurrent(CUR19) + +# --------------------------------------------------------------------------- +# 2. Zanti Supplementary Data 4. header at row index 4, data from index 5. +# --------------------------------------------------------------------------- +wb = openpyxl.load_workbook(XLSX, read_only=True) +ws = wb["Supplementary Data 4"] +C = dict(CHR=2, POS19=3, POS38=4, REF=5, ALT=6, GENE=9, HGVSC=12, HGVSP=13, + BRIDGES=27, CARRIERS=30, UKB=33, CCLR=37, SUGG=38, PS4=42) + +zan = {} # key -> dict +zan_dropped = 0 # N/A / None / no computable LR +for i, r in enumerate(ws.iter_rows(values_only=True)): + if i < 5 or r is None or r[C["GENE"]] is None: + continue + gene = str(r[C["GENE"]]).strip() + if gene not in TX: + continue + cclr = fnum(r[C["CCLR"]]) + sugg = ("" if r[C["SUGG"]] is None else str(r[C["SUGG"]]).strip()) + if cclr is None or sugg in ("N/A", "None"): + zan_dropped += 1 + continue + hgvsc = None if r[C["HGVSC"]] is None else str(r[C["HGVSC"]]).strip() + chrom = "chr%s" % str(r[C["CHR"]]).strip() + ref = str(r[C["REF"]]).strip() + if hgvsc and hgvsc.startswith("c."): + key = "%s:%s" % (TX[gene], re.sub(r"\s+", "", hgvsc)) + name = key + else: + key = "%s:%s:%s:%s>%s" % (gene, chrom, str(r[C["POS38"]]).strip(), + ref, str(r[C["ALT"]]).strip()) + name = key + rec = {"gene": gene, "chrom": chrom, "ref": ref, "name": name, + "hgvsp": "" if r[C["HGVSP"]] is None else str(r[C["HGVSP"]]).strip(), + "ccLR": cclr, "sugg": sugg, + "bridges": r[C["BRIDGES"]], "carriers": r[C["CARRIERS"]], "ukb": r[C["UKB"]], + "ps4": "" if r[C["PS4"]] is None else str(r[C["PS4"]]).strip()} + for asm, col in (("38", "POS38"), ("19", "POS19")): + p = r[C[col]] + if p is None or str(p).strip() == "": + rec["pos" + asm] = None + else: + s = int(float(str(p).strip())) - 1 + rec["pos" + asm] = (chrom, str(s), str(s + max(1, len(ref)))) + zan[key] = rec + +# --------------------------------------------------------------------------- +# 3. Merge: universe = union of current-track variants and Zanti variants. +# New combined LR = product(family, co-occ, seg, path) x Zanti ccLR. +# --------------------------------------------------------------------------- +allkeys = set(cur38) | set(zan) +conflicts = [] # (key, MF, ccLR, combined) +n_both = n_zan_only = n_mf_only = n_no_evidence_left = 0 +color_count = {} + +def mfProduct(cur): + """Product of the four non-case-control components; None if none present.""" + vals = [fnum(cur[k]) for k in ("famHist", "cooc", "seg", "path")] + vals = [v for v in vals if v is not None] + if not vals: + return None + p = 1.0 + for v in vals: + p *= v + return p + +def buildBed(asm, curmap): + """Emit bed lines for one assembly.""" + global n_both, n_zan_only, n_mf_only, n_no_evidence_left + lines = [] + for key in allkeys: + cur = curmap.get(key) + z = zan.get(key) + + # multifactorial part + mf = mfProduct(cur) if cur else None + cc = z["ccLR"] if z else None + + # combined LR + terms = [t for t in (mf, cc) if t is not None] + if not terms: + combined = None + else: + combined = 1.0 + for t in terms: + combined *= t + + # coordinates for this assembly: prefer current-track coords for + # multifactorial variants, else Zanti's provided coords. + if cur: + chrom, start, end = cur["chrom"], cur["start"], cur["end"] + elif z and z["pos" + asm]: + chrom, start, end = z["pos" + asm] + else: + continue # variant not placeable on this assembly + + # classify membership (count once, on hg38 pass only) + if asm == "38": + if cur and z: n_both += 1 + elif z: n_zan_only += 1 + else: n_mf_only += 1 + if combined is None: + n_no_evidence_left += 1 + # direction conflict: MF vs ccLR point opposite ways + if mf is not None and cc is not None: + if (cc >= 2.08 and mf <= 0.48) or (cc <= 0.48 and mf >= 2.08): + conflicts.append((key, round(mf, 5), round(cc, 5), + round(combined, 5))) + + code = assignACMGcode(combined) + rgb = assignRGB(combined) + if asm == "38": + color_count[rgb] = color_count.get(rgb, 0) + 1 + + def fmt(v): + return "" if v is None else str(round(v, 5)) + + name = z["name"] if z else key + hgvsp = z["hgvsp"] if z else "" + cc_str = fmt(cc) + bridges = fmt(fnum(z["bridges"])) if z else "" + carriers = fmt(fnum(z["carriers"])) if z else "" + ukb = fmt(fnum(z["ukb"])) if z else "" + sugg = z["sugg"] if z else "" + caputo = cur["caputo"] if cur else "" + parsons = cur["parsons"] if cur else "" + li = cur["li"] if cur else "" + easton = cur["easton"] if cur else "" + + # mouseOver mirrors the original track: Combined LR + ACMG code only. + # Per-source scores (including the Zanti ccLR) live in the detail page, + # not the mouseOver, so no single evidence type is singled out. + mouse = ("HGVSc: %s
Combined LR: %s
" + "ACMG Code: %s" % (name, fmt(combined), code)) + + row = [chrom, start, end, name, "0", ".", start, end, rgb, + fmt(combined), code, + cur["famHist"] if cur else "", cur["cooc"] if cur else "", + cur["seg"] if cur else "", cur["path"] if cur else "", + cc_str, bridges, carriers, ukb, sugg, + caputo, parsons, li, easton, hgvsp, mouse] + lines.append("\t".join(row)) + return lines + +for asm, curmap, sizes, out in (("38", cur38, HG38SIZES, "BRCAmfaZantiHg38"), + ("19", cur19, HG19SIZES, "BRCAmfaZantiHg19")): + bed = WORK + "/%s.bed" % out + with open(bed, "w") as fh: + fh.write("\n".join(buildBed(asm, curmap)) + "\n") + bash("bedSort %s %s" % (bed, bed)) + +# --------------------------------------------------------------------------- +# 4. autoSql and bigBed. +# --------------------------------------------------------------------------- +AS = '''table BRCAmla +"BRCA1/BRCA2 multifactorial likelihood analysis (PP4/BP5), with Zanti et al. 2025 case-control LR" + ( + string chrom; "Reference sequence chromosome or scaffold" + uint chromStart; "Start position in chromosome" + uint chromEnd; "End position in chromosome" + string name; "HGVS Nucleotide" + uint score; "Not used, all 0" + char[1] strand; "Not used, all ." + uint thickStart; "Same as chromStart" + uint thickEnd; "Same as chromEnd" + uint reserved; "RGB value" + string LLR; "Combined LR score (product of available evidence)" + string ACMGcode; "PP4/BP5 code and strength from the combined LR" + string familyHistoryCombinedLR; "Combined family-history LR. Blank if none." + string cooccurrenceCombinedLR; "Combined co-occurrence LR. Blank if none." + string segregationCombinedLR; "Combined segregation LR. Blank if none." + string pathologyCombinedLR; "Combined pathology LR. Blank if none." + string caseControlLR; "Case-control LR from Zanti et al. 2025 (ccLR). Blank if none." + string bridgesLR; "Zanti BRIDGES dataset ccLR" + string carriersLR; "Zanti CARRIERS dataset ccLR" + string ukbLR; "Zanti UK Biobank ccLR" + string zantiSuggestedCode; "Zanti standalone suggested ACMG/AMP evidence (case-control only)" + string caputoLRs; "Caputo et al scores (family, co-occurrence, segregation, pathology)" + string parsonsLRs; "Parsons et al scores (family, co-occurrence, segregation, pathology)" + string liLRs; "Li et al scores (family)" + string eastonLRs; "Easton et al scores (family, co-occurrence, segregation)" + string HGVSp; "HGVS protein change" + string _mouseOver; "Field only used as mouseOver" + )''' +with open(WORK + "/BRCAmlaZanti.as", "w") as fh: + fh.write(AS) + +for out, sizes in (("BRCAmfaZantiHg38", HG38SIZES), ("BRCAmfaZantiHg19", HG19SIZES)): + bash("bedToBigBed -as=%s/BRCAmlaZanti.as -type=bed9+17 -tab %s/%s.bed %s %s/%s.bb" + % (WORK, WORK, out, sizes, WORK, out)) + +# --------------------------------------------------------------------------- +# 5. Report. +# --------------------------------------------------------------------------- +print("=== BUILD SUMMARY ===") +print("Zanti variants dropped (N/A / no computable LR): %d" % zan_dropped) +print("Membership (hg38): both=%d Zanti-only=%d multifactorial-only=%d" + % (n_both, n_zan_only, n_mf_only)) +print("Variants left with no evidence at all after CC swap: %d" % n_no_evidence_left) +print("Color counts (hg38): %s" % color_count) +print("Direction conflicts (MF vs ccLR opposite): %d" % len(conflicts)) +conflicts.sort(key=lambda x: abs(__import__("math").log10(x[2]) if x[2] > 0 else 0), + reverse=True) +print("Top conflicts (key, MFproduct, ccLR, combined):") +for c in conflicts[:15]: + print(" ", c) +with open(WORK + "/directionConflicts.tsv", "w") as fh: + fh.write("variant\tmultifactorialProduct\tzantiCcLR\tcombinedLR\n") + for c in conflicts: + fh.write("%s\t%s\t%s\t%s\n" % c) +print("Conflicts written to directionConflicts.tsv")