|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +make_transverse_profiles.py -- Stage 3 (map-level), SBND SCE migration. |
| 4 | +
|
| 5 | +Map-level SCE spatial-offset PROFILES vs drift distance, in Lane Kashur's |
| 6 | +technote convention: |
| 7 | +
|
| 8 | + * FORWARD displacement (Delta = reco - true) -> read TrueFwd_Displacement_* |
| 9 | + * horizontal axis = DRIFT DISTANCE (0 = anode, ~200 cm = cathode), not signed x |
| 10 | + * West = orange, East = blue (matches technote Fig 4) |
| 11 | +
|
| 12 | +Produces one figure (08_transverse_profiles.png) with three panels: |
| 13 | +
|
| 14 | + (a) Delta x vs drift -- CROSS-CHECK. Should reproduce technote Fig 4 |
| 15 | + (peak ~0.9 cm near mid-drift, West curve above East). If it does, the |
| 16 | + geometry / drift-distance handling is validated and panels (b),(c) are |
| 17 | + trustworthy. |
| 18 | + (b) Delta y vs drift -- conditioned near top (y>0) and bottom (y<0) walls. |
| 19 | + Expect top negative / bottom positive, |Delta y| growing toward the |
| 20 | + cathode (large drift). This is the SBND analog of MicroBooNE's |
| 21 | + Delta_y_top(x) / Delta_y_bottom(x) (NOTE-1018, Sec 6.1). |
| 22 | + (c) Delta z vs drift -- conditioned near the upstream (z->0) and |
| 23 | + downstream (z->~500) ends, where Delta z is non-negligible. |
| 24 | +
|
| 25 | +This reads the map ONLY (no BEE / reco output), so it gives the map |
| 26 | +EXPECTATION. The reco-vs-map closure (Stage 3a + plot 09) comes later. |
| 27 | +
|
| 28 | +Run on the gpvm with the SCE env loaded (ROOT + matplotlib available): |
| 29 | +
|
| 30 | + source $SCE_TOP/restore_sce_env_036_sce.sh |
| 31 | + python make_transverse_profiles.py |
| 32 | + # optional: SCE_MAP_FILE=/some/other/map.root python make_transverse_profiles.py |
| 33 | +""" |
| 34 | + |
| 35 | +import os |
| 36 | +import numpy as np |
| 37 | +import ROOT |
| 38 | +import matplotlib |
| 39 | +matplotlib.use("Agg") |
| 40 | +import matplotlib.pyplot as plt |
| 41 | + |
| 42 | +# ---------------------------------------------------------------------------- |
| 43 | +# Configuration (everything you might want to tune lives here) |
| 44 | +# ---------------------------------------------------------------------------- |
| 45 | +MAP_FILE = os.environ.get( |
| 46 | + "SCE_MAP_FILE", |
| 47 | + "/cvmfs/sbnd.opensciencegrid.org/products/sbnd/sbnd_data/v01_42_00/" |
| 48 | + "SCEoffsets/SCEoffsets_SBND_E500_dualmap_CV_voxelTH3.root", |
| 49 | +) |
| 50 | + |
| 51 | +# Forward-displacement TH3 names. Lane's Fig 7/8 are the FORWARD map, so we |
| 52 | +# match that. (TrueBkwd_* exist too; do NOT mix the two within SBND.) |
| 53 | +FWD = { |
| 54 | + ("x", "E"): "TrueFwd_Displacement_X_E", ("x", "W"): "TrueFwd_Displacement_X_W", |
| 55 | + ("y", "E"): "TrueFwd_Displacement_Y_E", ("y", "W"): "TrueFwd_Displacement_Y_W", |
| 56 | + ("z", "E"): "TrueFwd_Displacement_Z_E", ("z", "W"): "TrueFwd_Displacement_Z_W", |
| 57 | +} |
| 58 | + |
| 59 | +# Geometry (cm). Cathode at x = 0, anodes at x = +/- ANODE_ABS_X. |
| 60 | +# East TPC = x < 0 (APA 0), West TPC = x > 0 (APA 1). |
| 61 | +ANODE_ABS_X = 200.0 |
| 62 | +TPC_XRANGE = {"E": (-ANODE_ABS_X, 0.0), "W": (0.0, ANODE_ABS_X)} |
| 63 | + |
| 64 | +# Conditioning regions (cm). |
| 65 | +TOP_Y_MIN = 150.0 # "near top wall" (Delta y) |
| 66 | +BOT_Y_MAX = -150.0 # "near bottom wall" (Delta y) |
| 67 | +UPSTREAM_Z_MAX = 60.0 # near z = 0 end (Delta z) |
| 68 | +DOWNSTREAM_Z_MIN = 440.0 # near z ~ 500 end (Delta z) |
| 69 | + |
| 70 | +# Bulk region used for the Delta x cross-check (mimics Lane's edge-avoiding |
| 71 | +# calibration selection so the cross-check tracks Fig 4 rather than the corners). |
| 72 | +BULK_ABS_Y_MAX = 150.0 |
| 73 | +BULK_Z_MIN, BULK_Z_MAX = 50.0, 450.0 |
| 74 | + |
| 75 | +# Drift-distance binning. 20 cm matches existing plot 06; drop to 10 to mirror |
| 76 | +# Lane Fig 4 more finely once you trust the script. |
| 77 | +DRIFT_BINS = np.arange(0.0, ANODE_ABS_X + 1e-6, 20.0) |
| 78 | + |
| 79 | +# Lane colors (colorblind-safe). |
| 80 | +COL = {"W": "#E69F00", "E": "#0072B2"} # West orange, East blue |
| 81 | +TPC_LABEL = {"W": "West", "E": "East"} |
| 82 | + |
| 83 | +OUTDIR = os.environ.get("SCE_PLOT_OUT", "validation_plots") |
| 84 | +OUTNAME = "08_transverse_profiles.png" |
| 85 | + |
| 86 | + |
| 87 | +# ---------------------------------------------------------------------------- |
| 88 | +# Helpers |
| 89 | +# ---------------------------------------------------------------------------- |
| 90 | +def load_voxels(h): |
| 91 | + """Flatten a TH3 to (x, y, z, value) arrays of bin centers / contents [cm].""" |
| 92 | + ax, ay, az = h.GetXaxis(), h.GetYaxis(), h.GetZaxis() |
| 93 | + nx, ny, nz = h.GetNbinsX(), h.GetNbinsY(), h.GetNbinsZ() |
| 94 | + xc = np.fromiter((ax.GetBinCenter(i) for i in range(1, nx + 1)), float, nx) |
| 95 | + yc = np.fromiter((ay.GetBinCenter(j) for j in range(1, ny + 1)), float, ny) |
| 96 | + zc = np.fromiter((az.GetBinCenter(k) for k in range(1, nz + 1)), float, nz) |
| 97 | + val = np.empty((nx, ny, nz), dtype=float) |
| 98 | + for i in range(nx): |
| 99 | + for j in range(ny): |
| 100 | + for k in range(nz): |
| 101 | + val[i, j, k] = h.GetBinContent(i + 1, j + 1, k + 1) |
| 102 | + X, Y, Z = np.meshgrid(xc, yc, zc, indexing="ij") |
| 103 | + return X.ravel(), Y.ravel(), Z.ravel(), val.ravel() |
| 104 | + |
| 105 | + |
| 106 | +def drift_profile(x, val, mask): |
| 107 | + """Median (+ MAD) of val vs drift distance = ANODE_ABS_X - |x|, within mask.""" |
| 108 | + d = ANODE_ABS_X - np.abs(x) |
| 109 | + d, v = d[mask], val[mask] |
| 110 | + cen, med, mad = [], [], [] |
| 111 | + for lo, hi in zip(DRIFT_BINS[:-1], DRIFT_BINS[1:]): |
| 112 | + b = (d >= lo) & (d < hi) |
| 113 | + if b.sum() < 5: # need a few voxels for a stable median |
| 114 | + continue |
| 115 | + m = float(np.median(v[b])) |
| 116 | + cen.append(0.5 * (lo + hi)) |
| 117 | + med.append(m) |
| 118 | + mad.append(float(np.median(np.abs(v[b] - m)))) |
| 119 | + return np.array(cen), np.array(med), np.array(mad) |
| 120 | + |
| 121 | + |
| 122 | +def watermark(ax): |
| 123 | + ax.text(0.03, 0.95, "SBND Simulation\nPreliminary", transform=ax.transAxes, |
| 124 | + ha="left", va="top", color="0.45", fontsize=10) |
| 125 | + |
| 126 | + |
| 127 | +# ---------------------------------------------------------------------------- |
| 128 | +# Load |
| 129 | +# ---------------------------------------------------------------------------- |
| 130 | +if not os.path.exists(MAP_FILE): |
| 131 | + raise SystemExit(f"[fatal] SCE map not found:\n {MAP_FILE}\n" |
| 132 | + f"Set SCE_MAP_FILE to the correct path.") |
| 133 | + |
| 134 | +f = ROOT.TFile.Open(MAP_FILE) |
| 135 | +if not f or f.IsZombie(): |
| 136 | + raise SystemExit(f"[fatal] could not open {MAP_FILE}") |
| 137 | + |
| 138 | +data = {} # (comp, tpc) -> (x,y,z,val) |
| 139 | +for (comp, tpc), name in FWD.items(): |
| 140 | + h = f.Get(name) |
| 141 | + if not h: |
| 142 | + raise SystemExit(f"[fatal] histogram '{name}' not in file. " |
| 143 | + f"Check names with: rootls -l {MAP_FILE}") |
| 144 | + ax = h.GetXaxis() |
| 145 | + print(f" {name:32s} nbins=({h.GetNbinsX()},{h.GetNbinsY()},{h.GetNbinsZ()})" |
| 146 | + f" x:[{ax.GetXmin():.1f},{ax.GetXmax():.1f}] cm") |
| 147 | + data[(comp, tpc)] = load_voxels(h) |
| 148 | + |
| 149 | +os.makedirs(OUTDIR, exist_ok=True) |
| 150 | + |
| 151 | +# ---------------------------------------------------------------------------- |
| 152 | +# Plot |
| 153 | +# ---------------------------------------------------------------------------- |
| 154 | +fig, axes = plt.subplots(1, 3, figsize=(16.5, 5.2)) |
| 155 | +fig.suptitle("SBND SCE dualmap -- forward spatial offsets vs drift distance " |
| 156 | + "(map expectation)", fontsize=13) |
| 157 | + |
| 158 | +# (a) Delta x cross-check ---------------------------------------------------- |
| 159 | +axx = axes[0] |
| 160 | +for tpc in ("W", "E"): |
| 161 | + x, y, z, v = data[("x", tpc)] |
| 162 | + xlo, xhi = TPC_XRANGE[tpc] |
| 163 | + bulk = ((x >= xlo) & (x <= xhi) & (np.abs(y) <= BULK_ABS_Y_MAX) |
| 164 | + & (z >= BULK_Z_MIN) & (z <= BULK_Z_MAX)) |
| 165 | + # plot |Delta x| to match Lane Fig 4 (which shows the offset as positive) |
| 166 | + c, m, e = drift_profile(x, np.abs(v), bulk) |
| 167 | + axx.errorbar(c, m, yerr=e, marker="s", ms=5, lw=1.6, capsize=2, |
| 168 | + color=COL[tpc], label=f"{TPC_LABEL[tpc]} TPC") |
| 169 | +axx.set_title(r"(a) $|\Delta x|$ vs drift — cross-check vs Fig 4") |
| 170 | +axx.set_xlabel("Drift Distance [cm]") |
| 171 | +axx.set_ylabel(r"Spatial Offset $|\Delta x|$ [cm]") |
| 172 | +axx.set_xlim(0, ANODE_ABS_X) |
| 173 | +axx.grid(alpha=0.3) |
| 174 | +axx.legend() |
| 175 | +watermark(axx) |
| 176 | + |
| 177 | +# (b) Delta y top / bottom --------------------------------------------------- |
| 178 | +axy = axes[1] |
| 179 | +for tpc in ("W", "E"): |
| 180 | + x, y, z, v = data[("y", tpc)] |
| 181 | + xlo, xhi = TPC_XRANGE[tpc] |
| 182 | + inx = (x >= xlo) & (x <= xhi) |
| 183 | + top = inx & (y >= TOP_Y_MIN) |
| 184 | + bot = inx & (y <= BOT_Y_MAX) |
| 185 | + c, m, e = drift_profile(x, v, top) |
| 186 | + axy.errorbar(c, m, yerr=e, marker="^", ms=5, lw=1.6, capsize=2, ls="-", |
| 187 | + color=COL[tpc], label=f"{TPC_LABEL[tpc]} top") |
| 188 | + c, m, e = drift_profile(x, v, bot) |
| 189 | + axy.errorbar(c, m, yerr=e, marker="v", ms=5, lw=1.6, capsize=2, ls="--", |
| 190 | + color=COL[tpc], label=f"{TPC_LABEL[tpc]} bottom") |
| 191 | +axy.axhline(0, color="0.6", lw=0.8) |
| 192 | +axy.set_title(r"(b) $\Delta y$ vs drift — near top / bottom walls") |
| 193 | +axy.set_xlabel("Drift Distance [cm]") |
| 194 | +axy.set_ylabel(r"Spatial Offset $\Delta y$ [cm]") |
| 195 | +axy.set_xlim(0, ANODE_ABS_X) |
| 196 | +axy.grid(alpha=0.3) |
| 197 | +axy.legend(fontsize=8) |
| 198 | +watermark(axy) |
| 199 | + |
| 200 | +# (c) Delta z upstream / downstream ----------------------------------------- |
| 201 | +axz = axes[2] |
| 202 | +for tpc in ("W", "E"): |
| 203 | + x, y, z, v = data[("z", tpc)] |
| 204 | + xlo, xhi = TPC_XRANGE[tpc] |
| 205 | + inx = (x >= xlo) & (x <= xhi) |
| 206 | + up = inx & (z <= UPSTREAM_Z_MAX) |
| 207 | + dn = inx & (z >= DOWNSTREAM_Z_MIN) |
| 208 | + c, m, e = drift_profile(x, v, up) |
| 209 | + axz.errorbar(c, m, yerr=e, marker="^", ms=5, lw=1.6, capsize=2, ls="-", |
| 210 | + color=COL[tpc], label=f"{TPC_LABEL[tpc]} upstream") |
| 211 | + c, m, e = drift_profile(x, v, dn) |
| 212 | + axz.errorbar(c, m, yerr=e, marker="v", ms=5, lw=1.6, capsize=2, ls="--", |
| 213 | + color=COL[tpc], label=f"{TPC_LABEL[tpc]} downstream") |
| 214 | +axz.axhline(0, color="0.6", lw=0.8) |
| 215 | +axz.set_title(r"(c) $\Delta z$ vs drift — near upstream / downstream ends") |
| 216 | +axz.set_xlabel("Drift Distance [cm]") |
| 217 | +axz.set_ylabel(r"Spatial Offset $\Delta z$ [cm]") |
| 218 | +axz.set_xlim(0, ANODE_ABS_X) |
| 219 | +axz.grid(alpha=0.3) |
| 220 | +axz.legend(fontsize=8) |
| 221 | +watermark(axz) |
| 222 | + |
| 223 | +fig.tight_layout(rect=(0, 0, 1, 0.96)) |
| 224 | +outpath = os.path.join(OUTDIR, OUTNAME) |
| 225 | +fig.savefig(outpath, dpi=140) |
| 226 | +print(f"\n[ok] wrote {outpath}") |
| 227 | + |
| 228 | +# ---------------------------------------------------------------------------- |
| 229 | +# Console summary (sanity numbers) |
| 230 | +# ---------------------------------------------------------------------------- |
| 231 | +''' |
| 232 | +print("\nPeak |median| offset per component (over plotted drift bins):") |
| 233 | +for comp, label in (("x", "|dx| bulk"), ("y", "dy top/bot"), ("z", "dz up/dn")): |
| 234 | + for tpc in ("E", "W"): |
| 235 | + x, y, z, v = data[(comp, tpc)] |
| 236 | + xlo, xhi = TPC_XRANGE[tpc] |
| 237 | + inx = (x >= xlo) & (x <= xhi) |
| 238 | + if comp == "x": |
| 239 | + mask = inx & (np.abs(y) <= BULK_ABS_Y_MAX) & (z >= BULK_Z_MIN) & (z <= BULK_Z_MAX) |
| 240 | + vals = np.abs(v) |
| 241 | + elif comp == "y": |
| 242 | + mask = inx & ((y >= TOP_Y_MIN) | (y <= BOT_Y_MAX)) |
| 243 | + vals = v |
| 244 | + else: |
| 245 | + mask = inx & ((z <= UPSTREAM_Z_MAX) | (z >= DOWNSTREAM_Z_MIN)) |
| 246 | + vals = v |
| 247 | + _, m, _ = drift_profile(x, vals, mask) |
| 248 | + peak = np.max(np.abs(m)) if m.size else float("nan") |
| 249 | + print(f" {label:12s} {TPC_LABEL[tpc]:5s}: {peak:6.3f} cm") |
| 250 | +''' |
| 251 | +print("\nPeak |median| offset per component / region (over plotted drift bins):") |
| 252 | +def _peak(x, v, mask): |
| 253 | + _, m, _ = drift_profile(x, v, mask) |
| 254 | + return np.max(np.abs(m)) if m.size else float("nan") |
| 255 | + |
| 256 | +for tpc in ("E", "W"): |
| 257 | + xlo, xhi = TPC_XRANGE[tpc] |
| 258 | + x, y, z, v = data[("x", tpc)]; inx = (x >= xlo) & (x <= xhi) |
| 259 | + bulk = inx & (np.abs(y) <= BULK_ABS_Y_MAX) & (z >= BULK_Z_MIN) & (z <= BULK_Z_MAX) |
| 260 | + print(f" |dx| bulk {TPC_LABEL[tpc]:5s}: {_peak(x, np.abs(v), bulk):6.3f} cm") |
| 261 | + x, y, z, v = data[("y", tpc)]; inx = (x >= xlo) & (x <= xhi) |
| 262 | + print(f" dy top {TPC_LABEL[tpc]:5s}: {_peak(x, v, inx & (y >= TOP_Y_MIN)):6.3f} cm") |
| 263 | + print(f" dy bottom {TPC_LABEL[tpc]:5s}: {_peak(x, v, inx & (y <= BOT_Y_MAX)):6.3f} cm") |
| 264 | + x, y, z, v = data[("z", tpc)]; inx = (x >= xlo) & (x <= xhi) |
| 265 | + print(f" dz upstream {TPC_LABEL[tpc]:5s}: {_peak(x, v, inx & (z <= UPSTREAM_Z_MAX)):6.3f} cm") |
| 266 | + print(f" dz downstream {TPC_LABEL[tpc]:5s}: {_peak(x, v, inx & (z >= DOWNSTREAM_Z_MIN)):6.3f} cm") |
0 commit comments