Skip to content

Commit ce22bb2

Browse files
Introduction of tau reconstruction (#522)
1 parent 1fe0a9f commit ce22bb2

2 files changed

Lines changed: 328 additions & 0 deletions

File tree

analyzers/dataframe/FCCAnalyses/ReconstructedParticle.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// EDM4hep
1111
#include "edm4hep/ReconstructedParticleData.h"
1212
#include "edm4hep/ParticleIDData.h"
13+
#include "JetConstituentsUtils.h"
1314

1415
namespace FCCAnalyses ::ReconstructedParticle {
1516
/**
@@ -198,6 +199,12 @@ struct recoilBuilder {
198199
* @brief Get number of b-jets.
199200
*/
200201
int getJet_ntags(ROOT::VecOps::RVec<bool> inBJetMask);
202+
203+
// Identify hadronic tau candidates in jets.
204+
ROOT::VecOps::RVec<edm4hep::ReconstructedParticleData> findTauInJet(
205+
const ROOT::VecOps::RVec<FCCAnalyses::JetConstituentsUtils::FCCAnalysesJetConstituents>& jets,
206+
int request);
207+
201208
} // namespace FCCAnalyses::ReconstructedParticle
202209

203210
#endif /* RECONSTRUCTEDPARTICLE_ANALYZERS_H */

analyzers/dataframe/src/ReconstructedParticle.cc

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,4 +466,325 @@ int getJet_ntags(ROOT::VecOps::RVec<bool> inBJetMask) {
466466
return std::count_if(inBJetMask.begin(), inBJetMask.end(),
467467
[](bool bJet) { return bJet; });
468468
}
469+
470+
// Identify hadronic tau decays from a jet collection and, optionally, extract specific
471+
// sub-components of the decay (e.g. the charged/neutral pions of a 1- or 3-prong decay).
472+
// Efficiency of the code and validation can be found in https://arxiv.org/abs/2601.11383.
473+
//
474+
// Usage: first cluster the event into jets (e.g. clustering_ee_kt(2, 4, 1, 0)), then call this
475+
// function on the jet constituents. For each jet, the algorithm:
476+
// 1) picks the highest-pT charged constituent as the seed ("leading pion"),
477+
// 2) rejects the jet if that seed is below `kLeadPtMin` GeV, or if the jet contains a
478+
// constituent identified as an electron or muon,
479+
// 3) adds further charged/neutral constituents within `kConeHalfAngle` rad of the running
480+
// tau direction and above `kCompanionPtMin` GeV,
481+
// 4) classifies the candidate by prong multiplicity (1 or 3 charged tracks, net charge +-1)
482+
// and photon/neutral-hadron count into a `tauID` (see table below), and discards the
483+
// candidate (tauID = -2) if its visible mass exceeds `kTauMassMax` GeV.
484+
//
485+
// `request` selects which four-vector is written out for a candidate:
486+
// 0 : full visible tau
487+
// 1 : charged pion with the same charge as the tau
488+
// (for a 3-prong decay, the one paired with the opposite-charge pion closest to
489+
// the rho(770) mass; for a 1-prong decay, simply the leading pion)
490+
// 2 : the tau's other component - the opposite-charge pion of the rho pair (3-prong)
491+
// or the summed neutral system (1-prong)
492+
// 3 : sum of all charged pions
493+
// 4 (or any other value) : summed neutral system only
494+
// Requests 1-4 only differ from each other in the 3-prong case; for 1-prong decays,
495+
// 1 == the single charged pion and 2/4 == the neutral system.
496+
//
497+
// Output: one edm4hep::ReconstructedParticleData entry per input jet (never skipped), so the
498+
// output vector can be zipped index-by-index with the input jets and filtered by `type`:
499+
// type == tauID (0-6 for 1-prong, 10-16 for 3-prong, higher tauID = more photons/pi0s found)
500+
// type == -1 : no charged constituent above kLeadPtMin found in the jet
501+
// type == -11 : jet contains an electron
502+
// type == -13 : jet contains a muon
503+
// type == -2 : visible mass >= kTauMassMax
504+
// type == -3 : constituents don't form a valid 1- or 3-prong
505+
// type == -4 : leading charged constituent has |charge| != 1 (not expected in practice)
506+
ROOT::VecOps::RVec<edm4hep::ReconstructedParticleData> findTauInJet (const ROOT::VecOps::RVec< FCCAnalyses::JetConstituentsUtils::FCCAnalysesJetConstituents >& jets, int request){
507+
508+
// Constituent mass windows used to veto electrons/muons
509+
constexpr double kMuonMass = 0.105658; // GeV
510+
constexpr double kElectronMass = 0.000510999; // GeV
511+
constexpr double kMuonMassTol = 1.e-03;
512+
constexpr double kElectronMassTol = 1.e-05;
513+
// pT thresholds (GeV)
514+
constexpr double kLeadPtMin = 2.0;
515+
constexpr double kCompanionPtMin = 1.0;
516+
// Half-opening angle (rad) of the cone
517+
constexpr double kConeHalfAngle = 0.20;
518+
// Nominal rho(770) mass (GeV)
519+
constexpr double kRhoMass = 0.775;
520+
// Reject candidates with a visible mass at or above this (GeV)
521+
constexpr double kTauMassMax = 3.0;
522+
523+
ROOT::VecOps::RVec<edm4hep::ReconstructedParticleData> out;
524+
525+
for (size_t i = 0; i < jets.size(); ++i) {
526+
527+
// Full visible tau (0 in request)
528+
TLorentzVector sum_tau;
529+
edm4hep::ReconstructedParticleData Tau;
530+
// Neutral constituents (2 or 4 in request)
531+
TLorentzVector neutral;
532+
// Charged constituents (1 or 3 in request)
533+
TLorentzVector charged;
534+
// Individual ones
535+
ROOT::VecOps::RVec<TLorentzVector> charged_vec;
536+
// Their charge
537+
ROOT::VecOps::RVec<int> charges_vec;
538+
// Their track index to access later for track related variables
539+
ROOT::VecOps::RVec<int> track_vec;
540+
541+
int tauID = -1;
542+
int count_piP = 0, count_piM = 0, count_pho = 0;
543+
544+
// Leading particle
545+
TLorentzVector lead;
546+
lead.SetPxPyPzE(0, 0, 0, 0);
547+
int chargeLead = 0;
548+
int track = 0;
549+
550+
FCCAnalyses::JetConstituentsUtils::FCCAnalysesJetConstituents jcs = jets.at(i);
551+
552+
// First loop through the constituents to find the leading pion.
553+
for (const auto& jc : jcs) {
554+
555+
// No electrons or muons in hadronic tau decays
556+
if (fabs(jc.mass - kMuonMass) < kMuonMassTol) {
557+
tauID = -13;
558+
continue;
559+
}
560+
if (fabs(jc.mass - kElectronMass) < kElectronMassTol) {
561+
tauID = -11;
562+
continue;
563+
}
564+
565+
// Now find the leading charged particle
566+
if (sqrt(jc.momentum.x*jc.momentum.x + jc.momentum.y*jc.momentum.y) > lead.Pt() && jc.charge != 0) {
567+
lead.SetPxPyPzE(jc.momentum.x, jc.momentum.y, jc.momentum.z, jc.energy);
568+
chargeLead = jc.charge;
569+
track = jc.tracks_begin; // This saves the index in the track collection related to the leading pion
570+
}
571+
}
572+
573+
charges_vec.push_back(chargeLead);
574+
charged_vec.push_back(lead);
575+
track_vec.push_back(track);
576+
577+
if (lead.Pt() < kLeadPtMin) {
578+
tauID = -1;
579+
Tau.type = tauID;
580+
out.push_back(Tau);
581+
continue;
582+
} // Too low pt, the particle will be empty but still will show up as an entry
583+
584+
if (tauID==-13 || tauID==-11) {
585+
Tau.type = tauID;
586+
out.push_back(Tau);
587+
continue;
588+
} // Leptons in jet, the particle will be empty but still will show up as an entry
589+
590+
if (chargeLead==1) {
591+
count_piP++;
592+
} else if (chargeLead==-1) {
593+
count_piM++;
594+
} else {
595+
// Not expected: the seed passed the charge!=0 check above, so |charge| should be 1.
596+
Tau.type = -4;
597+
out.push_back(Tau);
598+
continue;
599+
}
600+
601+
sum_tau += lead;
602+
charged += lead;
603+
604+
// Now loop to build the tau adding candidates to the lead only if they satisfy some conditions: distance, charge, etc
605+
for (const auto& jc : jcs) {
606+
607+
TLorentzVector tlv;
608+
tlv.SetPxPyPzE(jc.momentum.x, jc.momentum.y, jc.momentum.z, jc.energy);
609+
610+
if (tlv==lead) continue;
611+
612+
// Distance (in terms of Theta) to the running tau direction
613+
double dTheta = fabs(sum_tau.Theta() - tlv.Theta());
614+
615+
if (tlv.Pt()<kCompanionPtMin || dTheta>kConeHalfAngle) continue;
616+
617+
if (jc.charge>0) {
618+
count_piP++;
619+
charged += tlv;
620+
charges_vec.push_back(jc.charge);
621+
charged_vec.push_back(tlv);
622+
track_vec.push_back(jc.tracks_begin);
623+
} else if (jc.charge<0) {
624+
count_piM++;
625+
charged += tlv;
626+
charges_vec.push_back(jc.charge);
627+
charged_vec.push_back(tlv);
628+
track_vec.push_back(jc.tracks_begin);
629+
} else {
630+
count_pho++;
631+
neutral += tlv;
632+
}
633+
634+
sum_tau += tlv;
635+
}
636+
637+
// Lets build the ID : count the charged pions and the neutrals.
638+
// Considering the decays of the tau, we want candidates with one or three charged candidates (one or three prongs).
639+
// The ID is then increased depending on the number of photons/neutral hadrons found.
640+
641+
if (tauID!=-13 && tauID!=-11 && abs(count_piP-count_piM)==1 && ((count_piP+count_piM)==1 || (count_piP+count_piM)==3)) {
642+
643+
if ((count_piP+count_piM)==1 && count_pho==0) tauID=0;
644+
if ((count_piP+count_piM)==1 && count_pho==1) tauID=1;
645+
if ((count_piP+count_piM)==1 && count_pho==2) tauID=2;
646+
if ((count_piP+count_piM)==1 && count_pho==3) tauID=3;
647+
if ((count_piP+count_piM)==1 && count_pho==4) tauID=4;
648+
if ((count_piP+count_piM)==1 && count_pho==5) tauID=5;
649+
if ((count_piP+count_piM)==1 && count_pho>=6) tauID=6;
650+
651+
if ((count_piP+count_piM)==3 && count_pho==0) tauID=10;
652+
if ((count_piP+count_piM)==3 && count_pho==1) tauID=11;
653+
if ((count_piP+count_piM)==3 && count_pho==2) tauID=12;
654+
if ((count_piP+count_piM)==3 && count_pho==3) tauID=13;
655+
if ((count_piP+count_piM)==3 && count_pho==4) tauID=14;
656+
if ((count_piP+count_piM)==3 && count_pho==5) tauID=15;
657+
if ((count_piP+count_piM)==3 && count_pho>=6) tauID=16;
658+
659+
if (request==0) {
660+
Tau.momentum.x = sum_tau.Px();
661+
Tau.momentum.y = sum_tau.Py();
662+
Tau.momentum.z = sum_tau.Pz();
663+
Tau.mass = sum_tau.M();
664+
Tau.energy = sum_tau.E();
665+
Tau.charge = (count_piP-count_piM);
666+
Tau.type = tauID;
667+
Tau.tracks_begin = track;
668+
} else if (request==1 || request==2) {
669+
// Get the pion from the rho resonance for the request==2
670+
if ((count_piP+count_piM)==3) {
671+
// Charge the tau candidate is required to have (+-1); the pion sharing this
672+
// charge in the opposite-charge pair closest to the rho mass is "second".
673+
int tauCharge = count_piP - count_piM;
674+
TLorentzVector second;
675+
int second_track = -1;
676+
TLorentzVector third;
677+
double minMassDifference = -1e6;
678+
for (size_t iCh = 0; iCh < charges_vec.size(); ++iCh) {
679+
for (size_t jCh = iCh + 1; jCh < charges_vec.size(); ++jCh) {
680+
if (charges_vec[iCh] + charges_vec[jCh] == 0) { // opposite charges
681+
double invMass = (charged_vec[iCh] + charged_vec[jCh]).M();
682+
double massDifference = std::abs(invMass - kRhoMass);
683+
684+
if (minMassDifference == -1e6 || massDifference < minMassDifference) {
685+
// First candidate pair found, or a smaller mass difference than before
686+
minMassDifference = massDifference;
687+
// Save the pion with same charge as the tau as the charged one and the other as the neutral
688+
if (charges_vec[iCh]==tauCharge) {
689+
second = charged_vec[iCh];
690+
third = charged_vec[jCh];
691+
second_track = track_vec[iCh];
692+
} else {
693+
second = charged_vec[jCh];
694+
third = charged_vec[iCh];
695+
second_track = track_vec[jCh];
696+
}
697+
}
698+
}
699+
}
700+
}
701+
702+
if (request==1) {
703+
Tau.momentum.x = second.Px();
704+
Tau.momentum.y = second.Py();
705+
Tau.momentum.z = second.Pz();
706+
Tau.mass = second.M();
707+
Tau.energy = second.E();
708+
Tau.charge = (count_piP-count_piM);
709+
Tau.type = tauID;
710+
Tau.tracks_begin = second_track;
711+
} else {
712+
Tau.momentum.x = third.Px();
713+
Tau.momentum.y = third.Py();
714+
Tau.momentum.z = third.Pz();
715+
Tau.mass = third.M();
716+
Tau.energy = third.E();
717+
Tau.charge = (count_piP-count_piM); // neutral particle has the same charge as the tau to allow for easy matching
718+
Tau.type = tauID;
719+
Tau.tracks_begin = second_track; // and same track index as the charged
720+
}
721+
} else {
722+
// 1-prong: only one charged pion exists, so it is trivially "the pion with the
723+
// same charge as the tau" (request==1); the neutral system is everything else.
724+
if (request==1) {
725+
Tau.momentum.x = lead.Px();
726+
Tau.momentum.y = lead.Py();
727+
Tau.momentum.z = lead.Pz();
728+
Tau.mass = lead.M();
729+
Tau.energy = lead.E();
730+
Tau.charge = (count_piP-count_piM);
731+
Tau.type = tauID;
732+
Tau.tracks_begin = track;
733+
} else {
734+
// The only charged constituent is the lead, so the neutral system is exactly
735+
// `neutral` (using it directly avoids the floating point cancellation of
736+
// computing sum_tau - lead).
737+
Tau.momentum.x = neutral.Px();
738+
Tau.momentum.y = neutral.Py();
739+
Tau.momentum.z = neutral.Pz();
740+
Tau.mass = neutral.M();
741+
Tau.energy = neutral.E();
742+
Tau.charge = (count_piP-count_piM); // neutral particle has the same charge as the tau to allow for easy matching
743+
Tau.type = tauID;
744+
Tau.tracks_begin = track; // and same track index as the charged
745+
}
746+
}
747+
} else if (request==3) {
748+
Tau.momentum.x = charged.Px();
749+
Tau.momentum.y = charged.Py();
750+
Tau.momentum.z = charged.Pz();
751+
Tau.mass = charged.M();
752+
Tau.energy = charged.E();
753+
Tau.charge = (count_piP-count_piM);
754+
Tau.type = tauID;
755+
Tau.tracks_begin = track;
756+
} else {
757+
Tau.momentum.x = neutral.Px();
758+
Tau.momentum.y = neutral.Py();
759+
Tau.momentum.z = neutral.Pz();
760+
Tau.mass = neutral.M();
761+
Tau.energy = neutral.E();
762+
Tau.charge = (count_piP-count_piM); // neutral particle has the same charge as the tau to allow for easy matching
763+
Tau.type = tauID;
764+
Tau.tracks_begin = track; // and same track index as the charged
765+
}
766+
767+
// Save taus (or its components) with mass below kTauMassMax
768+
if (sum_tau.M() < kTauMassMax) {
769+
out.push_back(Tau);
770+
} else {
771+
// Reset the particle if it's not a tau but keep a different ID to identify the cause
772+
Tau.momentum.x = 0;
773+
Tau.momentum.y = 0;
774+
Tau.momentum.z = 0;
775+
Tau.mass = 0;
776+
Tau.energy = 0;
777+
Tau.charge = 0;
778+
Tau.type = -2;
779+
out.push_back(Tau);
780+
}
781+
} else {
782+
// Prong number not matched
783+
Tau.type = -3;
784+
out.push_back(Tau);
785+
}
786+
}
787+
return out;
788+
}
789+
469790
} // namespace FCCAnalyses::ReconstructedParticle

0 commit comments

Comments
 (0)