d0632693bee08bf61b7990e0c6a1de8c050e337f
mspeir
  Sat Aug 1 20:53:09 2026 -0700
singleCellSignalsPeaks: color by cell class, harmonize labels and facets (hg38)

Overhaul of the hg38 track and the shared build scripts it and the mm10 track
are generated from:

- Color every subtrack by broad cell class from one colorblind-conscious palette
(shared with the mm10 track, so a class is the same color on both assemblies);
add a color legend to the description page.
- Add a "Cell class" facet; the fine cell type becomes a searchable table column.
Group subtracks by class via priority; every subtrack is off by default.
- Paper-curated cell-type names, redundant-synonym merges, QC-cluster drop, and
per-collection tissue/life-stage/condition (including the SEA-AD region and
ADNC neuropathology level, from Gabitto 2024 and Hawrylycz 2024).
- Rebuild the longLabels from the harmonized cell type + facets, so the cryptic
source short labels decode.
- Reclassify 10 mislabeled interaction bigBeds out of the signal/peaks composite,
retype a narrowPeak-format bigBed, and drop deprecated *.old data
(936 -> 925 subtracks).
- Archive the curation with the scripts: build_celltype_crosswalks.py and
celltype-crosswalks/ (per-collection crosswalks, palette, class map, and the
paper-decode source tables).

refs #37914

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

diff --git src/hg/makeDb/scripts/singleCellSignalsPeaks/copySingleCellSignalsPeaksFiles.py src/hg/makeDb/scripts/singleCellSignalsPeaks/copySingleCellSignalsPeaksFiles.py
new file mode 100644
index 00000000000..f54ff1839fe
--- /dev/null
+++ src/hg/makeDb/scripts/singleCellSignalsPeaks/copySingleCellSignalsPeaksFiles.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""
+Copy the source data files for the native singleCellSignalsPeaks track into the
+genome's bed directory, mirroring each file's served relative path (Redmine
+#37820 for hg38, #37914 for mm10).
+
+For the given assembly it reads the hub build's main signal-&-peaks composite
+(cellBrowser<Asm>) stanzas, and for every subtrack copies the source file
+(resolved from the hub manifest by served relative path) to
+  /hive/data/genomes/<asm>/bed/singleCellSignalsPeaks/<served-relpath>
+The served subpath is preserved on purpose: some peak-file basenames repeat
+across datasets, so a flat directory would clobber them, and keeping the subpath
+lets the /gbdb/<asm>/bbi/singleCellSignalsPeaks symlink resolve every bigDataUrl.
+
+It also copies the composite's facet metadata to
+  <bed>/singleCellSignalsPeaks_metadata.tsv   (the track's metaDataUrl target).
+
+Usage:
+  copySingleCellSignalsPeaksFiles.py [--assembly hg38|mm10] [--dry-run]
+"""
+import re, os, shutil, argparse
+from urllib.parse import urlparse
+
+HUB_BUILD = "/hive/users/mspeir/claude/cell-browser/all-tracks-hub-build"
+TRACK = "singleCellSignalsPeaks"
+
+def load_relpath_to_abs(manifest, asm):
+    m = {}
+    with open(manifest) as fh:
+        hdr = fh.readline().rstrip("\n").split("\t")
+        ai, ui, asmi = hdr.index("abs_path"), hdr.index("track_url"), hdr.index("assembly")
+        for line in fh:
+            f = line.rstrip("\n").split("\t")
+            if len(f) <= max(ai, ui, asmi) or f[asmi] != asm:
+                continue
+            m[urlparse(f[ui]).path.lstrip("/")] = f[ai]
+    return m
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--assembly", default="hg38", choices=["hg38", "mm10"])
+    ap.add_argument("--stanzas")
+    ap.add_argument("--manifest", default=os.path.join(HUB_BUILD, "manifest.tsv"))
+    ap.add_argument("--dry-run", action="store_true")
+    args = ap.parse_args()
+
+    asm = args.assembly
+    hub_composite = "cellBrowser" + asm.capitalize()
+    stanzas = args.stanzas or os.path.join(HUB_BUILD, "stanzas/%s.trackDb.txt" % asm)
+    beddir = "/hive/data/genomes/%s/bed/%s" % (asm, TRACK)
+    rel2abs = load_relpath_to_abs(args.manifest, asm)
+
+    copied = missing = total = 0
+    nbytes = 0
+    misses = []
+    for s in re.split(r"\n\s*\n", open(stanzas).read().strip()):
+        lines = s.splitlines()
+        parent = next((l for l in lines if l.startswith("parent ")), "").strip()
+        if parent != "parent " + hub_composite:
+            continue
+        bdu = next((l for l in lines if l.strip().startswith("bigDataUrl ")), None)
+        if not bdu:
+            continue
+        total += 1
+        rel = urlparse(bdu.split(None, 1)[1].strip()).path.lstrip("/")
+        src = rel2abs.get(rel)
+        if not src or not os.path.exists(src):
+            missing += 1
+            misses.append(rel)
+            continue
+        dst = os.path.join(beddir, rel)
+        if not args.dry_run:
+            os.makedirs(os.path.dirname(dst), exist_ok=True)
+            if not (os.path.exists(dst) and os.path.getsize(dst) == os.path.getsize(src)):
+                shutil.copy2(src, dst)
+        copied += 1
+        nbytes += os.path.getsize(src)
+
+    meta_src = os.path.join(HUB_BUILD, "meta", "%s.metadata.tsv" % asm)
+    meta_dst = os.path.join(beddir, "%s_metadata.tsv" % TRACK)
+    if not args.dry_run and os.path.exists(meta_src):
+        os.makedirs(beddir, exist_ok=True)
+        shutil.copy2(meta_src, meta_dst)
+
+    print("assembly=%s composite=%s: subtracks=%d copied=%d missing=%d  ~%.1f GB%s"
+          % (asm, hub_composite, total, copied, missing, nbytes / 1e9,
+             "  (dry-run)" if args.dry_run else "  -> " + beddir))
+    if misses:
+        print("MISSING %d source files:" % len(misses))
+        for r in misses[:25]:
+            print("  " + r)
+
+if __name__ == "__main__":
+    main()