Belle II Software light-2609-luna
dstarVeto.py
1#!/usr/bin/env python3
2
9
10"""
11D* Veto reconstruction for ModeSelector.
12
13This module reconstructs D* candidates from D mesons (daughters of B candidates)
14combined with soft pions or pi0s from the Rest of Event. The reconstructed
15D* mass difference (deltaMassDiff) and vertex fit chi2 probability are stored
16as ExtraInfo on the B candidates.
17
18This helps identify B -> D* X decays that were reconstructed as B -> D X
19by the FEI, which the ModeSelector uses as input features (feature blocks 5-8).
20"""
21
22import math
23
24import basf2 as b2
25import modularAnalysis as ma
26from ROOT import Belle2
27from variables import variables as vm
28from vertex import kFit, treeFit
29
30
31class _SetDstarVetoDefaults(b2.Module):
32 """Set missing D* veto ExtraInfo keys to NaN on all B candidates.
33
34 This ensures every B candidate has Dstp_deltaMassDiff and Dst0_deltaMassDiff
35 set (even when the corresponding D* type is not its first daughter and no veto
36 candidate is reconstructed).
37 """
38
39 def __init__(self, particle_lists, keys):
40 """Set up with lists of particle list names and ExtraInfo key names."""
41 super().__init__()
42
43 self._particle_lists = particle_lists
44
45 self._keys = keys
46
47 def event(self):
48 """Set missing ExtraInfo keys to NaN for every candidate in each list."""
49 for list_name in self._particle_lists:
50 plist = Belle2.PyStoreObj(list_name)
51 if not plist.isValid():
52 continue
53 for i in range(plist.obj().getListSize()):
54 p = plist.obj().getParticle(i)
55 for key in self._keys:
56 if not p.hasExtraInfo(key):
57 p.addExtraInfo(key, math.nan)
58
59
60def add_dstar_veto_aliases():
61 """
62 Add variable aliases needed for D* veto reconstruction.
63 """
64 # D* - D mass difference using invariant mass
65 vm.addAlias('trueM', 'M - dM')
66 vm.addAlias('massDiffInvM', 'formula(InvM - daughter(0, M))')
67 vm.addAlias('trueMassDiff', 'trueM - daughter(0,trueM)')
68 vm.addAlias('deltaMassDiffInvM', 'massDiffInvM - trueMassDiff')
69 vm.addAlias('deltaMassDiff', 'massDifference(0) - trueMassDiff')
70
71 vm.addAlias('dmID', 'extraInfo(decayModeID)')
72
73
74def addDstarVeto(
75 particleLists,
76 path: b2.Path = None,
77 deltaMassDiffCut: tuple = (-0.02, 0.02),
78 dMassCut: tuple = (-0.03, 0.03),
79 writeExtraInfo: bool = True,
80 skipTreeFit: bool = True
81):
82 """
83 Add D* veto reconstruction to the path for B meson particle lists.
84
85 This function reconstructs D* candidates by combining D mesons (first daughter
86 of B candidates) with soft pions or pi0s from the Rest of Event, to identify
87 cases where the FEI reconstructed B -> D X but the true decay was B -> D* X.
88
89 For B candidates with D0 as first daughter:
90 - D*+ -> D0 pi+ (from ROE)
91 - D*0 -> D0 pi0 (from ROE)
92
93 For B candidates with D+ as first daughter:
94 - D*+ -> D+ pi0 (from ROE)
95
96 The veto builds its own ROE on private particles that wrap the B candidates, so it
97 neither uses nor creates an ROE related to the B candidates. An ROE built by the user
98 on the input lists, before or after this function, is independent of the veto.
99
100 Parameters:
101 particleLists (str or list): Name(s) of B meson particle list(s)
102 (e.g., 'B+:feiHadronic' or ['B+:feiHadronic', 'B0:feiHadronic'])
103 path (basf2.Path): The basf2 path to add modules to.
104 deltaMassDiffCut (tuple): Cut on deltaMassDiff (D* mass diff - true mass diff) in GeV.
105 dMassCut (tuple): Cut on D and D* mass deviation (dM) in GeV.
106 writeExtraInfo (bool): Whether to write ExtraInfo to particles.
107 skipTreeFit (bool): If True, skip the vertex TreeFit (significant speedup). The
108 deltaMassDiff will use InvM-based computation instead of fit-based, and chiProb
109 will not be available (stored as NaN). Candidates are ranked by
110 abs(deltaMassDiffInvM) instead of chiProb. Default: True
111
112 The following ExtraInfo fields are added to B candidates:
113
114 For D0 daughter (D*+ and D*0 veto):
115 - Dstp_deltaMassDiff: Delta mass difference for D*+ -> D0 pi+
116 - Dstp_chiProb: Vertex fit chi2 probability for D*+ (NaN if skipTreeFit)
117 - Dst0_deltaMassDiff: Delta mass difference for D*0 -> D0 pi0
118 - Dst0_chiProb: Vertex fit chi2 probability for D*0 (NaN if skipTreeFit)
119
120 For D+ daughter (D*+ veto only):
121 - Dstp_deltaMassDiff: Delta mass difference for D*+ -> D+ pi0
122 - Dstp_chiProb: Vertex fit chi2 probability for D*+ (NaN if skipTreeFit)
123 """
124 if path is None:
125 b2.B2FATAL("Path is required for addDstarVeto")
126
127 if isinstance(particleLists, str):
128 particleLists = [particleLists]
129
130 add_dstar_veto_aliases()
131
132 # ExtraInfo variable mappings
133 extra_info_dstp = {
134 'daughter(0,chiProb)': 'Dstp_chiProb',
135 'daughter(0,deltaMassDiff)': 'Dstp_deltaMassDiff',
136 }
137 extra_info_dst0 = {
138 'daughter(0,chiProb)': 'Dst0_chiProb',
139 'daughter(0,deltaMassDiff)': 'Dst0_deltaMassDiff',
140 }
141
142 # For veto reconstruction, use InvM-based deltaMassDiff
143 extra_info_veto_dstp = {
144 'daughter(0,deltaMassDiffInvM)': 'Dstp_deltaMassDiff',
145 }
146 extra_info_veto_dst0 = {
147 'daughter(0,deltaMassDiffInvM)': 'Dst0_deltaMassDiff',
148 }
149 if not skipTreeFit:
150 extra_info_veto_dstp['daughter(0,chiProb)'] = 'Dstp_chiProb'
151 extra_info_veto_dst0['daughter(0,chiProb)'] = 'Dst0_chiProb'
152
153 # Create pi+ list for D*+ -> D0 pi+ reconstruction
154 from_ip = "[[dr < 2] and [abs(dz) < 4]]"
155 p_cut = " and [p > 0.05] and [useCMSFrame(p) < 0.5]"
156 ma.fillParticleList('pi+:dstVeto', from_ip + p_cut, path=path)
157
158 # Create pi0 list for D* veto reconstruction. The lists are private to the veto,
159 # so they cannot collide with a list of the same name created elsewhere, e.g. one
160 # without the photon MVA, whose suppression cuts would then silently reject all pi0s.
161 # The selection reproduces the 50% efficiency pi0 selection optimised in May 2020
162 # (photon and pi0 cuts, mass-constrained fit) that the models were trained with.
163 photon_cuts = '[clusterNHits > 1.5] and thetaInCDCAcceptance and ' \
164 '[[clusterReg == 1 and E > 0.025] or [clusterReg == 2 and E > 0.025] or [clusterReg == 3 and E > 0.040]]'
165 ma.fillParticleList('gamma:dstVeto', photon_cuts, path=path)
166 # Photon MVA weights the training was done with (MC16rd)
167 ma.getBeamBackgroundProbability('gamma:dstVeto', weight='MC16rd', path=path)
168 ma.getFakePhotonProbability('gamma:dstVeto', weight='MC16rd', path=path)
169 ma.reconstructDecay('pi0:dstVetoAll -> gamma:dstVeto gamma:dstVeto', '0.105 < InvM < 0.150', dmID=1, path=path)
170 kFit('pi0:dstVetoAll', 0.0, 'mass', path=path)
171
172 # Apply additional pi0 cuts (matching training preprocessing)
173 pi0Cuts = '[useCMSFrame(p) < 0.5]'
174 pi0Cuts += ' and [daughter(0,beamBackgroundSuppression) > 0.5] and [daughter(0,fakePhotonSuppression) > 0.1]'
175 pi0Cuts += ' and [daughter(1,beamBackgroundSuppression) > 0.5] and [daughter(1,fakePhotonSuppression) > 0.1]'
176
177 ma.cutAndCopyList('pi0:dstVeto', 'pi0:dstVetoAll', pi0Cuts, path=path)
178
179 # --- Process each particle list ---
180 for particleList in particleLists:
181 particle_type = particleList.split(':')[0] # e.g., 'B+' or 'B0'
182 list_label = particleList.split(':')[1] if ':' in particleList else ''
183
184 # --- Process B candidates with D*+ or D*0 as first daughter ---
185 # These already have the correct mass difference, just store it
186 dstp_daughter_list = f'{particle_type}:dstVeto_Dstp_{list_label}'
187 dst0_daughter_list = f'{particle_type}:dstVeto_Dst0_{list_label}'
188
189 ma.cutAndCopyList(dstp_daughter_list, particleList,
190 '[abs(daughter(0,PDG)) == 413]', path=path)
191 ma.cutAndCopyList(dst0_daughter_list, particleList,
192 '[abs(daughter(0,PDG)) == 423]', path=path)
193
194 if writeExtraInfo:
195 ma.variablesToExtraInfo(dstp_daughter_list, extra_info_dstp, option=0, path=path)
196 ma.variablesToExtraInfo(dst0_daughter_list, extra_info_dst0, option=0, path=path)
197
198 # --- Process B candidates with D0 or D+ as first daughter (veto) ---
199 for d_pdg, d_str in [(421, 'D0'), (411, 'Dp')]:
200 channel_name = f'{particle_type}:dstVeto_{d_str}_{list_label}'
201 d_particle = 'D0' if d_pdg == 421 else 'D+'
202
203 ma.cutAndCopyList(channel_name, particleList,
204 f'abs(daughter(0,PDG)) == {d_pdg}', path=path)
205
206 # Wrap each B candidate in a new single-daughter particle and build the ROE for
207 # the wrappers. A particle can only have one ROE, so building it on the B
208 # candidates would make the veto reuse an ROE built by the user on the same
209 # candidates (with possibly different input lists), and an ROE built here would
210 # in turn be reused by the user. The wrappers are private to the veto, and their
211 # ROE contains the same tracks and clusters as an ROE of the B candidate.
212 wrapper_list = f'Xsd:dstVeto_{d_str}_{"Bp" if particle_type == "B+" else "B0"}_{list_label}'
213 ma.reconstructDecay(f'{wrapper_list} -> {channel_name}', '', allowChargeViolation=True, path=path)
214 path.modules()[-1].set_log_level(b2.LogLevel.ERROR)
215 ma.buildRestOfEvent(wrapper_list, path=path)
216
217 # Create ROE path
218 roe_path = b2.Path()
219 dead_end_path = b2.Path()
220
221 ma.signalSideParticleFilter(wrapper_list, '', roe_path, dead_end_path)
222
223 # Get particles from ROE or direct B daughters
224 roe_condition = f'[isInRestOfEvent == 1] or [isDescendantOfList({channel_name},1) == 1]'
225 ma.cutAndCopyList('pi0:dstVetoROE', 'pi0:dstVeto', roe_condition, path=roe_path)
226
227 if d_str == 'D0':
228 # D0 can form D*+ (with pi+) or D*0 (with pi0)
229 dst_daughters = ['pi+', 'pi0']
230 ma.cutAndCopyList('pi+:dstVetoROE', 'pi+:dstVeto', roe_condition, path=roe_path)
231 else:
232 # D+ can only form D*+ (with pi0)
233 dst_daughters = ['pi0']
234
235 # Fill signal side D
236 ma.fillSignalSideParticleList(f'{d_particle}:dstVetoSig', f'Xsd -> [{particle_type} -> ^{d_particle}]',
237 path=roe_path)
238
239 dstp_lists = []
240 dst0_lists = []
241
242 for i, dst_daughter in enumerate(dst_daughters):
243 if d_str == 'Dp' or (d_str == 'D0' and dst_daughter == 'pi+'):
244 dst_list = f'D*+:dstVeto_{i}'
245 else:
246 dst_list = f'D*0:dstVeto_{i}'
247
248 ma.reconstructDecay(f'{dst_list} -> {d_particle}:dstVetoSig {dst_daughter}:dstVetoROE',
249 '', dmID=i, path=roe_path)
250
251 # Mass window cuts
252 cut_str = f'[{deltaMassDiffCut[0]} < deltaMassDiffInvM < {deltaMassDiffCut[1]}]'
253 cut_str += f' and [{dMassCut[0]} < dM < {dMassCut[1]}]'
254 cut_str += f' and [{dMassCut[0]} < daughter(0,dM) < {dMassCut[1]}]'
255 ma.applyCuts(dst_list, cut_str, path=roe_path)
256
257 # Rank by best candidate
258 if dst_daughter == 'pi0':
259 ma.rankByHighest(dst_list, 'daughter(1,chiProb)', 1, path=roe_path)
260 elif dst_daughter == 'pi+':
261 if skipTreeFit:
262 ma.rankByLowest(dst_list, 'abs(deltaMassDiffInvM)', 1, path=roe_path)
263 # else: will rank by vertex fit quality after fit
264
265 if not skipTreeFit:
266 # Vertex fit with mass constraints
267 treeFit(
268 list_name=dst_list,
269 conf_level=0,
270 ipConstraint=False,
271 updateAllDaughters=False,
272 massConstraint=["D*+", "D*0", "D+", "D0", "K_S0", "pi0"],
273 path=roe_path,
274 )
275
276 if dst_daughter == 'pi+':
277 ma.rankByHighest(dst_list, 'chiProb', 1, path=roe_path)
278
279 if 'D*+:dstVeto' in dst_list:
280 dstp_lists.append(dst_list)
281 else:
282 dst0_lists.append(dst_list)
283
284 # Merge D* lists
285 if dstp_lists:
286 ma.copyLists('D*+:dstVeto', dstp_lists, writeOut=False, path=roe_path)
287 ma.applyCuts('D*+:dstVeto', 'useCMSFrame(p) < 3', path=roe_path)
288 ma.rankByLowest('D*+:dstVeto', 'dmID', 1, path=roe_path)
289
290 if dst0_lists:
291 ma.copyLists('D*0:dstVeto', dst0_lists, writeOut=False, path=roe_path)
292 ma.applyCuts('D*0:dstVeto', 'useCMSFrame(p) < 3', path=roe_path)
293 ma.rankByLowest('D*0:dstVeto', 'dmID', 1, path=roe_path)
294
295 # Create dummy particles to transfer ExtraInfo back to signal side
296 if dstp_lists:
297 ma.reconstructDecay('Xsd:dstVetoDstp -> D*+:dstVeto', '', allowChargeViolation=True, path=roe_path)
298 roe_path.modules()[-1].set_log_level(b2.LogLevel.ERROR)
299 if writeExtraInfo:
300 ma.variableToSignalSideExtraInfo('Xsd:dstVetoDstp', extra_info_veto_dstp, path=roe_path)
301
302 if dst0_lists:
303 ma.reconstructDecay('Xsd:dstVetoDst0 -> D*0:dstVeto', '', allowChargeViolation=True, path=roe_path)
304 roe_path.modules()[-1].set_log_level(b2.LogLevel.ERROR)
305 if writeExtraInfo:
306 ma.variableToSignalSideExtraInfo('Xsd:dstVetoDst0', extra_info_veto_dst0, path=roe_path)
307
308 # Execute ROE path
309 path.for_each('RestOfEvent', 'RestOfEvents', roe_path)
310
311 # The ROE loop writes the results to the wrappers; copy them to the B candidates
312 if writeExtraInfo:
313 veto_keys = list(extra_info_veto_dstp.values())
314 if d_str == 'D0':
315 veto_keys += list(extra_info_veto_dst0.values())
316 ma.variablesToDaughterExtraInfo(
317 wrapper_list, f'Xsd -> ^{particle_type}',
318 {f'extraInfo({key})': key for key in veto_keys}, path=path)
319
320 if writeExtraInfo:
321 # After all real values are set, fill any remaining missing deltaMassDiff
322 # keys with NaN. This ensures every B candidate has these keys so that
323 # ModeSelectorModule's hasExtraInfo check does not FATAL. NaN is converted
324 # to None in ModeSelectorModule and stored as 0 in the sparse feature matrix,
325 # matching the behaviour of a genuinely absent veto candidate.
326 path.add_module(_SetDstarVetoDefaults(
327 [particleList],
328 ['Dstp_deltaMassDiff', 'Dst0_deltaMassDiff'],
329 ))
330
331 b2.B2INFO(f"DstarVeto: Added D* veto reconstruction for {particleLists}")
a (simplified) python wrapper for StoreObjPtr.
Definition PyStoreObj.h:67
_particle_lists
Particle list names to iterate over.
Definition dstarVeto.py:43
__init__(self, particle_lists, keys)
Definition dstarVeto.py:39
_keys
ExtraInfo key names to set to NaN if missing.
Definition dstarVeto.py:45