Belle II Software development
KLMEventT0Validation.py
1#!/usr/bin/env python3
2
3
10
11"""
12KLMEventT0ValidationModule
13===========================
14A basf2.Module subclass that runs in the same basf2 path as
15KLMEventT0Estimator and fills validation histograms for the per-track
16T0 estimation quality.
17
18Per-hit T0 is computed as::
19
20 T0_est = Trec - Tcable - Tprop - Tfly
21
22where:
23 Trec = KLMDigit::getTime() (raw digit time)
24 Tcable = KLMTimeCableDelay payload (cable delay per channel)
25 Tprop = dist * delay_const (propagation along strip,
26 KLMTimeConstants payload)
27 Tfly = 0.5 * (entry_TOF + exit_TOF) (particle flight time from
28 matched ExtHit pair)
29
30This exactly mirrors the correction chain in KLMEventT0EstimatorModule.
31
32Histograms produced (directory structure):
33
34 per_track/
35 h_t0trk_bklm_scint, h_t0trk_bklm_rpc, h_t0trk_eklm_scint
36
37 per_event/
38 h_t0evt_{bklm_scint,bklm_rpc,eklm_scint,all}
39 h_final_source
40
41 diagnostics/
42 h_nhits_pertrk_{bklm_scint,eklm_scint}
43 h_sem_pertrk_{bklm_scint,eklm_scint}
44 h_digitQ_{bklm_scint,eklm_scint}
45 h_sample_type
46 h_T{rec,cable,prop,fly}_{bklm_scint,bklm_rpc,eklm_scint}
47 h_dimuon_{all,bklm_scint,bklm_rpc,eklm_scint,scint_only,with_rpc,with_rpc_dir}
48 h_per_track_resolution -- σ(ΔT0)/√2, 6 categories (filled in terminate)
49
50 pulls/
51 h_pull_{bklm_scint,eklm_scint,rpc_phi,rpc_z,bklm_rpc}
52 h_pull_{B_vs_E,B_vs_R,E_vs_R}
53 h_pull_summary_{mean,width}
54 sector/
55 h2_pull_{bklm_scint,eklm_fwd_bwd,rpc_phi,rpc_z}_{mean,sigma} -- 2D summary TH2D
56 bklm_scint/ -- 8x8 pairwise pull histograms
57 eklm_scint_fwd_vs_bwd/ -- 4x4 pairwise
58 rpc_phi/ -- 8x8 pairwise
59 rpc_z/ -- 8x8 pairwise
60
61 residuals/ -- same structure as pulls but unnormalized (ns)
62
63 cross_detector/
64 h_deltaT0_<RegA>_vs_<RegB> -- 4x4 matrix (4 detector regions)
65 h2_deltaT0_{mean,sigma,entries}
66
67 final/
68 h_t0evt_final_{scint_only,with_rpc,with_rpc_dir}
69
70Usage
71-----
72 from klm.validation.KLMEventT0Validation import KLMEventT0ValidationModule
73 path.add_module(KLMEventT0ValidationModule(
74 muon_list_name='mu+:forT0',
75 output_file='KLMEventT0Validation.root',
76 ))
77"""
78
79import ctypes
80import math
81
82import basf2
83import ROOT
84from ROOT import Belle2, TFile, TH1D, TH1I, TH2D, gROOT, TF1
85
86gROOT.SetBatch(True)
87ROOT.gSystem.Load('libklm')
88ROOT.gInterpreter.Declare('#include "klm/bklm/geometry/GeometryPar.h"')
89ROOT.gInterpreter.Declare('#include "klm/eklm/geometry/GeometryData.h"')
90ROOT.gInterpreter.Declare('#include "klm/eklm/geometry/TransformData.h"')
91
92# ---------------------------------------------------------------------------
93# Constants mirroring the C++ module
94# ---------------------------------------------------------------------------
95_N_BKLM_SECTORS = 8
96_N_EKLM_SECTORS = 4 # per endcap (sectors 1-4)
97_N_REGIONS = 4 # EKLM Bwd / EKLM Fwd / BKLM RPC / BKLM Scint
98_REG_EKLM_BWD = 0
99_REG_EKLM_FWD = 1
100_REG_BKLM_RPC = 2
101_REG_BKLM_SCINT = 3
102
103# EKLM section numbers: 1 = backward (z<0), 2 = forward (z>0)
104_EKLM_SECTION_BWD = 1
105_EKLM_SECTION_FWD = 2
106
107# basf2 / CLHEP unit conversions
108# basf2 positions are in cm; CLHEP uses mm internally.
109# Belle2::Unit::mm = 0.1 (1 mm = 0.1 basf2-units = 0.1 cm)
110# CLHEP::mm = 1.0
111_BASF2_TO_MM = 10.0 # multiply cm → mm
112_MM_TO_BASF2 = 0.1 # multiply mm → cm (= Unit::mm / CLHEP::mm)
113
114
115# ---------------------------------------------------------------------------
116# Small statistics helpers
117# ---------------------------------------------------------------------------
118
119def _mu_sem(values):
120 """Return (mean, SEM) for a list, or (None, None) if empty."""
121 n = len(values)
122 if n == 0:
123 return None, None
124 mu = sum(values) / n
125 if n == 1:
126 return mu, 0.0
127 ss = sum((x - mu) ** 2 for x in values)
128 return mu, math.sqrt(ss / (n - 1) / n)
129
130
131def _weighted_mean(pairs):
132 """
133 Inverse-variance weighted mean.
134
135 Parameters
136 ----------
137 pairs : list of (mu, sem) tuples
138
139 Returns
140 -------
141 (mu, sem) or (None, None)
142 """
143 if not pairs:
144 return None, None
145 valid = [(mu, se) for mu, se in pairs
146 if se is not None and se > 0.0 and math.isfinite(mu)]
147 if valid:
148 wsum = sum(1.0 / (se * se) for _, se in valid)
149 wtsum = sum(mu / (se * se) for mu, se in valid)
150 if wsum > 0.0:
151 return wtsum / wsum, math.sqrt(1.0 / wsum)
152 # Fallback: simple mean
153 vals = [mu for mu, _ in pairs if math.isfinite(mu)]
154 if not vals:
155 return None, None
156 return sum(vals) / len(vals), None
157
158
159def _gauss_fit(hist, fit_lo=-5.0, fit_hi=5.0):
160 """
161 Fit a Gaussian to *hist* and return (mean, meanErr, sigma, sigmaErr).
162 Returns all None on failure or insufficient statistics.
163 """
164 if hist is None or hist.GetEntries() < 30:
165 return None, None, None, None
166 gaus = TF1('_gfit', 'gaus', fit_lo, fit_hi)
167 gaus.SetParameters(hist.GetMaximum(), hist.GetMean(), hist.GetStdDev())
168 gaus.SetParLimits(2, 0.01, abs(fit_hi - fit_lo))
169 status = hist.Fit(gaus, 'LQN0', '', fit_lo, fit_hi)
170 if status != 0:
171 gaus.Delete()
172 return None, None, None, None
173 result = (gaus.GetParameter(1), gaus.GetParError(1),
174 abs(gaus.GetParameter(2)), gaus.GetParError(2))
175 gaus.Delete()
176 return result
177
178
179# ---------------------------------------------------------------------------
180# Module class
181# ---------------------------------------------------------------------------
182
183class KLMEventT0ValidationModule(basf2.Module):
184 """
185 Full validation module for KLMEventT0Estimator.
186
187 Recomputes per-track T0 using the same correction chain as the C++
188 estimator (Trec - Tcable - Tprop - Tfly) and fills the complete set
189 of validation histograms that were present in the monolithic Trash
190 module: per-track/per-event T0, timing components, pulls, residuals,
191 cross-detector ΔT0, dimuon ΔT0, pairwise sector histograms and their
192 2D Gaussian-fit summaries.
193 """
194
195 def __init__(self,
196 muon_list_name: str = 'mu+:forT0',
197 output_file: str = 'KLMEventT0Validation.root',
198 opposite_charges_only: bool = True,
199 adc_cut_bklm_scint_min: float = 30.0,
200 adc_cut_bklm_scint_max: float = 320.0,
201 adc_cut_eklm_scint_min: float = 40.0,
202 adc_cut_eklm_scint_max: float = 350.0):
203 """Initialise the module with histogram booking parameters and ADC cuts."""
204 super().__init__()
205 self.set_name('KLMEventT0ValidationModule')
206
207 self._muon_list_name = muon_list_name
208
209 self._output_file_path = output_file
210
211 self._opp_charges_only = opposite_charges_only
212
213 self._adc_bklm_min = adc_cut_bklm_scint_min
214
215 self._adc_bklm_max = adc_cut_bklm_scint_max
216
217 self._adc_eklm_min = adc_cut_eklm_scint_min
218
219 self._adc_eklm_max = adc_cut_eklm_scint_max
220
221
222 self._outfile = None
223
224 self._h = {}
225
226
227 self._geo_bklm = None
228
229 self._geo_eklm = None
230
231 self._transform_eklm = None
232
233 self._elem_num = None
234
235
236 self._delay_eklm = 0.0
237
238 self._delay_bklm = 0.0
239
240 self._delay_rpc_phi = 0.0
241
242 self._delay_rpc_z = 0.0
243
244 self._db_cable_delay = None
245
247
248 # ------------------------------------------------------------------
249 # Lifecycle
250 # ------------------------------------------------------------------
251
252 def initialize(self):
253 """Open output file, book histograms, and initialise geometry."""
254 self._outfile = TFile(self._output_file_path, 'RECREATE')
255 self._book_histograms()
256
260 True, Belle2.EKLM.TransformData.c_None)
262
263 # Register DB dependencies
265 'KLMTimeCableDelay', Belle2.KLMTimeCableDelay.Class())
267 'KLMChannelStatus', Belle2.KLMChannelStatus.Class())
268
269 def beginRun(self):
270 """Cache run-dependent DB constants from KLMTimeConstants payload."""
271 tc = Belle2.PyDBObj('KLMTimeConstants', Belle2.KLMTimeConstants.Class())
272 if tc.isValid():
273 obj = tc.obj()
274 self._delay_eklm = obj.getDelay(Belle2.KLMTimeConstants.c_EKLM)
275 self._delay_bklm = obj.getDelay(Belle2.KLMTimeConstants.c_BKLM)
276 self._delay_rpc_phi = obj.getDelay(Belle2.KLMTimeConstants.c_RPCPhi)
277 self._delay_rpc_z = obj.getDelay(Belle2.KLMTimeConstants.c_RPCZ)
278 else:
279 basf2.B2WARNING('KLMEventT0Validation: KLMTimeConstants not available; '
280 'propagation delays set to zero.')
281 self._delay_eklm = self._delay_bklm = 0.0
282 self._delay_rpc_phi = self._delay_rpc_z = 0.0
283
284 def terminate(self):
285 """Fit summary histograms, write all histograms, and close the file."""
287 if self._outfile:
288 self._outfile.Write('', ROOT.TObject.kOverwrite)
289 self._outfile.Close()
290 basf2.B2INFO(f'KLMEventT0ValidationModule: wrote {self._output_file_path}')
291
292 # ------------------------------------------------------------------
293 # Histogram booking
294 # ------------------------------------------------------------------
295
297 """Create all validation histograms inside the output ROOT file."""
298 h = self._h
299 f = self._outfile
300
301 def H1(name, title, nb, lo, hi):
302 return TH1D(name, title, nb, lo, hi)
303
304 def H1I(name, title, nb, lo, hi):
305 return TH1I(name, title, nb, lo, hi)
306
307 # --- per_track/ ---
308 f.mkdir('per_track').cd()
309 h['t0trk_B'] = H1('h_t0trk_bklm_scint',
310 'Per-track T_{0} (BKLM Scint);T_{0} [ns]', 800, -100, 100)
311 h['t0trk_R'] = H1('h_t0trk_bklm_rpc',
312 'Per-track T_{0} (BKLM RPC);T_{0} [ns]', 800, -100, 100)
313 h['t0trk_E'] = H1('h_t0trk_eklm_scint',
314 'Per-track T_{0} (EKLM Scint);T_{0} [ns]', 800, -100, 100)
315
316 # --- per_event/ ---
317 f.mkdir('per_event').cd()
318 h['t0evt_B'] = H1('h_t0evt_bklm_scint',
319 'Per-event T_{0} (BKLM Scint);T_{0} [ns]', 800, -100, 100)
320 h['t0evt_R'] = H1('h_t0evt_bklm_rpc',
321 'Per-event T_{0} (BKLM RPC);T_{0} [ns]', 800, -100, 100)
322 h['t0evt_E'] = H1('h_t0evt_eklm_scint',
323 'Per-event T_{0} (EKLM Scint);T_{0} [ns]', 800, -100, 100)
324 h['t0evt_all'] = H1('h_t0evt_all',
325 'Per-event T_{0} (all KLM);T_{0} [ns]', 800, -100, 100)
326 h['final_source'] = H1I('h_final_source', 'Final KLM source;;events', 7, 0.5, 7.5)
327 for ib, lab in enumerate(['B only', 'E only', 'R only',
328 'B+E', 'B+R', 'E+R', 'B+E+R'], 1):
329 h['final_source'].GetXaxis().SetBinLabel(ib, lab)
330
331 # --- diagnostics/ ---
332 f.mkdir('diagnostics').cd()
333 h['nhits_B'] = H1I('h_nhits_pertrk_bklm_scint',
334 'Hits per track (BKLM Scint);N_{hits}', 50, 0, 50)
335 h['nhits_E'] = H1I('h_nhits_pertrk_eklm_scint',
336 'Hits per track (EKLM Scint);N_{hits}', 50, 0, 50)
337 h['sem_B'] = H1('h_sem_pertrk_bklm_scint',
338 'SEM per track (BKLM Scint);SEM [ns]', 200, 0.0, 10.0)
339 h['sem_E'] = H1('h_sem_pertrk_eklm_scint',
340 'SEM per track (EKLM Scint);SEM [ns]', 200, 0.0, 10.0)
341 h['digitQ_B'] = H1('h_digitQ_bklm_scint',
342 'KLMDigit charge (BKLM Scint);ADC (a.u.)', 100, 0, 800)
343 h['digitQ_E'] = H1('h_digitQ_eklm_scint',
344 'KLMDigit charge (EKLM Scint);ADC (a.u.)', 100, 0, 800)
345 h['sample_type'] = H1I('h_sample_type', 'Sample type;;events', 2, 0.5, 2.5)
346 h['sample_type'].GetXaxis().SetBinLabel(1, 'Data')
347 h['sample_type'].GetXaxis().SetBinLabel(2, 'MC')
348
349 # Timing components per detector type
350 _timing_cfg = {
351 'B': ('BKLM Scint', -5000, -4000, -5000, -4000),
352 'R': ('BKLM RPC', -800, -500, -800, -500),
353 'E': ('EKLM Scint', -5000, -4000, -5000, -4000),
354 }
355 for key, (label, r0, r1, rc0, rc1) in _timing_cfg.items():
356 h[f'Trec_{key}'] = H1(f'h_Trec_{key.lower()}',
357 f'T_{{rec}} ({label});time [ns]', 800, r0, r1)
358 h[f'Tcable_{key}'] = H1(f'h_Tcable_{key.lower()}',
359 f'T_{{cable}} ({label});time [ns]', 800, rc0, rc1)
360 h[f'Tprop_{key}'] = H1(f'h_Tprop_{key.lower()}',
361 f'T_{{prop}} ({label});time [ns]', 800, -50, 50)
362 h[f'Tfly_{key}'] = H1(f'h_Tfly_{key.lower()}',
363 f'T_{{fly}} ({label});time [ns]', 800, -100, 100)
364
365 # --- dimuon (inside diagnostics/, matching C++ module layout) ---
366 self._safe_cd(f, 'diagnostics')
367 h['dimuon_all'] = H1('h_dimuon_all',
368 'Dimuon #DeltaT_{0} (all KLM);T_{0}(#mu^{+})-T_{0}(#mu^{-}) [ns]',
369 400, -50, 50)
370 h['dimuon_B'] = H1('h_dimuon_bklm_scint',
371 'Dimuon #DeltaT_{0} (BKLM Scint);#DeltaT_{0} [ns]', 400, -50, 50)
372 h['dimuon_R'] = H1('h_dimuon_bklm_rpc',
373 'Dimuon #DeltaT_{0} (BKLM RPC);#DeltaT_{0} [ns]', 400, -50, 50)
374 h['dimuon_E'] = H1('h_dimuon_eklm_scint',
375 'Dimuon #DeltaT_{0} (EKLM Scint);#DeltaT_{0} [ns]', 400, -50, 50)
376 h['dimuon_scint'] = H1('h_dimuon_scint_only',
377 'Dimuon #DeltaT_{0} (Scint only);#DeltaT_{0} [ns]', 400, -50, 50)
378 h['dimuon_rpc'] = H1('h_dimuon_with_rpc',
379 'Dimuon #DeltaT_{0} (With RPC);#DeltaT_{0} [ns]', 400, -50, 50)
380 h['dimuon_rpcdir'] = H1('h_dimuon_with_rpc_dir',
381 'Dimuon #DeltaT_{0} (With RPC dir);#DeltaT_{0} [ns]', 400, -50, 50)
382 # Resolution summary (filled in terminate() from Gaussian fits)
383 # 6 categories: BKLM Scint, BKLM RPC, EKLM Scint, Scint Only, With RPC, With RPC Dir
384 h['resolution'] = H1('h_per_track_resolution',
385 'Per-Track T_{0} Resolution (#sigma_{#DeltaT0}/#sqrt{2});'
386 'Category;Resolution [ns]', 6, 0.5, 6.5)
387 for ib, lab in enumerate(
388 ['BKLM Scint', 'BKLM RPC', 'EKLM Scint', 'Scint Only', 'With RPC', 'With RPC Dir'], 1):
389 h['resolution'].GetXaxis().SetBinLabel(ib, lab)
390
391 # --- pulls/ ---
392 f.mkdir('pulls').cd()
393 h['pull_B'] = H1('h_pull_bklm_scint',
394 'Pull (BKLM Scint);(T_{0,i}-T_{0,j})/#sigma', 200, -10, 10)
395 h['pull_E'] = H1('h_pull_eklm_scint',
396 'Pull (EKLM Scint);(T_{0,i}-T_{0,j})/#sigma', 200, -10, 10)
397 h['pull_Rphi'] = H1('h_pull_rpc_phi',
398 'Pull (BKLM RPC Phi);(T_{0,i}-T_{0,j})/#sigma', 200, -10, 10)
399 h['pull_Rz'] = H1('h_pull_rpc_z',
400 'Pull (BKLM RPC Z);(T_{0,i}-T_{0,j})/#sigma', 200, -10, 10)
401 h['pull_R'] = H1('h_pull_bklm_rpc',
402 'Pull (BKLM RPC combined);(T_{0,i}-T_{0,j})/#sigma', 200, -10, 10)
403 h['pull_BvE'] = H1('h_pull_B_vs_E',
404 'Pull (BKLM Scint vs EKLM Scint);pull', 200, -10, 10)
405 h['pull_BvR'] = H1('h_pull_B_vs_R',
406 'Pull (BKLM Scint vs BKLM RPC);pull', 200, -10, 10)
407 h['pull_EvR'] = H1('h_pull_E_vs_R',
408 'Pull (EKLM Scint vs BKLM RPC);pull', 200, -10, 10)
409 h['pull_smean'] = H1('h_pull_summary_mean',
410 'Pull Mean;Category;#mu', 4, 0.5, 4.5)
411 h['pull_swidth'] = H1('h_pull_summary_width',
412 'Pull Sigma;Category;#sigma', 4, 0.5, 4.5)
413 for ib, lab in enumerate(['BKLM Scint', 'EKLM Scint', 'RPC Phi', 'RPC Z'], 1):
414 h['pull_smean'].GetXaxis().SetBinLabel(ib, lab)
415 h['pull_swidth'].GetXaxis().SetBinLabel(ib, lab)
416
417 # Pairwise sector pull histograms (each in its own subdirectory)
418 h['ppw_B'] = self._book_pairwise(
419 f, 'pulls/sector/bklm_scint', 'h_pull_B_s{i}_vs_s{j}',
420 'BKLM Scint Pull: Sec {i} vs Sec {j};pull',
421 _N_BKLM_SECTORS, 200, -10, 10)
422 h['ppw_E'] = self._book_pairwise(
423 f, 'pulls/sector/eklm_scint_fwd_vs_bwd',
424 'h_pull_E_fwd{i}_vs_bwd{j}',
425 'EKLM Pull: Fwd Sec {i} vs Bwd Sec {j};pull',
426 _N_EKLM_SECTORS, 200, -10, 10, offset=1)
427 h['ppw_Rphi'] = self._book_pairwise(
428 f, 'pulls/sector/rpc_phi', 'h_pull_Rphi_s{i}_vs_s{j}',
429 'RPC Phi Pull: Sec {i} vs Sec {j};pull',
430 _N_BKLM_SECTORS, 200, -10, 10)
431
432 h['ppw_Rz'] = self._book_pairwise(
433 f, 'pulls/sector/rpc_z', 'h_pull_Rz_s{i}_vs_s{j}',
434 'RPC Z Pull: Sec {i} vs Sec {j};pull',
435 _N_BKLM_SECTORS, 200, -10, 10)
436
437 # 2D summary histograms live in pulls/sector/ (matching C++ layout)
438 self._safe_cd(f, 'pulls/sector')
439 h['ppw_2d_B_mean'] = self._book_2d_summary(
440 'h2_pull_bklm_scint_mean', 'Pull Mean (BKLM Scint);Sec 1;Sec 2',
441 _N_BKLM_SECTORS)
442 h['ppw_2d_B_sigma'] = self._book_2d_summary(
443 'h2_pull_bklm_scint_sigma', 'Pull Sigma (BKLM Scint);Sec 1;Sec 2',
444 _N_BKLM_SECTORS)
445 h['ppw_2d_E_mean'] = self._book_2d_summary(
446 'h2_pull_eklm_fwd_bwd_mean', 'Pull Mean (EKLM Fwd vs Bwd);Fwd Sec;Bwd Sec',
447 _N_EKLM_SECTORS, lo=0.5)
448 h['ppw_2d_E_sigma'] = self._book_2d_summary(
449 'h2_pull_eklm_fwd_bwd_sigma', 'Pull Sigma (EKLM Fwd vs Bwd);Fwd Sec;Bwd Sec',
450 _N_EKLM_SECTORS, lo=0.5)
451 h['ppw_2d_Rphi_mean'] = self._book_2d_summary(
452 'h2_pull_rpc_phi_mean', 'Pull Mean (RPC Phi);Sec 1;Sec 2', _N_BKLM_SECTORS)
453 h['ppw_2d_Rphi_sigma'] = self._book_2d_summary(
454 'h2_pull_rpc_phi_sigma', 'Pull Sigma (RPC Phi);Sec 1;Sec 2', _N_BKLM_SECTORS)
455 h['ppw_2d_Rz_mean'] = self._book_2d_summary(
456 'h2_pull_rpc_z_mean', 'Pull Mean (RPC Z);Sec 1;Sec 2', _N_BKLM_SECTORS)
457 h['ppw_2d_Rz_sigma'] = self._book_2d_summary(
458 'h2_pull_rpc_z_sigma', 'Pull Sigma (RPC Z);Sec 1;Sec 2', _N_BKLM_SECTORS)
459
460 # --- residuals/ ---
461 f.mkdir('residuals').cd()
462 h['res_B'] = H1('h_residual_bklm_scint',
463 'Residual (BKLM Scint);#DeltaT_{0} [ns]', 200, -50, 50)
464 h['res_E'] = H1('h_residual_eklm_scint',
465 'Residual (EKLM Scint);#DeltaT_{0} [ns]', 200, -50, 50)
466 h['res_Rphi'] = H1('h_residual_rpc_phi',
467 'Residual (BKLM RPC Phi);#DeltaT_{0} [ns]', 200, -50, 50)
468 h['res_Rz'] = H1('h_residual_rpc_z',
469 'Residual (BKLM RPC Z);#DeltaT_{0} [ns]', 200, -50, 50)
470 h['res_R'] = H1('h_residual_bklm_rpc',
471 'Residual (BKLM RPC combined);#DeltaT_{0} [ns]', 200, -50, 50)
472 h['res_BvE'] = H1('h_residual_B_vs_E',
473 'Residual BKLM Scint - EKLM Scint;#DeltaT_{0} [ns]', 200, -50, 50)
474 h['res_BvR'] = H1('h_residual_B_vs_R',
475 'Residual BKLM Scint - BKLM RPC;#DeltaT_{0} [ns]', 200, -50, 50)
476 h['res_EvR'] = H1('h_residual_E_vs_R',
477 'Residual EKLM Scint - BKLM RPC;#DeltaT_{0} [ns]', 200, -50, 50)
478 h['res_smean'] = H1('h_residual_summary_mean',
479 'Residual Mean;Category;#mu [ns]', 4, 0.5, 4.5)
480 h['res_swidth'] = H1('h_residual_summary_width',
481 'Residual Sigma;Category;#sigma [ns]', 4, 0.5, 4.5)
482 for ib, lab in enumerate(['BKLM Scint', 'EKLM Scint', 'RPC Phi', 'RPC Z'], 1):
483 h['res_smean'].GetXaxis().SetBinLabel(ib, lab)
484 h['res_swidth'].GetXaxis().SetBinLabel(ib, lab)
485
486 # Pairwise sector residual histograms (each in its own subdirectory)
487 h['rpw_B'] = self._book_pairwise(
488 f, 'residuals/sector/bklm_scint', 'h_residual_B_s{i}_vs_s{j}',
489 'BKLM Scint Residual: Sec {i} vs Sec {j};#DeltaT_{{0}} [ns]',
490 _N_BKLM_SECTORS, 200, -50, 50)
491 h['rpw_E'] = self._book_pairwise(
492 f, 'residuals/sector/eklm_scint_fwd_vs_bwd',
493 'h_residual_E_fwd{i}_vs_bwd{j}',
494 'EKLM Residual: Fwd Sec {i} vs Bwd Sec {j};#DeltaT_{{0}} [ns]',
495 _N_EKLM_SECTORS, 200, -50, 50, offset=1)
496 h['rpw_Rphi'] = self._book_pairwise(
497 f, 'residuals/sector/rpc_phi', 'h_residual_Rphi_s{i}_vs_s{j}',
498 'RPC Phi Residual: Sec {i} vs Sec {j};#DeltaT_{{0}} [ns]',
499 _N_BKLM_SECTORS, 200, -50, 50)
500 h['rpw_Rz'] = self._book_pairwise(
501 f, 'residuals/sector/rpc_z', 'h_residual_Rz_s{i}_vs_s{j}',
502 'RPC Z Residual: Sec {i} vs Sec {j};#DeltaT_{{0}} [ns]',
503 _N_BKLM_SECTORS, 200, -50, 50)
504
505 # 2D summary histograms live in residuals/sector/ (matching C++ layout)
506 self._safe_cd(f, 'residuals/sector')
507 h['rpw_2d_B_mean'] = self._book_2d_summary(
508 'h2_residual_bklm_scint_mean', 'Residual Mean (BKLM Scint);Sec 1;Sec 2',
509 _N_BKLM_SECTORS)
510 h['rpw_2d_B_sigma'] = self._book_2d_summary(
511 'h2_residual_bklm_scint_sigma', 'Residual Sigma (BKLM Scint);Sec 1;Sec 2',
512 _N_BKLM_SECTORS)
513 h['rpw_2d_E_mean'] = self._book_2d_summary(
514 'h2_residual_eklm_fwd_bwd_mean',
515 'Residual Mean (EKLM Fwd vs Bwd);Fwd Sec;Bwd Sec',
516 _N_EKLM_SECTORS, lo=0.5)
517 h['rpw_2d_E_sigma'] = self._book_2d_summary(
518 'h2_residual_eklm_fwd_bwd_sigma',
519 'Residual Sigma (EKLM Fwd vs Bwd);Fwd Sec;Bwd Sec',
520 _N_EKLM_SECTORS, lo=0.5)
521 h['rpw_2d_Rphi_mean'] = self._book_2d_summary(
522 'h2_residual_rpc_phi_mean', 'Residual Mean (RPC Phi);Sec 1;Sec 2',
523 _N_BKLM_SECTORS)
524 h['rpw_2d_Rphi_sigma'] = self._book_2d_summary(
525 'h2_residual_rpc_phi_sigma', 'Residual Sigma (RPC Phi);Sec 1;Sec 2',
526 _N_BKLM_SECTORS)
527 h['rpw_2d_Rz_mean'] = self._book_2d_summary(
528 'h2_residual_rpc_z_mean', 'Residual Mean (RPC Z);Sec 1;Sec 2',
529 _N_BKLM_SECTORS)
530 h['rpw_2d_Rz_sigma'] = self._book_2d_summary(
531 'h2_residual_rpc_z_sigma', 'Residual Sigma (RPC Z);Sec 1;Sec 2',
532 _N_BKLM_SECTORS)
533
534 # --- cross_detector/ 4x4 matrix ---
535 f.mkdir('cross_detector').cd()
536 _reg_names = ['EKLM_Bwd', 'EKLM_Fwd', 'BKLM_RPC', 'BKLM_Scint']
537 _reg_labels = ['EKLM Backward', 'EKLM Forward', 'BKLM RPC', 'BKLM Scint']
538 h['dt0'] = {}
539 for ri in range(_N_REGIONS):
540 h['dt0'][ri] = {}
541 for rj in range(_N_REGIONS):
542 h['dt0'][ri][rj] = H1(
543 f'h_deltaT0_{_reg_names[ri]}_vs_{_reg_names[rj]}',
544 f'#DeltaT_{{0}}: {_reg_labels[ri]} vs {_reg_labels[rj]};'
545 f'#DeltaT_{{0}} [ns]', 200, -50, 50)
546 h['dt0_2d_mean'] = TH2D('h2_deltaT0_mean',
547 '#DeltaT_{0} Mean by Region;Region 1;Region 2',
548 _N_REGIONS, -0.5, _N_REGIONS - 0.5,
549 _N_REGIONS, -0.5, _N_REGIONS - 0.5)
550 h['dt0_2d_sigma'] = TH2D('h2_deltaT0_sigma',
551 '#DeltaT_{0} Sigma by Region;Region 1;Region 2',
552 _N_REGIONS, -0.5, _N_REGIONS - 0.5,
553 _N_REGIONS, -0.5, _N_REGIONS - 0.5)
554 h['dt0_2d_entries'] = TH2D('h2_deltaT0_entries',
555 '#DeltaT_{0} Entries by Region;Region 1;Region 2',
556 _N_REGIONS, -0.5, _N_REGIONS - 0.5,
557 _N_REGIONS, -0.5, _N_REGIONS - 0.5)
558 for ri, lab in enumerate(_reg_labels, 1):
559 for hh in (h['dt0_2d_mean'], h['dt0_2d_sigma'], h['dt0_2d_entries']):
560 hh.GetXaxis().SetBinLabel(ri, lab)
561 hh.GetYaxis().SetBinLabel(ri, lab)
562
563 # --- final/ ---
564 f.mkdir('final').cd()
565 h['final_scint'] = H1('h_t0evt_final_scint_only',
566 'Final KLM T_{0} (Scint only);T_{0} [ns]', 800, -100, 100)
567 h['final_rpc'] = H1('h_t0evt_final_with_rpc',
568 'Final KLM T_{0} (Scint+RPC);T_{0} [ns]', 800, -100, 100)
569 h['final_rpcdir'] = H1('h_t0evt_final_with_rpc_dir',
570 'Final KLM T_{0} (Scint+RPC dir);T_{0} [ns]', 800, -100, 100)
571
572 # Prevent ROOT garbage-collecting objects not owned by directories
573 self._prevent_gc()
574 f.cd()
575
576 def _book_pairwise(self, rootfile, dirpath, name_tmpl, title_tmpl,
577 n, nb, lo, hi, offset=0):
578 """
579 Book an n×n grid of TH1D inside *dirpath* and return as a dict-of-dict.
580
581 *offset* shifts sector indices in name/title (use 1 for EKLM sectors 1–4).
582 """
583 # Navigate step-by-step, creating each sub-directory if needed.
584 # rootfile.mkdir(dirpath).cd() is unreliable in cppyy when
585 # intermediate directories already exist (mkdir may return None).
586 d = rootfile
587 for part in dirpath.split('/'):
588 sub = d.GetDirectory(part)
589 if not sub:
590 sub = d.mkdir(part)
591 d = sub
592 d.cd()
593 grid = {}
594 for i in range(n):
595 grid[i] = {}
596 for j in range(n):
597 si, sj = i + offset, j + offset
598 grid[i][j] = TH1D(
599 name_tmpl.format(i=si, j=sj),
600 title_tmpl.format(i=si, j=sj),
601 nb, lo, hi)
602 return grid
603
604 @staticmethod
605 def _safe_cd(rootfile, dirpath):
606 """Navigate *rootfile* to *dirpath*, creating sub-dirs as needed."""
607 d = rootfile
608 for part in dirpath.split('/'):
609 sub = d.GetDirectory(part)
610 if not sub:
611 sub = d.mkdir(part)
612 d = sub
613 d.cd()
614
615 def _book_2d_summary(self, name, title, n, lo=-0.5):
616 """Book a TH2D for pairwise Gaussian-fit summaries."""
617 hi = n + lo
618 return TH2D(name, title, n, lo, hi, n, lo, hi)
619
620 def _prevent_gc(self):
621 """Call ROOT.SetOwnership(False) on all booked histograms."""
622 def _traverse(obj):
623 if isinstance(obj, dict):
624 for v in obj.values():
625 _traverse(v)
626 elif hasattr(obj, 'IsA'): # it's a ROOT object
627 ROOT.SetOwnership(obj, False)
628 for v in self._h.values():
629 _traverse(v)
630
631 # ------------------------------------------------------------------
632 # Helper: midpoint of two ROOT::Math::XYZVector (cppyy may lack operator+)
633 # ------------------------------------------------------------------
634 @staticmethod
635 def _midpoint(a, b):
636 """Return the midpoint of two XYZVector-like objects."""
637 return ROOT.Math.XYZVector(0.5 * (a.X() + b.X()),
638 0.5 * (a.Y() + b.Y()),
639 0.5 * (a.Z() + b.Z()))
640
641 # ------------------------------------------------------------------
642 # ExtHit map building (mirrors collectExtrapolatedHits in C++)
643 # ------------------------------------------------------------------
644
645 def _build_ext_maps(self, track):
646 """
647 Build channel→ExtHit maps for *track*.
648
649 Returns
650 -------
651 scint_map : dict channel_key → (entry_ExtHit, exit_ExtHit)
652 rpc_map : dict module_key → (entry_ExtHit, exit_ExtHit)
653
654 where entry = minimum TOF, exit = maximum TOF among all ExtHits
655 with the same key. This mirrors the C++ matchExt() logic.
656 """
657 scint_raw = {} # key → list of ExtHit
658 rpc_raw = {}
659
660 _muids = track.getRelationsTo['Belle2::KLMMuidLikelihood']('KLMMuidLikelihoods')
661 muid = _muids[0] if _muids.size() > 0 else None
662 elem = self._elem_num
663
664 for ext in track.getRelationsTo['Belle2::ExtHit']('ExtHits'):
665 if ext.getStatus() != Belle2.EXT_EXIT:
666 continue
667
668 det = ext.getDetectorID()
669 is_bklm = (det == Belle2.Const.EDetector.BKLM)
670 is_eklm = (det == Belle2.Const.EDetector.EKLM)
671 if not is_bklm and not is_eklm:
672 continue
673
674 copy_id = ext.getCopyID()
675
676 if is_bklm:
677 # Decode BKLM channel number → element numbers
678 # copy_id is int but KLMChannelNumber is uint16_t;
679 # C++ narrows implicitly, Python/cppyy does not.
680 fwd = ctypes.c_int(0)
681 sec = ctypes.c_int(0)
682 lay = ctypes.c_int(0)
683 pla = ctypes.c_int(0)
684 strip = ctypes.c_int(0)
686 copy_id & 0xFFFF, fwd, sec, lay, pla, strip)
687 fwd, sec, lay, pla, strip = (fwd.value, sec.value,
688 lay.value, pla.value, strip.value)
689
690 # Check layer was crossed
691 if muid:
692 if not muid.isExtrapolatedBarrelLayerCrossed(lay - 1):
693 continue
694
695 is_rpc = (lay >= Belle2.BKLMElementNumbers.c_FirstRPCLayer)
696 if is_rpc:
697 key = elem.moduleNumber(
698 Belle2.KLMElementNumbers.c_BKLM, fwd, sec, lay)
699 rpc_raw.setdefault(key, []).append(ext)
700 else:
701 key = elem.channelNumber(
702 Belle2.KLMElementNumbers.c_BKLM, fwd, sec, lay, pla, strip)
703 scint_raw.setdefault(key, []).append(ext)
704
705 else: # is_eklm
706 # Decode EKLM strip number → element numbers
707 fwd = ctypes.c_int(0)
708 lay = ctypes.c_int(0)
709 sec = ctypes.c_int(0)
710 pla = ctypes.c_int(0)
711 strip = ctypes.c_int(0)
712 Belle2.EKLMElementNumbers.Instance().stripNumberToElementNumbers(
713 copy_id, fwd, lay, sec, pla, strip)
714 fwd, lay, sec, pla, strip = (fwd.value, lay.value,
715 sec.value, pla.value, strip.value)
716
717 if muid:
718 if not muid.isExtrapolatedEndcapLayerCrossed(lay - 1):
719 continue
720
721 key = elem.channelNumber(
722 Belle2.KLMElementNumbers.c_EKLM, fwd, sec, lay, pla, strip)
723 scint_raw.setdefault(key, []).append(ext)
724
725 # Reduce each list to (entry, exit) pair by TOF
726 def _entry_exit(exts):
727 en = min(exts, key=lambda e: e.getTOF())
728 ex = max(exts, key=lambda e: e.getTOF())
729 return en, ex
730
731 scint_map = {k: _entry_exit(v) for k, v in scint_raw.items()}
732 rpc_map = {k: _entry_exit(v) for k, v in rpc_raw.items()}
733 return scint_map, rpc_map
734
735 # ------------------------------------------------------------------
736 # Per-subdetector accumulators (full correction chain)
737 # ------------------------------------------------------------------
738
739 def _accumulate_eklm(self, track, scint_map):
740 """
741 Accumulate EKLM scintillator hits for *track*.
742
743 Returns
744 -------
745 results : list of dict, each with:
746 t0_est, Trec, Tcable, Tprop, Tfly, section, sector
747 """
748 results = []
749 cable_db = self._db_cable_delay
750 status_db = self._db_channel_status
751 delay = self._delay_eklm
752
753 for hit2d in track.getRelationsTo['Belle2::KLMHit2d']('KLMHit2ds'):
754 if hit2d.getSubdetector() != Belle2.KLMElementNumbers.c_EKLM:
755 continue
756
757 for digit in hit2d.getRelationsTo['Belle2::KLMDigit']('KLMDigits'):
758 if not digit.isGood():
759 continue
760
761 cid = digit.getUniqueChannelID()
762 if (status_db.isValid() and
763 status_db.obj().getChannelStatus(cid) !=
764 Belle2.KLMChannelStatus.c_Normal):
765 continue
766
767 # ADC charge fill (before cut)
768 charge = digit.getCharge()
769 if self._h['digitQ_E']:
770 self._h['digitQ_E'].Fill(charge)
771
772 # ADC cut
773 if not (self._adc_eklm_min <= charge <= self._adc_eklm_max):
774 continue
775
776 # Match to ExtHit pair
777 pair = scint_map.get(cid)
778 if pair is None:
779 continue
780 entry_ext, exit_ext = pair
781
782 Tfly = 0.5 * (entry_ext.getTOF() + exit_ext.getTOF())
783
784 # Propagation distance for EKLM strip
785 # strip_length is in CLHEP mm; convert to basf2 cm
786 strip_len_cm = (self._geo_eklm.getStripLength(digit.getStrip())
787 * _MM_TO_BASF2)
788
789 # Global hit position in CLHEP mm
790 pos = self._midpoint(entry_ext.getPosition(), exit_ext.getPosition())
791 hit_global = ROOT.HepGeom.Point3D('double')(
792 pos.X() * _BASF2_TO_MM,
793 pos.Y() * _BASF2_TO_MM,
794 pos.Z() * _BASF2_TO_MM)
795
796 # Apply strip local transform to get local x
797 tr = self._transform_eklm.getStripGlobalToLocal(digit)
798 hit_local = tr * hit_global
799 # hit_local.x() is in CLHEP mm → convert to cm
800 local_x_cm = hit_local.x() * _MM_TO_BASF2
801
802 dist_cm = 0.5 * strip_len_cm - local_x_cm
803
804 # Full correction chain
805 Trec = digit.getTime()
806 Tcable = (cable_db.obj().getTimeDelay(cid)
807 if cable_db.isValid() else 0.0)
808 Tprop = dist_cm * delay
809 t0_est = Trec - Tcable - Tprop - Tfly
810
811 if not math.isfinite(t0_est):
812 continue
813
814 # EKLM section: 1=bwd, 2=fwd → convert to 0-indexed for matching
815 results.append({
816 't0_est': t0_est,
817 'Trec': Trec, 'Tcable': Tcable,
818 'Tprop': Tprop, 'Tfly': Tfly,
819 'section': digit.getSection(), # 1=bwd, 2=fwd
820 'sector': digit.getSector(), # 1-4
821 })
822
823 return results
824
825 def _accumulate_bklm_scint(self, track, scint_map):
826 """
827 Accumulate BKLM scintillator hits for *track*.
828
829 Returns
830 -------
831 results : list of dict (same fields as _accumulate_eklm, plus
832 'sector' as BKLM sector 1-8)
833 """
834 results = []
835 cable_db = self._db_cable_delay
836 status_db = self._db_channel_status
837 delay = self._delay_bklm
838
839 for hit2d in track.getRelationsTo['Belle2::KLMHit2d']('KLMHit2ds'):
840 if hit2d.getSubdetector() != Belle2.KLMElementNumbers.c_BKLM:
841 continue
842 if hit2d.inRPC():
843 continue
844 if hit2d.isOutOfTime():
845 continue
846
847 mod = self._geo_bklm.findModule(
848 hit2d.getSection(), hit2d.getSector(), hit2d.getLayer())
849 pos2d = hit2d.getPosition()
850
851 for b1d in hit2d.getRelationsTo['Belle2::BKLMHit1d']('BKLMHit1ds'):
852 is_phi = b1d.isPhiReadout()
853
854 for digit in b1d.getRelationsTo['Belle2::KLMDigit']('KLMDigits'):
855 if digit.inRPC() or not digit.isGood():
856 continue
857
858 cid = digit.getUniqueChannelID()
859 if (status_db.isValid() and
860 status_db.obj().getChannelStatus(cid) !=
861 Belle2.KLMChannelStatus.c_Normal):
862 continue
863
864 charge = digit.getCharge()
865 if self._h['digitQ_B']:
866 self._h['digitQ_B'].Fill(charge)
867
868 if not (self._adc_bklm_min <= charge <= self._adc_bklm_max):
869 continue
870
871 pair = scint_map.get(cid)
872 if pair is None:
873 continue
874 entry_ext, exit_ext = pair
875
876 Tfly = 0.5 * (entry_ext.getTOF() + exit_ext.getTOF())
877 pos_ext = self._midpoint(entry_ext.getPosition(), exit_ext.getPosition())
878
879 # Gate in local coordinates (position consistency check)
880 loc_ext = mod.globalToLocal(
881 ROOT.CLHEP.Hep3Vector(pos_ext.X(), pos_ext.Y(), pos_ext.Z()),
882 True)
883 loc_hit = mod.globalToLocal(
884 ROOT.CLHEP.Hep3Vector(pos2d.X(), pos2d.Y(), pos2d.Z()),
885 True)
886 diff = loc_ext - loc_hit
887 if (abs(diff.z()) > mod.getZStripWidth() or
888 abs(diff.y()) > mod.getPhiStripWidth()):
889 continue
890
891 # Propagation distance (in cm, same units as delay constant)
892 dist_cm = mod.getPropagationDistance(loc_ext, digit.getStrip(), is_phi)
893
894 Trec = digit.getTime()
895 Tcable = (cable_db.obj().getTimeDelay(cid)
896 if cable_db.isValid() else 0.0)
897 Tprop = dist_cm * delay
898 t0_est = Trec - Tcable - Tprop - Tfly
899
900 if not math.isfinite(t0_est):
901 continue
902
903 results.append({
904 't0_est': t0_est,
905 'Trec': Trec, 'Tcable': Tcable,
906 'Tprop': Tprop, 'Tfly': Tfly,
907 'section': hit2d.getSection(), # 0=bwd,1=fwd
908 'sector': hit2d.getSector(), # 1-8
909 })
910
911 return results
912
913 def _accumulate_bklm_rpc(self, track, rpc_map, accept_phi):
914 """
915 Accumulate BKLM RPC hits for *track*, filtered by readout direction.
916
917 Parameters
918 ----------
919 accept_phi : bool
920 True → accumulate phi-plane digits only.
921 False → accumulate z-plane digits only.
922
923 Returns
924 -------
925 results : list of dict (same fields as _accumulate_eklm)
926 """
927 results = []
928 cable_db = self._db_cable_delay
929 status_db = self._db_channel_status
930 delay = self._delay_rpc_phi if accept_phi else self._delay_rpc_z
931
932 for hit2d in track.getRelationsTo['Belle2::KLMHit2d']('KLMHit2ds'):
933 if hit2d.getSubdetector() != Belle2.KLMElementNumbers.c_BKLM:
934 continue
935 if not hit2d.inRPC():
936 continue
937 if hit2d.isOutOfTime():
938 continue
939
940 mod = self._geo_bklm.findModule(
941 hit2d.getSection(), hit2d.getSector(), hit2d.getLayer())
942 pos2d = hit2d.getPosition()
943
944 for b1d in hit2d.getRelationsTo['Belle2::BKLMHit1d']('BKLMHit1ds'):
945 is_phi = b1d.isPhiReadout()
946 if accept_phi and not is_phi:
947 continue
948 if not accept_phi and is_phi:
949 continue
950
951 for digit in b1d.getRelationsTo['Belle2::KLMDigit']('KLMDigits'):
952 if not digit.inRPC():
953 continue
954 if not digit.isGood():
955 continue
956
957 cid = digit.getUniqueChannelID()
958 if (status_db.isValid() and
959 status_db.obj().getChannelStatus(cid) !=
960 Belle2.KLMChannelStatus.c_Normal):
961 continue
962
963 # RPC: matched by module key
964 module_key = self._elem_num.moduleNumber(
965 digit.getSubdetector(),
966 digit.getSection(), digit.getSector(), digit.getLayer())
967 pair = rpc_map.get(module_key)
968 if pair is None:
969 continue
970 entry_ext, exit_ext = pair
971
972 Tfly = 0.5 * (entry_ext.getTOF() + exit_ext.getTOF())
973 pos_ext = self._midpoint(entry_ext.getPosition(), exit_ext.getPosition())
974
975 loc_ext = mod.globalToLocal(
976 ROOT.CLHEP.Hep3Vector(pos_ext.X(), pos_ext.Y(), pos_ext.Z()),
977 True)
978 loc_hit = mod.globalToLocal(
979 ROOT.CLHEP.Hep3Vector(pos2d.X(), pos2d.Y(), pos2d.Z()),
980 True)
981 diff = loc_ext - loc_hit
982 if (abs(diff.z()) > mod.getZStripWidth() or
983 abs(diff.y()) > mod.getPhiStripWidth()):
984 continue
985
986 # RPC propagation distance (Hep3Vector, pick y or z component)
987 propa_v = mod.getPropagationDistance(loc_ext)
988 dist_cm = propa_v.y() if is_phi else propa_v.z()
989
990 Trec = digit.getTime()
991 Tcable = (cable_db.obj().getTimeDelay(cid)
992 if cable_db.isValid() else 0.0)
993 Tprop = dist_cm * delay
994 t0_est = Trec - Tcable - Tprop - Tfly
995
996 if not math.isfinite(t0_est):
997 continue
998
999 results.append({
1000 't0_est': t0_est,
1001 'Trec': Trec, 'Tcable': Tcable,
1002 'Tprop': Tprop, 'Tfly': Tfly,
1003 'section': hit2d.getSection(),
1004 'sector': hit2d.getSector(),
1005 })
1006
1007 return results
1008
1009 # ------------------------------------------------------------------
1010 # Event
1011 # ------------------------------------------------------------------
1012
1013 def event(self):
1014 """Read muon tracks, compute per-track T0, fill all histograms."""
1015 h = self._h
1016
1017 # Sample type: check for MCInitialParticles
1018 mc_ip = Belle2.PyStoreObj('MCInitialParticles')
1019 is_mc = mc_ip.isValid()
1020 if h.get('sample_type'):
1021 h['sample_type'].Fill(2 if is_mc else 1)
1022
1023 muon_list = Belle2.PyStoreObj(self._muon_list_name)
1024 if not muon_list.isValid():
1025 return
1026 n_particles = muon_list.obj().getListSize()
1027 if n_particles == 0:
1028 return
1029
1030 # Per-track results keyed by category:
1031 # Each entry is a dict:
1032 # mu, sem, charge, n_hits, dom_sector, dom_section
1033 trk_B, trk_E, trk_R, trk_Rphi, trk_Rz = [], [], [], [], []
1034
1035 for i in range(n_particles):
1036 particle = muon_list.obj().getParticle(i)
1037 if not particle:
1038 continue
1039 track = particle.getTrack()
1040 if not track:
1041 continue
1042 charge = int(particle.getCharge())
1043
1044 # Build ExtHit maps for this track
1045 scint_map, rpc_map = self._build_ext_maps(track)
1046
1047 # Accumulate per-subdetector
1048 hits_E = self._accumulate_eklm(track, scint_map)
1049 hits_B = self._accumulate_bklm_scint(track, scint_map)
1050 hits_Rphi = self._accumulate_bklm_rpc(track, rpc_map, accept_phi=True)
1051 hits_Rz = self._accumulate_bklm_rpc(track, rpc_map, accept_phi=False)
1052
1053 # Fill timing-component diagnostics
1054 for det_key, hits in (('E', hits_E), ('B', hits_B),
1055 ('R', hits_Rphi + hits_Rz)):
1056 for hd in hits:
1057 for comp in ('Trec', 'Tcable', 'Tprop', 'Tfly'):
1058 hh = h.get(f'{comp}_{det_key}')
1059 if hh:
1060 hh.Fill(hd[comp])
1061
1062 # Per-track T0 from each category
1063 def _trk_result(hits, h_nhits, h_sem):
1064 """Compute (mu, sem, dom_sector, dom_section) for a hit list."""
1065 if not hits:
1066 return None
1067 vals = [hd['t0_est'] for hd in hits]
1068 mu, sem = _mu_sem(vals)
1069 if mu is None or not math.isfinite(mu):
1070 return None
1071 n = len(hits)
1072 dom_sec = int(round(sum(hd['sector'] for hd in hits) / n))
1073 dom_section = int(round(sum(hd['section'] for hd in hits) / n))
1074 if h_nhits:
1075 h_nhits.Fill(n)
1076 if h_sem and sem is not None:
1077 h_sem.Fill(sem)
1078 return mu, (sem if sem is not None else 0.0), dom_sec, dom_section
1079
1080 res_B = _trk_result(hits_B, h.get('nhits_B'), h.get('sem_B'))
1081 res_E = _trk_result(hits_E, h.get('nhits_E'), h.get('sem_E'))
1082 res_Rphi = _trk_result(hits_Rphi, None, None)
1083 res_Rz = _trk_result(hits_Rz, None, None)
1084
1085 # Combined RPC (phi + z)
1086 hits_R = hits_Rphi + hits_Rz
1087 res_R = _trk_result(hits_R, None, None)
1088
1089 if res_B:
1090 mu, se, sec, section = res_B
1091 if h.get('t0trk_B'):
1092 h['t0trk_B'].Fill(mu)
1093 trk_B.append({'mu': mu, 'sem': se, 'charge': charge,
1094 'sector': sec, 'section': section})
1095
1096 if res_E:
1097 mu, se, sec, section = res_E
1098 if h.get('t0trk_E'):
1099 h['t0trk_E'].Fill(mu)
1100 trk_E.append({'mu': mu, 'sem': se, 'charge': charge,
1101 'sector': sec, 'section': section})
1102
1103 if res_R:
1104 mu, se, sec, section = res_R
1105 if h.get('t0trk_R'):
1106 h['t0trk_R'].Fill(mu)
1107 trk_R.append({'mu': mu, 'sem': se, 'charge': charge,
1108 'sector': sec, 'section': section})
1109
1110 if res_Rphi:
1111 mu, se, sec, section = res_Rphi
1112 trk_Rphi.append({'mu': mu, 'sem': se, 'charge': charge,
1113 'sector': sec, 'section': section})
1114
1115 if res_Rz:
1116 mu, se, sec, section = res_Rz
1117 trk_Rz.append({'mu': mu, 'sem': se, 'charge': charge,
1118 'sector': sec, 'section': section})
1119
1120 if not any([trk_B, trk_E, trk_R]):
1121 return
1122
1123 # --- Per-event T0 (track average per category) ---
1124 def _evt_t0(trk_list):
1125 pairs = [(t['mu'], t['sem']) for t in trk_list]
1126 return _weighted_mean(pairs)
1127
1128 muB, seB = _evt_t0(trk_B)
1129 muE, seE = _evt_t0(trk_E)
1130 muR, seR = _evt_t0(trk_R)
1131 muRphi, seRphi = _evt_t0(trk_Rphi)
1132 muRz, seRz = _evt_t0(trk_Rz)
1133
1134 def _safe_fill(key, val):
1135 hh = h.get(key)
1136 if hh and val is not None and math.isfinite(val):
1137 hh.Fill(val)
1138
1139 _safe_fill('t0evt_B', muB)
1140 _safe_fill('t0evt_E', muE)
1141 _safe_fill('t0evt_R', muR)
1142
1143 # Overall event T0 from all categories
1144 all_parts = [(mu, se) for mu, se in ((muB, seB), (muE, seE), (muR, seR))
1145 if mu is not None and math.isfinite(mu)]
1146 muAll, _ = _weighted_mean(all_parts)
1147 _safe_fill('t0evt_all', muAll)
1148
1149 # Final source audit
1150 useB = muB is not None and math.isfinite(muB)
1151 useE = muE is not None and math.isfinite(muE)
1152 useR = muR is not None and math.isfinite(muR)
1153 if any((useB, useE, useR)):
1154 src = (useB, useE, useR)
1155 src_map = {
1156 (True, False, False): 1,
1157 (False, True, False): 2,
1158 (False, False, True): 3,
1159 (True, True, False): 4,
1160 (True, False, True): 5,
1161 (False, True, True): 6,
1162 (True, True, True): 7,
1163 }
1164 sb = src_map.get(src)
1165 if sb and h.get('final_source'):
1166 h['final_source'].Fill(sb)
1167
1168 # --- Final T0 combinations ---
1169 def _combine(parts):
1170 """Inverse-variance combine non-None (mu, se) pairs."""
1171 valid = [(mu, se) for mu, se in parts
1172 if mu is not None and math.isfinite(mu)]
1173 return _weighted_mean(valid)
1174
1175 t0_scint, _ = _combine([(muB, seB), (muE, seE)])
1176 t0_rpc, _ = _combine([(muB, seB), (muE, seE), (muR, seR)])
1177 t0_rpcdir, _ = _combine([(muB, seB), (muE, seE),
1178 (muRphi, seRphi), (muRz, seRz)])
1179 _safe_fill('final_scint', t0_scint)
1180 _safe_fill('final_rpc', t0_rpc)
1181 _safe_fill('final_rpcdir', t0_rpcdir)
1182
1183 # --- Same-detector pair histograms (pulls and residuals) ---
1184 self._fill_pairs(trk_B, h.get('pull_B'), h.get('res_B'),
1185 h.get('ppw_B'), h.get('rpw_B'), bklm=True)
1186 self._fill_pairs(trk_E, h.get('pull_E'), h.get('res_E'),
1187 h.get('ppw_E'), h.get('rpw_E'), bklm=False)
1188 self._fill_pairs(trk_Rphi, h.get('pull_Rphi'), h.get('res_Rphi'),
1189 h.get('ppw_Rphi'), h.get('rpw_Rphi'), bklm=True)
1190 self._fill_pairs(trk_Rz, h.get('pull_Rz'), h.get('res_Rz'),
1191 h.get('ppw_Rz'), h.get('rpw_Rz'), bklm=True)
1192 self._fill_pairs(trk_R, h.get('pull_R'), h.get('res_R'),
1193 None, None, bklm=True)
1194
1195 # --- Cross-detector pulls (event-level) ---
1196 def _cross_pull(muX, seX, muY, seY, h_pull, h_res):
1197 if muX is None or muY is None:
1198 return
1199 if not (math.isfinite(muX) and math.isfinite(muY)):
1200 return
1201 dt = muX - muY
1202 if h_res and math.isfinite(dt):
1203 h_res.Fill(dt)
1204 if (seX is not None and seY is not None and
1205 seX > 0 and seY > 0):
1206 denom2 = seX * seX + seY * seY
1207 pull = dt / math.sqrt(denom2)
1208 if h_pull and math.isfinite(pull):
1209 h_pull.Fill(pull)
1210
1211 _cross_pull(muB, seB, muE, seE, h.get('pull_BvE'), h.get('res_BvE'))
1212 _cross_pull(muB, seB, muR, seR, h.get('pull_BvR'), h.get('res_BvR'))
1213 _cross_pull(muE, seE, muR, seR, h.get('pull_EvR'), h.get('res_EvR'))
1214
1215 # --- Cross-detector 4x4 ΔT0 matrix ---
1216 # Map detector regions to per-event T0 values
1217 # Regions: 0=EKLM Bwd, 1=EKLM Fwd, 2=BKLM RPC, 3=BKLM Scint
1218 def _eklm_region_t0(trk_list, target_section):
1219 """Per-event T0 from tracks in a specific EKLM section."""
1220 filtered = [t for t in trk_list if t['section'] == target_section]
1221 if not filtered:
1222 return None, None
1223 return _weighted_mean([(t['mu'], t['sem']) for t in filtered])
1224
1225 reg_t0 = {
1226 _REG_EKLM_BWD: _eklm_region_t0(trk_E, _EKLM_SECTION_BWD),
1227 _REG_EKLM_FWD: _eklm_region_t0(trk_E, _EKLM_SECTION_FWD),
1228 _REG_BKLM_RPC: (muR, seR),
1229 _REG_BKLM_SCINT: (muB, seB),
1230 }
1231 dt0_h = h.get('dt0', {})
1232 for ri in range(_N_REGIONS):
1233 mu_i, se_i = reg_t0[ri]
1234 if mu_i is None or not math.isfinite(mu_i):
1235 continue
1236 for rj in range(_N_REGIONS):
1237 mu_j, se_j = reg_t0[rj]
1238 if mu_j is None or not math.isfinite(mu_j):
1239 continue
1240 dt = mu_i - mu_j
1241 hh = dt0_h.get(ri, {}).get(rj)
1242 if hh and math.isfinite(dt):
1243 hh.Fill(dt)
1244
1245 # --- Dimuon ΔT0 ---
1246 def _dimuon_fill(trk_list, h_hist):
1247 if not h_hist:
1248 return
1249 t0p = t0m = None
1250 for t in trk_list:
1251 if t['charge'] > 0:
1252 t0p = t['mu']
1253 elif t['charge'] < 0:
1254 t0m = t['mu']
1255 if (t0p is not None and t0m is not None and
1256 math.isfinite(t0p) and math.isfinite(t0m)):
1257 h_hist.Fill(t0p - t0m)
1258
1259 _dimuon_fill(trk_B, h.get('dimuon_B'))
1260 _dimuon_fill(trk_E, h.get('dimuon_E'))
1261 _dimuon_fill(trk_R, h.get('dimuon_R'))
1262
1263 # All KLM combined (use first available T0 per charge sign: B > E > R)
1264 trk_all_by_charge = {}
1265 for lst in (trk_B, trk_E, trk_R):
1266 for t in lst:
1267 q = t['charge']
1268 if q not in trk_all_by_charge:
1269 trk_all_by_charge[q] = t
1270 _dimuon_fill(list(trk_all_by_charge.values()), h.get('dimuon_all'))
1271
1272 # Scint-only combined per track: B+E hits combined
1273 trk_scint = self._combine_track_categories(trk_B, trk_E)
1274 _dimuon_fill(trk_scint, h.get('dimuon_scint'))
1275
1276 # With-RPC combined per track: B+E+R hits combined (RPC as single category)
1277 trk_all_cats = self._combine_track_categories(trk_B, trk_E, trk_R)
1278 _dimuon_fill(trk_all_cats, h.get('dimuon_rpc'))
1279
1280 # With-RPC-dir combined per track: B+E+Rphi+Rz as separate categories
1281 trk_rpcdir_cats = self._combine_track_categories(trk_B, trk_E, trk_Rphi, trk_Rz)
1282 _dimuon_fill(trk_rpcdir_cats, h.get('dimuon_rpcdir'))
1283
1284 # ------------------------------------------------------------------
1285 # Pair histogram helpers
1286 # ------------------------------------------------------------------
1287
1288 def _fill_pairs(self, trk_list, h_pull, h_res, ppw_pull, ppw_res, bklm):
1289 """
1290 Fill same-detector pair histograms for one category.
1291
1292 For BKLM (bklm=True): sector key = sector (0-indexed for 8 sectors).
1293 For EKLM (bklm=False): sector key = sector-1 (0-indexed for 4 sectors).
1294 """
1295 n = len(trk_list)
1296 for i in range(n):
1297 ti = trk_list[i]
1298 if not math.isfinite(ti['mu']):
1299 continue
1300 for j in range(i + 1, n):
1301 tj = trk_list[j]
1302 if not math.isfinite(tj['mu']):
1303 continue
1304 if self._opp_charges_only and ti['charge'] * tj['charge'] >= 0:
1305 continue
1306
1307 dt = ti['mu'] - tj['mu']
1308 if not math.isfinite(dt):
1309 continue
1310
1311 if h_res:
1312 h_res.Fill(dt)
1313
1314 si = ti['sem']
1315 sj = tj['sem']
1316 denom2 = si * si + sj * sj if (si and sj) else 0.0
1317 if denom2 > 0.0:
1318 pull = dt / math.sqrt(denom2)
1319 if h_pull and math.isfinite(pull):
1320 h_pull.Fill(pull)
1321
1322 # Pairwise sector histograms
1323 if ppw_pull or ppw_res:
1324 if bklm:
1325 # BKLM sectors 1-8 → 0-indexed key
1326 ki = ti['sector'] - 1
1327 kj = tj['sector'] - 1
1328 n_max = _N_BKLM_SECTORS
1329 else:
1330 # EKLM sectors 1-4 → 0-indexed key
1331 # Pairwise is Fwd (section=2) vs Bwd (section=1)
1332 # Use section to assign which side each track is on
1333 ki = ti['sector'] - 1
1334 kj = tj['sector'] - 1
1335 n_max = _N_EKLM_SECTORS
1336
1337 if 0 <= ki < n_max and 0 <= kj < n_max:
1338 if ppw_pull:
1339 pg = ppw_pull.get(ki, {}).get(kj)
1340 if pg and math.isfinite(pull):
1341 pg.Fill(pull)
1342 if ppw_res:
1343 rg = ppw_res.get(ki, {}).get(kj)
1344 if rg and math.isfinite(dt):
1345 rg.Fill(dt)
1346
1347 def _combine_track_categories(self, *categories):
1348 """
1349 Combine multiple per-track category lists into a single list,
1350 merging by charge (one entry per charge sign, weighted mean of T0).
1351 """
1352 by_charge = {}
1353 for cat in categories:
1354 for t in cat:
1355 q = t['charge']
1356 if q not in by_charge:
1357 by_charge[q] = []
1358 by_charge[q].append((t['mu'], t['sem']))
1359
1360 result = []
1361 for q, pairs in by_charge.items():
1362 mu, se = _weighted_mean(pairs)
1363 if mu is not None and math.isfinite(mu):
1364 result.append({'mu': mu, 'sem': se or 0.0, 'charge': q,
1365 'sector': -1, 'section': -1})
1366 return result
1367
1368 # ------------------------------------------------------------------
1369 # Summary histogram fitting (called from terminate)
1370 # ------------------------------------------------------------------
1371
1373 """
1374 Fit Gaussian to pull/residual/dimuon histograms and fill
1375 summary histograms (mean, sigma per category; 2D pairwise summaries;
1376 per-track T0 resolution from dimuon ΔT0).
1377 """
1378 h = self._h
1379
1380 # Pull summary (4 categories: B, E, Rphi, Rz)
1381 for ib, (key, label) in enumerate([('pull_B', 'BKLM Scint'),
1382 ('pull_E', 'EKLM Scint'),
1383 ('pull_Rphi', 'RPC Phi'),
1384 ('pull_Rz', 'RPC Z')], 1):
1385 mu, muE, sig, sigE = _gauss_fit(h.get(key), -5.0, 5.0)
1386 if mu is not None:
1387 h['pull_smean'].SetBinContent(ib, mu)
1388 h['pull_smean'].SetBinError(ib, muE)
1389 h['pull_swidth'].SetBinContent(ib, sig)
1390 h['pull_swidth'].SetBinError(ib, sigE)
1391 basf2.B2INFO(f'KLMEventT0Validation: {label} pull fit:'
1392 f' mean={mu:.3f}±{muE:.3f} sigma={sig:.3f}±{sigE:.3f}')
1393
1394 # Residual summary (4 categories)
1395 for ib, (key, label) in enumerate([('res_B', 'BKLM Scint'),
1396 ('res_E', 'EKLM Scint'),
1397 ('res_Rphi', 'RPC Phi'),
1398 ('res_Rz', 'RPC Z')], 1):
1399 mu, muE, sig, sigE = _gauss_fit(h.get(key), -25.0, 25.0)
1400 if mu is not None:
1401 h['res_smean'].SetBinContent(ib, mu)
1402 h['res_smean'].SetBinError(ib, muE)
1403 h['res_swidth'].SetBinContent(ib, sig)
1404 h['res_swidth'].SetBinError(ib, sigE)
1405
1406 # Pairwise sector pull summaries
1407 _pairwise_cfgs = [
1408 ('ppw_B', 'ppw_2d_B_mean', 'ppw_2d_B_sigma', _N_BKLM_SECTORS),
1409 ('ppw_E', 'ppw_2d_E_mean', 'ppw_2d_E_sigma', _N_EKLM_SECTORS),
1410 ('ppw_Rphi', 'ppw_2d_Rphi_mean', 'ppw_2d_Rphi_sigma', _N_BKLM_SECTORS),
1411 ('ppw_Rz', 'ppw_2d_Rz_mean', 'ppw_2d_Rz_sigma', _N_BKLM_SECTORS),
1412 ]
1413 for pw_key, mean_key, sig_key, n in _pairwise_cfgs:
1414 pw = h.get(pw_key, {})
1415 h2m = h.get(mean_key)
1416 h2s = h.get(sig_key)
1417 for i in range(n):
1418 for j in range(n):
1419 mu, _, sig, _ = _gauss_fit(pw.get(i, {}).get(j), -5.0, 5.0)
1420 if mu is not None:
1421 if h2m:
1422 h2m.SetBinContent(i + 1, j + 1, mu)
1423 if h2s:
1424 h2s.SetBinContent(i + 1, j + 1, sig)
1425
1426 # Pairwise sector residual summaries
1427 _rpairwise_cfgs = [
1428 ('rpw_B', 'rpw_2d_B_mean', 'rpw_2d_B_sigma', _N_BKLM_SECTORS),
1429 ('rpw_E', 'rpw_2d_E_mean', 'rpw_2d_E_sigma', _N_EKLM_SECTORS),
1430 ('rpw_Rphi', 'rpw_2d_Rphi_mean', 'rpw_2d_Rphi_sigma', _N_BKLM_SECTORS),
1431 ('rpw_Rz', 'rpw_2d_Rz_mean', 'rpw_2d_Rz_sigma', _N_BKLM_SECTORS),
1432 ]
1433 for pw_key, mean_key, sig_key, n in _rpairwise_cfgs:
1434 pw = h.get(pw_key, {})
1435 h2m = h.get(mean_key)
1436 h2s = h.get(sig_key)
1437 for i in range(n):
1438 for j in range(n):
1439 mu, _, sig, _ = _gauss_fit(pw.get(i, {}).get(j), -25.0, 25.0)
1440 if mu is not None:
1441 if h2m:
1442 h2m.SetBinContent(i + 1, j + 1, mu)
1443 if h2s:
1444 h2s.SetBinContent(i + 1, j + 1, sig)
1445
1446 # Cross-detector ΔT0 2D summary
1447 dt0_h = h.get('dt0', {})
1448 h2m = h.get('dt0_2d_mean')
1449 h2s = h.get('dt0_2d_sigma')
1450 h2e = h.get('dt0_2d_entries')
1451 for ri in range(_N_REGIONS):
1452 for rj in range(_N_REGIONS):
1453 hh = dt0_h.get(ri, {}).get(rj)
1454 if hh is None:
1455 continue
1456 if h2e:
1457 h2e.SetBinContent(ri + 1, rj + 1, hh.GetEntries())
1458 mu, _, sig, _ = _gauss_fit(hh, -25.0, 25.0)
1459 if mu is not None:
1460 if h2m:
1461 h2m.SetBinContent(ri + 1, rj + 1, mu)
1462 if h2s:
1463 h2s.SetBinContent(ri + 1, rj + 1, sig)
1464
1465 # Per-track T0 resolution from dimuon ΔT0: σ_track = σ(ΔT0)/√2
1466 # 6 categories matching resolution histogram bin labels
1467 h_res = h.get('resolution')
1468 for ib, dm_key in enumerate(
1469 ['dimuon_B', 'dimuon_R', 'dimuon_E',
1470 'dimuon_scint', 'dimuon_rpc', 'dimuon_rpcdir'], 1):
1471 _, _, sig, sigE = _gauss_fit(h.get(dm_key), -40.0, 40.0)
1472 if sig is not None and h_res:
1473 per_track = sig / math.sqrt(2.0)
1474 per_track_err = sigE / math.sqrt(2.0)
1475 h_res.SetBinContent(ib, per_track)
1476 h_res.SetBinError(ib, per_track_err)
static void channelNumberToElementNumbers(KLMChannelNumber channel, int *section, int *sector, int *layer, int *plane, int *strip)
Get element numbers by channel number.
static const EKLMElementNumbers & Instance()
Instantiation.
static const GeometryData & Instance(enum DataSource dataSource=c_Database, const GearDir *gearDir=nullptr)
Instantiation.
Transformation data.
static const KLMElementNumbers & Instance()
Instantiation.
Class to access a DBObjPtr from Python.
Definition PyDBObj.h:50
a (simplified) python wrapper for StoreObjPtr.
Definition PyStoreObj.h:67
static GeometryPar * instance(void)
Static method to get a reference to the singleton GeometryPar instance.
_book_pairwise(self, rootfile, dirpath, name_tmpl, title_tmpl, n, nb, lo, hi, offset=0)
__init__(self, str muon_list_name='mu+:forT0', str output_file='KLMEventT0Validation.root', bool opposite_charges_only=True, float adc_cut_bklm_scint_min=30.0, float adc_cut_bklm_scint_max=320.0, float adc_cut_eklm_scint_min=40.0, float adc_cut_eklm_scint_max=350.0)
float _delay_bklm
Propagation-delay constant for BKLM scintillator strips (ns/cm), refreshed in beginRun().
_transform_eklm
EKLM coordinate transform helper (initialised in initialize()).
_adc_eklm_min
Lower ADC cut for EKLM scintillator digits.
_adc_eklm_max
Upper ADC cut for EKLM scintillator digits.
float _delay_eklm
Propagation-delay constant for EKLM strips (ns/cm), refreshed in beginRun().
_accumulate_bklm_rpc(self, track, rpc_map, accept_phi)
float _delay_rpc_phi
Propagation-delay constant for BKLM RPC phi strips (ns/cm), refreshed in beginRun().
float _delay_rpc_z
Propagation-delay constant for BKLM RPC z strips (ns/cm), refreshed in beginRun().
_fill_pairs(self, trk_list, h_pull, h_res, ppw_pull, ppw_res, bklm)
_geo_eklm
EKLM geometry data (initialised in initialize()).
_opp_charges_only
Only use opposite-charge track pairs for pull/residual histograms.
_geo_bklm
BKLM geometry parameters (initialised in initialize()).
_db_cable_delay
Cable-delay PyDBObj used to query per-channel offsets, refreshed in beginRun().
_db_channel_status
Channel-status PyDBObj used to skip bad channels, refreshed in beginRun().
_adc_bklm_min
Lower ADC cut for BKLM scintillator digits.
dict _h
All booked histograms, keyed by short name.
_elem_num
EKLM element-number helper (initialised in initialize()).
_adc_bklm_max
Upper ADC cut for BKLM scintillator digits.