@@ -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