Belle II Software development
KLMEventT0Validation.py
1#!/usr/bin/env python3
2
9"""
10KLMEventT0ValidationModule
11==========================
12A basf2.Module that runs after KLMEventT0Estimator in the same path and fills
13validation histograms from the estimator's results. It does not recompute them:
14it reads what the estimator stores in each event.
15
16 per muon of the ParticleList (extra info; <C> = BKLMScint, BKLMRPC, EKLMScint)
17 klmTrackT0<C>, klmTrackT0Err<C>, klmTrackNHits<C>,
18 klmTrackSector<C>, klmTrackSection<C>, klmTrackT0All, klmTrackT0ErrAll
19 per event
20 klmEventT0<C>, klmEventT0Err<C> (EventExtraInfo)
21 the KLM entry of EventT0 (final KLM event T0 and its uncertainty)
22
23Only the charge histograms read the hits of the track: the charge of each
24digit, before the estimator's charge selection.
25
26Histograms (directory structure):
27 diagnostics/
28 h_nhits_pertrk_{bklm_scint,bklm_rpc,eklm_scint}
29 h_err_pertrk_{bklm_scint,bklm_rpc,eklm_scint} per-track uncertainty
30 h_digitQ_{bklm_scint,eklm_scint}
31 h_dimuon_{all,bklm_scint,bklm_rpc,eklm_scint} T0(mu+) - T0(mu-)
32 h_per_track_resolution core width of h_dimuon_* / sqrt(2)
33 final/
34 h_t0evt_klm_final, h_t0evt_klm_final_err
35 pulls/
36 h_pull_{bklm_scint,bklm_rpc,eklm_scint} (T0(mu+) - T0(mu-)) / sqrt(err+^2 + err-^2)
37 h_pull_{B_vs_E,B_vs_R,E_vs_R} two categories of the same track
38 h_evt_pull_{B_vs_E,B_vs_R,E_vs_R} per-event results of two categories
39 sector/{bklm_scint,bklm_rpc,eklm}/ same-category pulls by sector pair
40 sector/h2_pull_{bklm_scint,bklm_rpc,eklm}_{mean,sigma}
41 residuals/
42 the same as pulls/, in ns
43
44Sector pairs: (i, j) has the mu+ in sector i and the mu- in sector j. BKLM sectors 1-8
45(both halves together); EKLM sectors B1-B4 (backward) and F1-F4 (forward).
46
47Usage
48-----
49 import basf2
50 import modularAnalysis as ma
51 from KLMEventT0Validation import KLMEventT0ValidationModule
52
53 ma.fillParticleList('mu+:forT0', 'p > 1.5 and abs(d0) < 2 and abs(z0) < 4', path=path)
54 path.add_module('KLMEventT0Estimator', MuonListName='mu+:forT0')
55 path.add_module(KLMEventT0ValidationModule(muon_list_name='mu+:forT0',
56 output_file='KLMEventT0Validation.root'))
57"""
58
59import array
60import math
61
62import basf2
63import ROOT
64from ROOT import Belle2, TFile, TH1D, TH2D
65
66ROOT.gROOT.SetBatch(True)
67
68
69CATEGORIES = [('BKLMScint', 'bklm_scint', 'B'),
70 ('BKLMRPC', 'bklm_rpc', 'R'),
71 ('EKLMScint', 'eklm_scint', 'E')]
72
73CROSS_PAIRS = [('B', 'E'), ('B', 'R'), ('E', 'R')]
74
75N_SECTOR_BINS = {'B': 8, 'R': 8, 'E': 8}
76
77
78def _sector_index(label, track):
79 """Sector-pair index of a track: BKLM sector - 1; EKLM 4 * (section - 1) + (sector - 1)."""
80 if label == 'E':
81 return 4 * (track['section'] - 1) + (track['sector'] - 1)
82 return track['sector'] - 1
83
84
85def _sector_name(label, index):
86 """Readable sector name for histogram names and titles."""
87 if label == 'E':
88 return ('B' if index < 4 else 'F') + str(index % 4 + 1)
89 return str(index + 1)
90
91
92def core_fit(hist):
93 """Single Gaussian fit to the core: start at the median with width IQR/1.349 and fit
94 within +-2.5 of that width. Returns (mean, mean error, sigma, sigma error) or None."""
95 if hist is None or hist.GetEntries() < 50:
96 return None
97 probs = array.array('d', [0.25, 0.5, 0.75])
98 q = array.array('d', [0.0, 0.0, 0.0])
99 hist.GetQuantiles(3, q, probs)
100 sigma0 = (q[2] - q[0]) / 1.349
101 if not sigma0 > 0.0:
102 return None
103 f = ROOT.TF1('core_fit', 'gaus', q[1] - 2.5 * sigma0, q[1] + 2.5 * sigma0)
104 f.SetParameters(hist.GetMaximum(), q[1], sigma0)
105 if int(hist.Fit(f, 'RQN0')) != 0:
106 return None
107 return f.GetParameter(1), f.GetParError(1), abs(f.GetParameter(2)), f.GetParError(2)
108
109
110class KLMEventT0ValidationModule(basf2.Module):
111 """Validation histograms for KLMEventT0Estimator, filled from the estimator's stored results."""
112
113 def __init__(self, muon_list_name='mu+:forT0', output_file='KLMEventT0Validation.root',
114 opposite_charges_only=True):
115 """
116 Parameters
117 ----------
118 muon_list_name : str
119 ParticleList given to KLMEventT0Estimator as MuonListName.
120 output_file : str
121 ROOT file for the histograms.
122 opposite_charges_only : bool
123 Use only opposite-charge track pairs for the same-category pulls, residuals and sector maps.
124 """
125 super().__init__()
126
127 self._muon_list_name = muon_list_name
128
129 self._output_file_path = output_file
130
131 self._opp_charges_only = opposite_charges_only
132
133 self._h = {}
134
135 self._outfile = None
136
137 self._channel_status = None
138
139 # ------------------------------------------------------------------
140 def initialize(self):
141 """Open the output file and book the histograms."""
142 self._channel_status = Belle2.PyDBObj('KLMChannelStatus')
143 self._outfile = TFile(self._output_file_path, 'RECREATE')
144 self._book_histograms()
145
146 def terminate(self):
147 """Fill the summary histograms, write and close the file."""
148 self._fill_summaries()
149 self._outfile.Write('', ROOT.TObject.kOverwrite)
150 self._outfile.Close()
151 basf2.B2INFO(f'KLMEventT0ValidationModule: wrote {self._output_file_path}')
152
153 # ------------------------------------------------------------------
154 def _cd(self, path):
155 """Create (if needed) and change to a directory of the output file."""
156 d = self._outfile
157 for part in path.split('/'):
158 sub = d.GetDirectory(part)
159 d = sub if sub else d.mkdir(part)
160 d.cd()
161
163 """Book all histograms; the binning follows the histograms used for the KLM event T0 note."""
164 h = self._h
165
166 self._cd('diagnostics')
167 for name, sfx, lab in CATEGORIES:
168 h[f'nhits_{lab}'] = TH1D(f'h_nhits_pertrk_{sfx}', f'Hits per track ({sfx});hits', 100, -0.5, 99.5)
169 h[f'err_{lab}'] = TH1D(f'h_err_pertrk_{sfx}', f'Per-track T0 uncertainty ({sfx});uncertainty [ns]',
170 800, 0.0, 20.0)
171 h[f'dimuon_{lab}'] = TH1D(f'h_dimuon_{sfx}', f'Di-muon T0(#mu^{{+}}) - T0(#mu^{{-}}) ({sfx});#DeltaT_{{0}} [ns]',
172 800, -100, 100)
173 h['dimuon_all'] = TH1D('h_dimuon_all', 'Di-muon T0(#mu^{+}) - T0(#mu^{-}) (all categories);#DeltaT_{0} [ns]',
174 800, -100, 100)
175 h['digitQ_B'] = TH1D('h_digitQ_bklm_scint', 'Digit charge (BKLM scintillator);charge [ADC counts]', 100, 0.0, 800.0)
176 h['digitQ_E'] = TH1D('h_digitQ_eklm_scint', 'Digit charge (EKLM scintillator);charge [ADC counts]', 100, 0.0, 800.0)
177 h['resolution'] = TH1D('h_per_track_resolution', 'Per-track resolution from di-muon #DeltaT_{0};;#sigma/#sqrt{2} [ns]',
178 4, 0.5, 4.5)
179 for i, lab in enumerate(['all', 'bklm_scint', 'bklm_rpc', 'eklm_scint'], 1):
180 h['resolution'].GetXaxis().SetBinLabel(i, lab)
181
182 self._cd('final')
183 h['final'] = TH1D('h_t0evt_klm_final', 'Final KLM event T0;T0 [ns]', 800, -100, 100)
184 h['final_err'] = TH1D('h_t0evt_klm_final_err', 'Final KLM event T0 uncertainty;uncertainty [ns]', 800, 0.0, 20.0)
185
186 for kind, title, nb, lo, hi in (('pull', 'Pull', 200, -10, 10), ('res', 'Residual', 500, -25, 25)):
187 top = 'pulls' if kind == 'pull' else 'residuals'
188 unit = '' if kind == 'pull' else ' [ns]'
189 self._cd(top)
190 for name, sfx, lab in CATEGORIES:
191 h[f'{kind}_{lab}'] = TH1D(f'h_{kind}_{sfx}', f'{title} ({sfx});T0(#mu^{{+}}) - T0(#mu^{{-}}){unit}',
192 nb, lo, hi)
193 for a, b in CROSS_PAIRS:
194 h[f'{kind}_{a}_vs_{b}'] = TH1D(f'h_{kind}_{a}_vs_{b}', f'{title}, same track;T0({a}) - T0({b}){unit}',
195 nb, lo, hi)
196 h[f'evt_{kind}_{a}_vs_{b}'] = TH1D(f'h_evt_{kind}_{a}_vs_{b}', f'{title}, per event;T0({a}) - T0({b}){unit}',
197 nb, lo, hi)
198 # Sector pairs, same binning as the global histograms of the old sector analysis
199 snb, slo, shi = (200, -10, 10) if kind == 'pull' else (200, -50, 50)
200 for name, sfx, lab in CATEGORIES:
201 self._cd(f'{top}/sector/{sfx}')
202 n = N_SECTOR_BINS[lab]
203 grid = {}
204 for i in range(n):
205 for j in range(n):
206 si, sj = _sector_name(lab, i), _sector_name(lab, j)
207 grid[(i, j)] = TH1D(f'h_{kind}_{sfx}_{si}_vs_{sj}',
208 f'{title} ({sfx}), #mu^{{+}} in {si}, #mu^{{-}} in {sj};'
209 f'T0(#mu^{{+}}) - T0(#mu^{{-}}){unit}', snb, slo, shi)
210 h[f'{kind}_sector_{lab}'] = grid
211 self._cd(f'{top}/sector')
212 for stat in ('mean', 'sigma'):
213 h2 = TH2D(f'h2_{kind}_{sfx}_{stat}', f'{title} {stat} ({sfx});#mu^{{+}} sector;#mu^{{-}} sector',
214 n, 0.5, n + 0.5, n, 0.5, n + 0.5)
215 for k in range(n):
216 h2.GetXaxis().SetBinLabel(k + 1, _sector_name(lab, k))
217 h2.GetYaxis().SetBinLabel(k + 1, _sector_name(lab, k))
218 h[f'{kind}_sector_{lab}_{stat}'] = h2
219
220 # ------------------------------------------------------------------
221 def event(self):
222 """Read the estimator's results for this event and fill the histograms."""
223 h = self._h
225 if not plist.isValid():
226 return
227
228 tracks = {lab: [] for _, _, lab in CATEGORIES} # per category: one dict per track
229 all_tracks = [] # (charge, T0 over all categories)
230 for i in range(plist.obj().getListSize()):
231 particle = plist.obj().getParticle(i)
232 charge = particle.getCharge()
233 for name, sfx, lab in CATEGORIES:
234 if not particle.hasExtraInfo('klmTrackT0' + name):
235 continue
236 t = {'mu': particle.getExtraInfo('klmTrackT0' + name),
237 'err': particle.getExtraInfo('klmTrackT0Err' + name),
238 'n': int(particle.getExtraInfo('klmTrackNHits' + name)),
239 'sector': int(particle.getExtraInfo('klmTrackSector' + name)),
240 'section': int(particle.getExtraInfo('klmTrackSection' + name)),
241 'charge': charge, 'index': i}
242 tracks[lab].append(t)
243 h[f'nhits_{lab}'].Fill(t['n'])
244 h[f'err_{lab}'].Fill(t['err'])
245 if particle.hasExtraInfo('klmTrackT0All'):
246 all_tracks.append((charge, particle.getExtraInfo('klmTrackT0All')))
247 self._fill_charges(particle)
248
249 # Final KLM event T0
250 evt_t0 = Belle2.PyStoreObj('EventT0')
251 if evt_t0.isValid():
252 klm = evt_t0.obj().getTemporaryEventT0s(Belle2.Const.KLM)
253 if klm.size() > 0:
254 h['final'].Fill(klm.back().eventT0)
255 h['final_err'].Fill(klm.back().eventT0Uncertainty)
256
257 # Same-category pairs: pulls, residuals and sector maps
258 for name, sfx, lab in CATEGORIES:
259 self._fill_pairs(lab, tracks[lab])
260
261 # Two categories of the same track
262 for a, b in CROSS_PAIRS:
263 for ta in tracks[a]:
264 for tb in tracks[b]:
265 if tb['index'] == ta['index']:
266 self._fill_diff(ta['mu'], ta['err'], tb['mu'], tb['err'], f'_{a}_vs_{b}')
267
268 # Per-event results of two categories
269 extra = Belle2.PyStoreObj('EventExtraInfo')
270 if extra.isValid():
271 evt = {}
272 for name, sfx, lab in CATEGORIES:
273 if extra.obj().hasExtraInfo('klmEventT0' + name):
274 evt[lab] = (extra.obj().getExtraInfo('klmEventT0' + name),
275 extra.obj().getExtraInfo('klmEventT0Err' + name))
276 for a, b in CROSS_PAIRS:
277 if a in evt and b in evt:
278 self._fill_diff(evt[a][0], evt[a][1], evt[b][0], evt[b][1], f'_{a}_vs_{b}', prefix='evt_')
279
280 # Di-muon T0(mu+) - T0(mu-): the last track of each charge, as in the estimator's earlier version
281 def _last(pairs, sign):
282 vals = [mu for q, mu in pairs if q * sign > 0]
283 return vals[-1] if vals else None
284 pos, neg = _last(all_tracks, 1), _last(all_tracks, -1)
285 if pos is not None and neg is not None:
286 h['dimuon_all'].Fill(pos - neg)
287 for name, sfx, lab in CATEGORIES:
288 pairs = [(t['charge'], t['mu']) for t in tracks[lab]]
289 pos, neg = _last(pairs, 1), _last(pairs, -1)
290 if pos is not None and neg is not None:
291 h[f'dimuon_{lab}'].Fill(pos - neg)
292
293 # ------------------------------------------------------------------
294 def _fill_diff(self, mu_a, err_a, mu_b, err_b, suffix, prefix=''):
295 """Fill residual and pull of a - b."""
296 h = self._h
297 if not (math.isfinite(mu_a) and math.isfinite(mu_b)):
298 return
299 h[f'{prefix}res{suffix}'].Fill(mu_a - mu_b)
300 den2 = err_a * err_a + err_b * err_b
301 if den2 > 0.0:
302 h[f'{prefix}pull{suffix}'].Fill((mu_a - mu_b) / math.sqrt(den2))
303
304 def _fill_pairs(self, lab, trk):
305 """Same-category pairs of tracks, the mu+ first."""
306 h = self._h
307 for i in range(len(trk)):
308 for j in range(i + 1, len(trk)):
309 a, b = trk[i], trk[j]
310 if self._opp_charges_only and a['charge'] * b['charge'] >= 0:
311 continue
312 if a['charge'] < 0 < b['charge']:
313 a, b = b, a
314 den2 = a['err'] ** 2 + b['err'] ** 2
315 if not den2 > 0.0:
316 continue
317 d = a['mu'] - b['mu']
318 pull = d / math.sqrt(den2)
319 h[f'res_{lab}'].Fill(d)
320 h[f'pull_{lab}'].Fill(pull)
321 ki, kj = _sector_index(lab, a), _sector_index(lab, b)
322 n = N_SECTOR_BINS[lab]
323 if 0 <= ki < n and 0 <= kj < n:
324 h[f'res_sector_{lab}'][(ki, kj)].Fill(d)
325 h[f'pull_sector_{lab}'][(ki, kj)].Fill(pull)
326
327 def _fill_charges(self, particle):
328 """Charge of the track's scintillator digits before the charge selection (good digits, normal channels)."""
329 track = particle.getTrack()
330 if not track:
331 return
332 status = self._channel_status
333
334 def normal(digit):
335 return (not status.isValid() or
336 status.obj().getChannelStatus(digit.getUniqueChannelID()) == Belle2.KLMChannelStatus.c_Normal)
337
338 for hit2d in track.getRelationsTo['Belle2::KLMHit2d']('KLMHit2ds'):
339 if hit2d.getSubdetector() == Belle2.KLMElementNumbers.c_EKLM:
340 for digit in hit2d.getRelationsTo['Belle2::KLMDigit']('KLMDigits'):
341 if digit.isGood() and normal(digit):
342 self._h['digitQ_E'].Fill(digit.getCharge())
343 elif not hit2d.inRPC() and not hit2d.isOutOfTime():
344 for hit1d in hit2d.getRelationsTo['Belle2::BKLMHit1d']('BKLMHit1ds'):
345 for digit in hit1d.getRelationsTo['Belle2::KLMDigit']('KLMDigits'):
346 if not digit.inRPC() and digit.isGood() and normal(digit):
347 self._h['digitQ_B'].Fill(digit.getCharge())
348
350 """Core-fit mean and width of every sector pair, and the per-track resolution."""
351 h = self._h
352 for kind in ('pull', 'res'):
353 for name, sfx, lab in CATEGORIES:
354 for (i, j), hh in h[f'{kind}_sector_{lab}'].items():
355 fit = core_fit(hh)
356 if fit is None:
357 continue
358 h[f'{kind}_sector_{lab}_mean'].SetBinContent(i + 1, j + 1, fit[0])
359 h[f'{kind}_sector_{lab}_mean'].SetBinError(i + 1, j + 1, fit[1])
360 h[f'{kind}_sector_{lab}_sigma'].SetBinContent(i + 1, j + 1, fit[2])
361 h[f'{kind}_sector_{lab}_sigma'].SetBinError(i + 1, j + 1, fit[3])
362 for b, key in enumerate(['dimuon_all', 'dimuon_B', 'dimuon_R', 'dimuon_E'], 1):
363 fit = core_fit(h[key])
364 if fit is not None:
365 h['resolution'].SetBinContent(b, fit[2] / math.sqrt(2.0))
366 h['resolution'].SetBinError(b, fit[3] / math.sqrt(2.0))
Class to access a DBObjPtr from Python.
Definition PyDBObj.h:50
a (simplified) python wrapper for StoreObjPtr.
Definition PyStoreObj.h:67
_fill_diff(self, mu_a, err_a, mu_b, err_b, suffix, prefix='')
_channel_status
KLM channel status, for the charge histograms.
__init__(self, muon_list_name='mu+:forT0', output_file='KLMEventT0Validation.root', opposite_charges_only=True)