Skip to content

Commit 5ca9707

Browse files
committed
sbnd/sbnd_abhat: Stage 3a transverse SCE map + closure (y, z)
Adds the transverse SCE map expectation and the reco-vs-map closure, plus a unified SUMMARY.txt covering all three components. New scripts: make_transverse_profiles.py -> plot 08 (map-level Dy/Dz vs drift, Lane convention) make_validation_3d.py -> plots 09 (q-pairing check), 10 (transverse residual), 11 (transverse closure) PNGs live in pics/ alongside the Stage-1 01-03. make_validation_plots.py renamed -> make_validation_plots_stage1.py [Stage 1, plots 01-03, superseded; kept for lineage]. Behavior changes [intentional] 1. clus_all_apa clustering now runs on fully-corrected coordinates. Previously the clustering pass saw (x_sce, y_raw, z_raw); now it sees (x_sce, y_sce, z_sce). Validated: per-point Dx residual is identical to the Stage-2 baseline (1.83 / 2.23 um E/W), confirming clustering does not perturb the X closure. 2. Validation pairing changed from (y, z, q) to q-only. Necessitated by (1): once y and z are also corrected, the old key would compare corrected sce-side (y, z) to uncorrected img-side (y, z) and fail trivially. q-only matches 226721 / 228762 points (99.1%); 2041 (0.9%) dropped on ambiguous q. Closure numbers (50 events): Dx rms (cm): E = 1.83e-04 W = 2.23e-04 max = 5.05e-04 Dy rms (cm): E = 2.63e-04 W = 1.65e-04 max = 9.91e-04 Dz rms (cm): E = 3.35e-04 W = 2.99e-04 max = 9.50e-04 Pooled W/E |Dx| = 1.326 [map vol-avg 1.276; 0.33-era reco 1.271]. The unchanged Dx baseline is the proof neither behavior change is physics-breaking.
1 parent 401e749 commit 5ca9707

9 files changed

Lines changed: 458 additions & 7 deletions

sbnd/sbnd_abhat/SUMMARY.txt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
SBND WCT 0.36 SCECorrection validation -- 50 crossing-muon events
2-
points: 228404 (East 70503, West 157901)
3-
per-point residual reco-map: rms E=1.83e-04 W=2.23e-04 cm, max 8.42e-04 cm
4-
pooled mean|Dx|: East=0.4021 West=0.5338 W/E=1.327
2+
points: 226721 paired by q (East 69913, West 156808); 226721/228762 matched, 2041 dropped (ambiguous q)
3+
=== Per-point reco-vs-map closure ===
4+
Dx rms (cm): E = 1.83e-04 W = 2.23e-04 max = 5.05e-04
5+
Dy rms (cm): E = 2.63e-04 W = 1.65e-04 max = 9.91e-04 [Stage 3a]
6+
Dz rms (cm): E = 3.35e-04 W = 2.99e-04 max = 9.50e-04 [Stage 3a]
7+
=> reproduces the TH3 backward map to interpolation precision in all three components
8+
pooled mean|Dx|: East=0.4026 West=0.5340 W/E=1.326
59
references: map vol-avg 1.276, 0.33-era reco 1.271

sbnd/sbnd_abhat/clus.jsonnet

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,17 @@ local LeventNo = std.parseInt(initial_eventNo);
2121

2222
local common_coords = ["x", "y", "z"];
2323
local common_corr_coords = ["x_t0cor", "y", "z"];
24-
local common_sce_coords = ["x_sce", "y", "z"];
24+
local common_sce_coords = ["x_sce", "y_sce", "z_sce"];
2525

2626

27+
local sce_field = {
28+
type: "SCEFieldTH3",
29+
name: "sbnd_dualmap",
30+
data: {
31+
sce_map_file: "/cvmfs/sbnd.opensciencegrid.org/products/sbnd/sbnd_data/v01_42_00/SCEoffsets/SCEoffsets_SBND_E500_dualmap_CV_voxelTH3.root",
32+
},
33+
};
34+
2735
local dvm = {
2836
overall: {
2937
FV_xmin: -202.5 * wc.cm,
@@ -51,7 +59,7 @@ local dvm = {
5159
FV_xmax: -0.45 * wc.cm,
5260
FV_xmin_margin: 2 * wc.cm,
5361
FV_xmax_margin: 2 * wc.cm,
54-
sce_map_file: "/cvmfs/sbnd.opensciencegrid.org/products/sbnd/sbnd_data/v01_42_00/SCEoffsets/SCEoffsets_SBND_E500_dualmap_CV_voxelTH3.root",
62+
sce_field: wc.tn(sce_field),
5563
},
5664
a1f0pA: $.a0f0pA + {
5765
FV_xmin: 0.45 * wc.mm,
@@ -81,7 +89,7 @@ local pctransforms(dv) = {
8189
type: "PCTransformSet",
8290
name: dv.name,
8391
data: { detector_volumes: wc.tn(dv) },
84-
uses: [dv]
92+
uses: [dv, sce_field]
8593
};
8694

8795

@@ -343,7 +351,15 @@ local clus_all_apa (
343351
detector: "sbnd",
344352
algorithm: "sce",
345353
pcname: "3d",
346-
coords: common_sce_coords, // ["x_sce","y","z"]
354+
coords: ["x_sce", "y", "z"], // original y,z kept for (y,z,q) pairing
355+
individual: false
356+
},
357+
{
358+
name: "sce3d", // full 3D SCE-corrected coords (transverse closure)
359+
detector: "sbnd",
360+
algorithm: "sce3d",
361+
pcname: "3d",
362+
coords: ["x_sce", "y_sce", "z_sce"],
347363
individual: false
348364
}
349365
],
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
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

Comments
 (0)