Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
ElectronAnalysisConfig.py 46.81 KiB
# Copyright (C) 2002-2025 CERN for the benefit of the ATLAS collaboration

# AnaAlgorithm import(s):
from AnalysisAlgorithmsConfig.ConfigBlock import ConfigBlock
from AthenaCommon.SystemOfUnits	import GeV
from AthenaConfiguration.Enums import LHCPeriod
from AnalysisAlgorithmsConfig.ConfigAccumulator import DataType
from TrigGlobalEfficiencyCorrection.TriggerLeg_DictHelpers import TriggerDict, MapKeysDict
from Campaigns.Utils import Campaign
from AthenaCommon.Logging import logging

# E/gamma import(s).
from xAODEgamma.xAODEgammaParameters import xAOD

import PATCore.ParticleDataType


class ElectronCalibrationConfig (ConfigBlock) :
    """the ConfigBlock for the electron four-momentum correction"""

    def __init__ (self, containerName='') :
        super (ElectronCalibrationConfig, self).__init__ ()
        self.setBlockName('Electrons')
        self.addOption ('inputContainer', '', type=str,  
            info="select electron input container, by default set to Electrons")
        self.addOption ('containerName', containerName, type=str,
            noneAction='error',
            info="the name of the output container after calibration.")
        self.addOption ('ESModel', '', type=str,
            info="flag of egamma calibration recommendation.")
        self.addOption ('decorrelationModel', '1NP_v1', type=str,
            info="egamma energy scale decorrelationModel. The default is 1NP_v1. "
            "Supported Model: 1NP_v1, FULL_v1.")
        self.addOption ('postfix', '', type=str,
            info="a postfix to apply to decorations and algorithm names. Typically "
            "not needed here since the calibration is common to all electrons.")
        self.addOption ('crackVeto', False, type=bool,
            info="whether to perform LAr crack veto based on the cluster eta, "
            "i.e. remove electrons within 1.37<|eta|<1.52. The default "
            "is False.")
        self.addOption ('isolationCorrection', False, type=bool,
            info="whether or not to perform isolation corrections (leakage "
            "corrections), i.e. set up an instance of "
            "CP::EgammaIsolationCorrectionAlg.")
        self.addOption ('recalibratePhyslite', True, type=bool,
            info="whether to run the CP::EgammaCalibrationAndSmearingAlg on "
            "PHYSLITE derivations. The default is True.")
        self.addOption ('minPt', 4.5*GeV, type=float,
            info="the minimum pT cut to apply to calibrated electrons. "
            "The default is 4.5 GeV.")
        self.addOption ('maxEta', 2.47, type=float,
            info="maximum electron |eta| (float). The default is 2.47.")
        self.addOption ('forceFullSimConfig', False, type=bool,
            info="whether to force the tool to use the configuration meant for "
            "full simulation samples. Only for testing purposes. The default "
            "is False.")

        self.addOption ('splitCalibrationAndSmearing', False, type=bool,
            info="EXPERIMENTAL: This splits the EgammaCalibrationAndSmearingTool "
            " into two steps. The first step applies a baseline calibration that "
            "is not affected by systematics. The second step then applies the "
            "systematics dependent corrections.  The net effect is that the "
            "slower first step only has to be run once, while the second is run "
            "once per systematic. ATLASG-2358")
    
        self.addOption ('decorateTruth', False, type=bool,
            info="decorate truth particle information on the reconstructed one")
        self.addOption ('decorateCaloClusterEta', False, type=bool,
            info="decorate the calo cluster eta on the reconstructed one")
        self.addOption ('writeTrackD0Z0', False, type = bool,
            info="save the d0 significance and z0sinTheta variables so they can be written out")
        self.addOption ('decorateEmva', False, type=bool,
            info="decorate E_mva_only on the objects (needed for columnar tools/PHYSLITE)")

        self.addOption ('decorateSamplingPattern', False, type=bool,
            info="add samplingPattern decorations to clusters as part of PHYSLITE")


    def makeCalibrationAndSmearingAlg (self, config, name) :
        """Create the calibration and smearing algorithm

        Factoring this out into its own function, as we want to
        instantiate it in multiple places"""
        log = logging.getLogger('ElectronCalibrationConfig')

        # Set up the calibration and smearing algorithm:
        alg = config.createAlgorithm( 'CP::EgammaCalibrationAndSmearingAlg', name + self.postfix )
        config.addPrivateTool( 'calibrationAndSmearingTool',
                            'CP::EgammaCalibrationAndSmearingTool' )
        # Set default ESModel per period
        if self.ESModel:
            alg.calibrationAndSmearingTool.ESModel = self.ESModel
        else:
            if config.geometry() is LHCPeriod.Run2:
                alg.calibrationAndSmearingTool.ESModel = 'es2023_R22_Run2_v1'
            elif config.geometry() is LHCPeriod.Run3:
                alg.calibrationAndSmearingTool.ESModel = 'es2022_R22_PRE'
            elif config.geometry() is LHCPeriod.Run4:
                log.warning("No ESModel set for Run4, using Run 3 model instead")
                alg.calibrationAndSmearingTool.ESModel = 'es2022_R22_PRE'
            else:
                raise ValueError (f"Can't set up the ElectronCalibrationConfig with {config.geometry().value}, "
                                  "there must be something wrong!")

        alg.calibrationAndSmearingTool.decorrelationModel = self.decorrelationModel
        alg.calibrationAndSmearingTool.useFastSim = (
            0 if self.forceFullSimConfig
            else int( config.dataType() is DataType.FastSim ))
        alg.calibrationAndSmearingTool.decorateEmva = self.decorateEmva
        alg.egammas = config.readName (self.containerName)
        alg.egammasOut = config.copyName (self.containerName)
        alg.preselection = config.getPreselection (self.containerName, '')
        return alg


    def makeAlgs (self, config) :

        log = logging.getLogger('ElectronCalibrationConfig')

        if self.forceFullSimConfig:
            log.warning("You are running ElectronCalibrationConfig forcing full sim config")
            log.warning(" This is only intended to be used for testing purposes")

        inputContainer = "AnalysisElectrons" if config.isPhyslite() else "Electrons"
        if self.inputContainer:
            inputContainer = self.inputContainer
        config.setSourceName (self.containerName, inputContainer)

        # Decorate calo cluster eta if required
        if self.decorateCaloClusterEta:
            alg = config.createAlgorithm( 'CP::EgammaCaloClusterEtaAlg',
                                          'ElectronEgammaCaloClusterEtaAlg' + self.postfix,
                                           reentrant=True )
            alg.particles = config.readName(self.containerName)
            config.addOutputVar (self.containerName, 'caloEta2', 'caloEta2', noSys=True)

        if self.decorateSamplingPattern:
            config.createAlgorithm( 'CP::EgammaSamplingPatternDecoratorAlg', 'EgammaSamplingPatternDecoratorAlg' + self.postfix )

        # Set up a shallow copy to decorate
        if config.wantCopy (self.containerName) :
            alg = config.createAlgorithm( 'CP::AsgShallowCopyAlg', 'ElectronShallowCopyAlg' + self.postfix )
            alg.input = config.readName (self.containerName)
            alg.output = config.copyName (self.containerName)

        # Set up the eta-cut on all electrons prior to everything else
        alg = config.createAlgorithm( 'CP::AsgSelectionAlg', 'ElectronEtaCutAlg' + self.postfix )
        alg.selectionDecoration = 'selectEta' + self.postfix + ',as_bits'
        config.addPrivateTool( 'selectionTool', 'CP::AsgPtEtaSelectionTool' )
        alg.selectionTool.maxEta = self.maxEta
        if self.crackVeto:
            alg.selectionTool.etaGapLow = 1.37
            alg.selectionTool.etaGapHigh = 1.52
        alg.selectionTool.useClusterEta = True
        alg.particles = config.readName (self.containerName)
        alg.preselection = config.getPreselection (self.containerName, '')
        config.addSelection (self.containerName, '', alg.selectionDecoration)

        # Select electrons only with good object quality.
        alg = config.createAlgorithm( 'CP::AsgSelectionAlg', 'ElectronObjectQualityAlg' + self.postfix )
        alg.selectionDecoration = 'goodOQ' + self.postfix + ',as_bits'
        config.addPrivateTool( 'selectionTool', 'CP::EgammaIsGoodOQSelectionTool' )
        alg.selectionTool.Mask = xAOD.EgammaParameters.BADCLUSELECTRON
        alg.particles = config.readName (self.containerName)
        alg.preselection = config.getPreselection (self.containerName, '')
        config.addSelection (self.containerName, '', alg.selectionDecoration)

        if not self.splitCalibrationAndSmearing :
            # Set up the calibration and smearing algorithm:
            alg = self.makeCalibrationAndSmearingAlg (config, 'ElectronCalibrationAndSmearingAlg')
            if config.isPhyslite() and not self.recalibratePhyslite :
                alg.skipNominal = True
        else:
            # This splits the EgammaCalibrationAndSmearingTool into two
            # steps. The first step applies a baseline calibration that
            # is not affected by systematics. The second step then
            # applies the systematics dependent corrections.  The net
            # effect is that the slower first step only has to be run
            # once, while the second is run once per systematic.
            #
            # For now (22 May 24) this has to happen in the same job, as
            # the output of the first step is not part of PHYSLITE, and
            # even for the nominal the output of the first and second
            # step are different.  In the future the plan is to put both
            # the output of the first and second step into PHYSLITE,
            # allowing to skip the first step when running on PHYSLITE.
            #
            # WARNING: All of this is experimental, see: ATLASG-2358

            # Set up the calibration algorithm:
            alg = self.makeCalibrationAndSmearingAlg (config, 'ElectronBaseCalibrationAlg')
            # turn off systematics for the calibration step
            alg.noToolSystematics = True
            # turn off smearing for the calibration step
            alg.calibrationAndSmearingTool.doSmearing = False

            # Set up the smearing algorithm:
            alg = self.makeCalibrationAndSmearingAlg (config, 'ElectronCalibrationSystematicsAlg')
            # turn off scale corrections for the smearing step
            alg.calibrationAndSmearingTool.doScaleCorrection = False
            alg.calibrationAndSmearingTool.useMVACalibration = False
            alg.calibrationAndSmearingTool.decorateEmva = False

        if self.minPt > 0 :
            # Set up the the pt selection
            alg = config.createAlgorithm( 'CP::AsgSelectionAlg', 'ElectronPtCutAlg' + self.postfix )
            alg.selectionDecoration = 'selectPt' + self.postfix + ',as_bits'
            config.addPrivateTool( 'selectionTool', 'CP::AsgPtEtaSelectionTool' )
            alg.selectionTool.minPt = self.minPt
            alg.particles = config.readName (self.containerName)
            alg.preselection = config.getPreselection (self.containerName, '')
            config.addSelection (self.containerName, '', alg.selectionDecoration,
                                preselection=True)

        # Set up the isolation correction algorithm:
        if self.isolationCorrection:
            alg = config.createAlgorithm( 'CP::EgammaIsolationCorrectionAlg',
                                          'ElectronIsolationCorrectionAlg' + self.postfix )
            config.addPrivateTool( 'isolationCorrectionTool',
                                   'CP::IsolationCorrectionTool' )
            alg.isolationCorrectionTool.IsMC = config.dataType() is not DataType.Data
            alg.isolationCorrectionTool.AFII_corr = (
                0 if self.forceFullSimConfig
                else config.dataType() is DataType.FastSim)
            alg.egammas = config.readName (self.containerName)
            alg.egammasOut = config.copyName (self.containerName)
            alg.preselection = config.getPreselection (self.containerName, '')

        # Additional decorations
        if self.writeTrackD0Z0:
            alg = config.createAlgorithm( 'CP::AsgLeptonTrackDecorationAlg',
                                          'LeptonTrackDecorator' + self.containerName + self.postfix,
                                           reentrant=True )
            alg.particles = config.readName (self.containerName)

        alg = config.createAlgorithm( 'CP::AsgEnergyDecoratorAlg', 'EnergyDecorator' + self.containerName + self.postfix )
        alg.particles = config.readName(self.containerName)

        config.addOutputVar (self.containerName, 'pt', 'pt')
        config.addOutputVar (self.containerName, 'eta', 'eta', noSys=True)
        config.addOutputVar (self.containerName, 'phi', 'phi', noSys=True)
        config.addOutputVar (self.containerName, 'e_%SYS%', 'e')
        config.addOutputVar (self.containerName, 'charge', 'charge', noSys=True)

        if self.writeTrackD0Z0:
            config.addOutputVar (self.containerName, 'd0_%SYS%', 'd0', noSys=True)
            config.addOutputVar (self.containerName, 'd0sig_%SYS%', 'd0sig', noSys=True)
            config.addOutputVar (self.containerName, 'z0sintheta_%SYS%', 'z0sintheta', noSys=True)
            config.addOutputVar (self.containerName, 'z0sinthetasig_%SYS%', 'z0sinthetasig', noSys=True)

        # decorate truth information on the reconstructed object:
        if self.decorateTruth and config.dataType() is not DataType.Data:
            config.addOutputVar (self.containerName, "truthType", "truth_type", noSys=True)
            config.addOutputVar (self.containerName, "truthOrigin", "truth_origin", noSys=True)

            config.addOutputVar (self.containerName, "firstEgMotherPdgId", "truth_firstEgMotherPdgId", noSys=True)
            config.addOutputVar (self.containerName, "firstEgMotherTruthOrigin", "truth_firstEgMotherTruthOrigin", noSys=True)
            config.addOutputVar (self.containerName, "firstEgMotherTruthType", "truth_firstEgMotherTruthType", noSys=True)


class ElectronWorkingPointConfig (ConfigBlock) :
    """the ConfigBlock for the electron working point

    This may at some point be split into multiple blocks (29 Aug 22)."""

    def __init__ (self, containerName='', selectionName='') :
        super (ElectronWorkingPointConfig, self).__init__ ()
        self.addOption ('containerName', containerName, type=str,
            noneAction='error',
            info="the name of the input container.")
        self.addOption ('selectionName', selectionName, type=str,
            noneAction='error',
            info="the name of the electron selection to define (e.g. tight or "
            "loose).")
        self.addOption ('postfix', None, type=str,
            info="a postfix to apply to decorations and algorithm names. "
            "Typically not needed here as selectionName is used internally.")
        self.addOption ('trackSelection', True, type=bool,
            info="whether or not to set up an instance of "
            "CP::AsgLeptonTrackSelectionAlg, with the recommended d_0 and "
            "z_0 sin(theta) cuts. The default is True.")
        self.addOption ('maxD0Significance', 5, type=float,
            info="maximum d0 significance used for the trackSelection"
            "The default is 5")
        self.addOption ('maxDeltaZ0SinTheta', 0.5, type=float,
            info="maximum z0sinTheta in mm used for the trackSelection"
            "The default is 0.5 mm")
        self.addOption ('identificationWP', None, type=str,
            info="the ID WP (string) to use. Supported ID WPs: TightLH, "
            "MediumLH, LooseBLayerLH, TightDNN, MediumDNN, LooseDNN, "
            "TightNoCFDNN, MediumNoCFDNN, VeryLooseNoCF97DNN.")
        self.addOption ('isolationWP', None, type=str,
            info="the isolation WP (string) to use. Supported isolation WPs: "
            "HighPtCaloOnly, Loose_VarRad, Tight_VarRad, TightTrackOnly_"
            "VarRad, TightTrackOnly_FixedRad, NonIso.")
        self.addOption ('addSelectionToPreselection', True, type=bool,
            info="whether to retain only electrons satisfying the working point "
            "requirements. The default is True.")
        self.addOption ('closeByCorrection', False, type=bool,
            info="whether to use close-by-corrected isolation working points")
        self.addOption ('recomputeID', False, type=bool,
            info="whether to rerun the ID LH/DNN. The default is False, i.e. to use "
            "derivation flags.")
        self.addOption ('chargeIDSelectionRun2', False, type=bool,
            info="whether to run the ECIDS tool. Only available for run 2. "
            "The default is False.")
        self.addOption ('recomputeChargeID', False, type=bool,
            info="whether to rerun the ECIDS. The default is False, i.e. to use "
            "derivation flags.")
        self.addOption ('doFSRSelection', False, type=bool,
            info="whether to accept additional electrons close to muons for "
            "the purpose of FSR corrections to these muons. Expert feature "
            "requested by the H4l analysis running on PHYSLITE. "
            "The default is False.")
        self.addOption ('noEffSF', False, type=bool,
            info="disables the calculation of efficiencies and scale factors. "
            "Experimental! only useful to test a new WP for which scale "
            "factors are not available. The default is False.")
        self.addOption ('saveDetailedSF', True, type=bool,
            info="save all the independent detailed object scale factors. "
            "The default is True.")
        self.addOption ('saveCombinedSF', False, type=bool,
            info="save the combined object scale factor. "
            "The default is False.")
        self.addOption ('forceFullSimConfig', False, type=bool,
            info="whether to force the tool to use the configuration meant for "
            "full simulation samples. Only for testing purposes. "
            "The default is False.")
        self.addOption ('correlationModelId', 'SIMPLIFIED', type=str,
            info="the correlation model (string) to use for ID scale factors "
            "Supported models: SIMPLIFIED (default), FULL, TOTAL, TOYS")
        self.addOption ('correlationModelIso', 'SIMPLIFIED', type=str,
            info="the correlation model (string) to use for isolation scale factors "
            "Supported models: SIMPLIFIED (default), FULL, TOTAL, TOYS")
        self.addOption ('correlationModelReco', 'SIMPLIFIED', type=str,
            info="the correlation model (string) to use for reconstruction scale factors "
            "Supported models: SIMPLIFIED (default), FULL, TOTAL, TOYS")


    def makeAlgs (self, config) :

        log = logging.getLogger('ElectronWorkingPointConfig')

        if self.forceFullSimConfig:
            log.warning("You are running ElectronWorkingPointConfig forcing full sim config")
            log.warning("This is only intended to be used for testing purposes")

        selectionPostfix = self.selectionName
        if selectionPostfix != '' and selectionPostfix[0] != '_' :
            selectionPostfix = '_' + selectionPostfix

        # The setup below is inappropriate for Run 1
        if config.geometry() is LHCPeriod.Run1:
            raise ValueError ("Can't set up the ElectronWorkingPointConfig with %s, there must be something wrong!" % config.geometry().value)

        postfix = self.postfix
        if postfix is None :
            postfix = self.selectionName
        if postfix != '' and postfix[0] != '_' :
            postfix = '_' + postfix

        # Set up the track selection algorithm:
        if self.trackSelection :
            alg = config.createAlgorithm( 'CP::AsgLeptonTrackSelectionAlg',
                                          'ElectronTrackSelectionAlg' + postfix,
                                          reentrant=True )
            alg.selectionDecoration = 'trackSelection' + postfix + ',as_bits'
            alg.maxD0Significance = self.maxD0Significance
            alg.maxDeltaZ0SinTheta = self.maxDeltaZ0SinTheta
            alg.particles = config.readName (self.containerName)
            alg.preselection = config.getPreselection (self.containerName, '')
            if self.trackSelection :
                config.addSelection (self.containerName, self.selectionName, alg.selectionDecoration,
                                     preselection=self.addSelectionToPreselection)

        if 'LH' in self.identificationWP:
            # Set up the likelihood ID selection algorithm
            # It is safe to do this before calibration, as the cluster E is used
            alg = config.createAlgorithm( 'CP::AsgSelectionAlg', 'ElectronLikelihoodAlg' + postfix )
            alg.selectionDecoration = 'selectLikelihood' + selectionPostfix + ',as_char'
            if self.recomputeID:
                # Rerun the likelihood ID
                config.addPrivateTool( 'selectionTool', 'AsgElectronLikelihoodTool' )
                alg.selectionTool.primaryVertexContainer = 'PrimaryVertices'
                # Here we have to match the naming convention of EGSelectorConfigurationMapping.h
                # which differ from the one used for scale factors
                if config.geometry() >= LHCPeriod.Run3:
                    alg.selectionTool.WorkingPoint = self.identificationWP.replace("BLayer","BL") + 'Electron'
                elif config.geometry() is LHCPeriod.Run2:
                    alg.selectionTool.WorkingPoint = self.identificationWP.replace("BLayer","BL") + 'Electron_Run2'
            else:
                # Select from Derivation Framework flags
                config.addPrivateTool( 'selectionTool', 'CP::AsgFlagSelectionTool' )
                dfFlag = "DFCommonElectronsLH" + self.identificationWP.split('LH')[0]
                dfFlag = dfFlag.replace("BLayer","BL")
                alg.selectionTool.selectionFlags = [dfFlag]
        elif 'SiHit' in self.identificationWP:
            # Only want SiHit electrons, so veto loose LH electrons
            algVeto = config.createAlgorithm( 'CP::AsgSelectionAlg', 'ElectronLikelihoodAlgVeto' + postfix + 'Veto')
            algVeto.selectionDecoration = 'selectLikelihoodVeto' + postfix + ',as_char'
            config.addPrivateTool( 'selectionTool', 'CP::AsgFlagSelectionTool' )
            algVeto.selectionTool.selectionFlags = ["DFCommonElectronsLHLoose"]
            algVeto.selectionTool.invertFlags    = [True]
            algVeto.particles = config.readName (self.containerName)
            algVeto.preselection = config.getPreselection (self.containerName, self.selectionName)
            # add the veto as a selection
            config.addSelection (self.containerName, self.selectionName, algVeto.selectionDecoration,
                                 preselection=self.addSelectionToPreselection)

            # Select SiHit electrons using IsEM bits
            alg = config.createAlgorithm( 'CP::AsgSelectionAlg', 'ElectronLikelihoodAlg' + postfix )
            alg.selectionDecoration = 'selectSiHit' + selectionPostfix + ',as_char'
            # Select from Derivation Framework IsEM bits
            config.addPrivateTool( 'selectionTool', 'CP::AsgMaskSelectionTool' )
            dfVar = "DFCommonElectronsLHLooseBLIsEMValue"
            alg.selectionTool.selectionVars = [dfVar]
            mask = int( 0 | 0x1 << 1 | 0x1 << 2)
            alg.selectionTool.selectionMasks = [mask]
        elif 'DNN' in self.identificationWP:
            if self.chargeIDSelectionRun2:
                raise ValueError('DNN is not intended to be used with '
                                 '`chargeIDSelectionRun2` option as there are '
                                 'DNN WPs containing charge flip rejection.')
            # Set up the DNN ID selection algorithm
            alg = config.createAlgorithm( 'CP::AsgSelectionAlg', 'ElectronDNNAlg' + postfix )
            alg.selectionDecoration = 'selectDNN' + selectionPostfix + ',as_char'
            if self.recomputeID:
                # Rerun the DNN ID
                config.addPrivateTool( 'selectionTool', 'AsgElectronSelectorTool' )
                # Here we have to match the naming convention of EGSelectorConfigurationMapping.h
                if config.geometry() is LHCPeriod.Run3:
                    raise ValueError ( "DNN working points are not available for Run 3 yet.")
                else:
                    alg.selectionTool.WorkingPoint = self.identificationWP + 'Electron'
            else:
                # Select from Derivation Framework flags
                config.addPrivateTool( 'selectionTool', 'CP::AsgFlagSelectionTool' )
                dfFlag = "DFCommonElectronsDNN" + self.identificationWP.split('DNN')[0]
                alg.selectionTool.selectionFlags = [dfFlag]

        alg.particles = config.readName (self.containerName)
        alg.preselection = config.getPreselection (self.containerName, self.selectionName)
        config.addSelection (self.containerName, self.selectionName, alg.selectionDecoration,
                             preselection=self.addSelectionToPreselection)

        # maintain order of selections
        if 'SiHit' in self.identificationWP:
            # Set up the ElectronSiHitDecAlg algorithm to decorate SiHit electrons with a minimal amount of information:
            algDec = config.createAlgorithm( 'CP::ElectronSiHitDecAlg', 'ElectronSiHitDecAlg' + postfix )
            selDec = 'siHitEvtHasLeptonPair' + selectionPostfix + ',as_char'
            algDec.selectionName     = selDec.split(",")[0]
            algDec.ElectronContainer = config.readName (self.containerName)
            # Set flag to only collect SiHit electrons for events with an electron or muon pair to minimize size increase from SiHit electrons
            algDec.RequireTwoLeptons = True
            config.addSelection (self.containerName, self.selectionName, selDec,
                                 preselection=self.addSelectionToPreselection)

        # Set up the FSR selection
        if self.doFSRSelection :
            # save the flag set for the WP
            wpFlag = alg.selectionDecoration.split(",")[0]
            alg = config.createAlgorithm( 'CP::EgammaFSRForMuonsCollectorAlg', 'EgammaFSRForMuonsCollectorAlg' + postfix )
            alg.selectionDecoration = wpFlag
            alg.ElectronOrPhotonContKey = config.readName (self.containerName)
            # For SiHit electrons, set flag to remove FSR electrons.
            # For standard electrons, FSR electrons need to be added as they may be missed by the standard selection.
            # For SiHit electrons FSR electrons are generally always selected, so they should be removed since they will be in the standard electron container.
            if 'SiHit' in self.identificationWP:
                alg.vetoFSR = True

        # Set up the isolation selection algorithm:
        if self.isolationWP != 'NonIso' :
            alg = config.createAlgorithm( 'CP::EgammaIsolationSelectionAlg',
                                          'ElectronIsolationSelectionAlg' + postfix )
            alg.selectionDecoration = 'isolated' + selectionPostfix + ',as_char'
            config.addPrivateTool( 'selectionTool', 'CP::IsolationSelectionTool' )
            alg.selectionTool.ElectronWP = self.isolationWP
            if self.closeByCorrection:
              alg.selectionTool.IsoDecSuffix = "CloseByCorr"
            alg.egammas = config.readName (self.containerName)
            alg.preselection = config.getPreselection (self.containerName, self.selectionName)
            config.addSelection (self.containerName, self.selectionName, alg.selectionDecoration,
                                 preselection=self.addSelectionToPreselection)

        if self.chargeIDSelectionRun2 and config.geometry() >= LHCPeriod.Run3:
            log.warning("ECIDS is only available for Run 2 and will not have effect in run 3.")

        # Select electrons only if they don't appear to have flipped their charge.
        if self.chargeIDSelectionRun2 and config.geometry() < LHCPeriod.Run3:
            alg = config.createAlgorithm( 'CP::AsgSelectionAlg',
                                          'ElectronChargeIDSelectionAlg' + postfix )
            alg.selectionDecoration = 'chargeID' + selectionPostfix + ',as_char'
            if self.recomputeChargeID:
                # Rerun the ECIDS BDT
                config.addPrivateTool( 'selectionTool',
                                       'AsgElectronChargeIDSelectorTool' )
                alg.selectionTool.TrainingFile = \
                    'ElectronPhotonSelectorTools/ChargeID/ECIDS_20180731rel21Summer2018.root'
                alg.selectionTool.WorkingPoint = 'Loose'
                alg.selectionTool.CutOnBDT = -0.337671 # Loose 97%
            else:
                # Select from Derivation Framework flags
                config.addPrivateTool( 'selectionTool', 'CP::AsgFlagSelectionTool' )
                alg.selectionTool.selectionFlags = ["DFCommonElectronsECIDS"]

            alg.particles = config.readName (self.containerName)
            alg.preselection = config.getPreselection (self.containerName, self.selectionName)
            config.addSelection (self.containerName, self.selectionName, alg.selectionDecoration,
                                 preselection=self.addSelectionToPreselection)

        correlationModels = ["SIMPLIFIED", "FULL", "TOTAL", "TOYS"]

        sfList = []
        # Set up the RECO electron efficiency correction algorithm:
        if config.dataType() is not DataType.Data and not self.noEffSF:

            if config.geometry() is LHCPeriod.Run2:
                raise ValueError('Run 2 does not yet have efficiency correction, '
                                 'please disable it by setting `noEffSF` to True.')
            if 'DNN' in self.identificationWP:
                raise ValueError('DNN does not yet have efficiency correction, '
                                 'please disable it by setting `noEffSF` to True.')

            alg = config.createAlgorithm( 'CP::ElectronEfficiencyCorrectionAlg',
                                          'ElectronEfficiencyCorrectionAlgReco' + postfix )
            config.addPrivateTool( 'efficiencyCorrectionTool',
                                   'AsgElectronEfficiencyCorrectionTool' )
            alg.scaleFactorDecoration = 'el_reco_effSF' + selectionPostfix + '_%SYS%'
            alg.efficiencyCorrectionTool.RecoKey = "Reconstruction"
            if self.correlationModelReco not in correlationModels:
                raise ValueError('Invalid correlation model for reconstruction efficiency, '
                                 f'has to be one of: {", ".join(correlationModels)}')
            if config.geometry() >= LHCPeriod.Run3 and self.correlationModelReco != "TOTAL":
                log.warning("Only TOTAL correlation model is currently supported "
                            "for reconstruction efficiency correction in Run 3.")
                alg.efficiencyCorrectionTool.CorrelationModel = "TOTAL"
            else:
                alg.efficiencyCorrectionTool.CorrelationModel = self.correlationModelReco
            if config.dataType() is DataType.FastSim:
                alg.efficiencyCorrectionTool.ForceDataType = (
                    PATCore.ParticleDataType.Full if self.forceFullSimConfig
                    else PATCore.ParticleDataType.Fast)
            elif config.dataType() is DataType.FullSim:
                alg.efficiencyCorrectionTool.ForceDataType = \
                    PATCore.ParticleDataType.Full
            alg.outOfValidity = 2 #silent
            alg.outOfValidityDeco = 'el_reco_bad_eff' + selectionPostfix
            alg.electrons = config.readName (self.containerName)
            alg.preselection = config.getPreselection (self.containerName, self.selectionName)
            if self.saveDetailedSF:
                config.addOutputVar (self.containerName, alg.scaleFactorDecoration,
                                     'reco_effSF' + postfix)
            sfList += [alg.scaleFactorDecoration]

        # Set up the ID electron efficiency correction algorithm:
        if config.dataType() is not DataType.Data and not self.noEffSF:
            alg = config.createAlgorithm( 'CP::ElectronEfficiencyCorrectionAlg',
                                          'ElectronEfficiencyCorrectionAlgID' + postfix )
            config.addPrivateTool( 'efficiencyCorrectionTool',
                                   'AsgElectronEfficiencyCorrectionTool' )
            alg.scaleFactorDecoration = 'el_id_effSF' + selectionPostfix + '_%SYS%'
            alg.efficiencyCorrectionTool.IdKey = self.identificationWP.replace("LH","")
            if self.correlationModelId not in correlationModels:
                raise ValueError('Invalid correlation model for identification efficiency, '
                                 f'has to be one of: {", ".join(correlationModels)}')
            alg.efficiencyCorrectionTool.CorrelationModel = self.correlationModelId
            if config.dataType() is DataType.FastSim:
                alg.efficiencyCorrectionTool.ForceDataType = (
                    PATCore.ParticleDataType.Full if self.forceFullSimConfig
                    else PATCore.ParticleDataType.Fast)
            elif config.dataType() is DataType.FullSim:
                alg.efficiencyCorrectionTool.ForceDataType = \
                    PATCore.ParticleDataType.Full
            alg.outOfValidity = 2 #silent
            alg.outOfValidityDeco = 'el_id_bad_eff' + selectionPostfix
            alg.electrons = config.readName (self.containerName)
            alg.preselection = config.getPreselection (self.containerName, self.selectionName)
            if self.saveDetailedSF:
                config.addOutputVar (self.containerName, alg.scaleFactorDecoration,
                                     'id_effSF' + postfix)
            sfList += [alg.scaleFactorDecoration]

        # Set up the ISO electron efficiency correction algorithm:
        if config.dataType() is not DataType.Data and self.isolationWP != 'NonIso' and not self.noEffSF:
            alg = config.createAlgorithm( 'CP::ElectronEfficiencyCorrectionAlg',
                                          'ElectronEfficiencyCorrectionAlgIsol' + postfix )
            config.addPrivateTool( 'efficiencyCorrectionTool',
                                   'AsgElectronEfficiencyCorrectionTool' )
            alg.scaleFactorDecoration = 'el_isol_effSF' + selectionPostfix + '_%SYS%'
            alg.efficiencyCorrectionTool.IdKey = self.identificationWP.replace("LH","")
            alg.efficiencyCorrectionTool.IsoKey = self.isolationWP
            if self.correlationModelIso not in correlationModels:
                raise ValueError('Invalid correlation model for isolation efficiency, '
                                 f'has to be one of: {", ".join(correlationModels)}')
            if config.geometry() >= LHCPeriod.Run3:
                log.warning("Only TOTAL correlation model is currently supported "
                      "for isolation efficiency correction in Run 3.")
                alg.efficiencyCorrectionTool.CorrelationModel = "TOTAL"
            else:
                alg.efficiencyCorrectionTool.CorrelationModel = self.correlationModelIso
            if config.dataType() is DataType.FastSim:
                alg.efficiencyCorrectionTool.ForceDataType = (
                    PATCore.ParticleDataType.Full if self.forceFullSimConfig
                    else PATCore.ParticleDataType.Fast)
            elif config.dataType() is DataType.FullSim:
                alg.efficiencyCorrectionTool.ForceDataType = \
                    PATCore.ParticleDataType.Full
            alg.outOfValidity = 2 #silent
            alg.outOfValidityDeco = 'el_isol_bad_eff' + selectionPostfix
            alg.electrons = config.readName (self.containerName)
            alg.preselection = config.getPreselection (self.containerName, self.selectionName)
            if self.saveDetailedSF:
                config.addOutputVar (self.containerName, alg.scaleFactorDecoration,
                                     'isol_effSF' + postfix)
            sfList += [alg.scaleFactorDecoration]

        # TO-DO: add trigger SFs, for which we need ID key + ISO key + Trigger key !

        if self.chargeIDSelectionRun2:
            # ECIDS is currently not supported in R22.
            # SFs might become available or it will be part of the DNN ID.
            pass

        if config.dataType() is not DataType.Data and not self.noEffSF and self.saveCombinedSF:
            alg = config.createAlgorithm( 'CP::AsgObjectScaleFactorAlg',
                                          'ElectronCombinedEfficiencyScaleFactorAlg' + postfix )
            alg.particles = config.readName (self.containerName)
            alg.inScaleFactors = sfList
            alg.outScaleFactor = 'effSF' + postfix + '_%SYS%'
            config.addOutputVar (self.containerName, alg.outScaleFactor, 'effSF' + postfix)


class ElectronTriggerAnalysisSFBlock (ConfigBlock):

    def __init__ (self, configName='') :
        super (ElectronTriggerAnalysisSFBlock, self).__init__ ()

        self.addOption ('triggerChainsPerYear', {}, type=None,
                        info="a dictionary with key (string) the year and value (list of "
                        "strings) the trigger chains. The default is {} (empty dictionary).")
        self.addOption ('electronID', '', type=str,
                        info="the electron ID WP (string) to use.")
        self.addOption ('electronIsol', '', type=str,
                        info="the electron isolation WP (string) to use.")
        self.addOption ('saveEff', False, type=bool,
                        info="define whether we decorate also the trigger scale efficiency "
                        "The default is false.")
        self.addOption ('prefixSF', 'trigEffSF', type=str,
                        info="the decoration prefix for trigger scale factors, "
                        "the default is 'trigEffSF'")
        self.addOption ('prefixEff', 'trigEff', type=str,
                        info="the decoration prefix for MC trigger efficiencies, "
                        "the default is 'trigEff'")
        self.addOption ('includeAllYears', False, type=bool,
                        info="if True, all configured years will be included in all jobs. "
                        "The default is False.")
        self.addOption ('removeHLTPrefix', True, type=bool,
                        info="remove the HLT prefix from trigger chain names, "
                        "The default is True.")
        self.addOption ('useToolKeyAsOutput', False, type=bool,
                        info="use tool trigger key as output, "
                        "The default is False.")
        self.addOption ('containerName', '', type=str,
                        info="the input electron container, with a possible selection, in "
                        "the format container or container.selection.")

    def makeAlgs (self, config) :

        if config.dataType() is not DataType.Data:
            log = logging.getLogger('ElectronTriggerSFConfig')

            if self.includeAllYears and not self.useToolKeyAsOutput:
                log.warning('`includeAllYears` is set to True, but `useToolKeyAsOutput` is set to False. '
                            'This will cause multiple branches to be written out with the same content.')

            # Dictionary from TrigGlobalEfficiencyCorrection/Triggers.cfg
            # Key is trigger chain (w/o HLT prefix)
            # Value is empty for single leg trigger or list of legs
            triggerDict = TriggerDict()

            # currently recommended versions
            version_Run2 = "2015_2018/rel21.2/Precision_Summer2020_v1"
            version_Run3 = "2015_2025/rel22.2/2022_Summer_Prerecom_v1"

            version = version_Run2 if config.geometry() is LHCPeriod.Run2 else version_Run3
            # Dictionary from TrigGlobalEfficiencyCorrection/MapKeys.cfg
            # Key is year_leg
            # Value is list of configs available, first one will be used
            mapKeysDict = MapKeysDict(version)

            # helper function for leg filtering, very hardcoded but allows autoconfiguration
            def filterConfFromMap(conf, electronMapKeys):
                if not conf:
                    raise ValueError("No configuration found for trigger chain.")
                if len(conf) == 1:
                    return conf[0]

                for c in conf:
                    if c in electronMapKeys:
                        return c

                return conf[0]

            if self.includeAllYears:
                years = [int(year) for year in self.triggerChainsPerYear.keys()]
                if any(year in years for year in [2015, 2016, 2017, 2018]) \
                    and any(year in years for year in [2022, 2023, 2024, 2025]):
                    raise ValueError("Mixing years from Run 2 and Run 3 in the same job is currently not supported.")
            elif config.campaign() is Campaign.MC20a:
                years = [2015, 2016]
            elif config.campaign() is Campaign.MC20d:
                years = [2017]
            elif config.campaign() is Campaign.MC20e:
                years = [2018]
            elif config.campaign() in [Campaign.MC21a, Campaign.MC23a]:
                years = [2022]
            elif config.campaign() in [Campaign.MC23c, Campaign.MC23d]:
                years = [2023]

            # prepare keys
            import ROOT
            triggerChainsPerYear_Run2 = {}
            triggerChainsPerYear_Run3 = {}
            for year, chains in self.triggerChainsPerYear.items():
                if not chains:
                    log.warning("No trigger chains configured for year %s. "
                                "Assuming this is intended, no Electron trigger SF will be computed.", year)
                    continue

                chains_split = [chain.replace("HLT_", "").replace(" || ", "_OR_") for chain in chains]
                if int(year) >= 2022:
                    triggerChainsPerYear_Run3[str(year)] = ' || '.join(chains_split)
                else:
                    triggerChainsPerYear_Run2[str(year)] = ' || '.join(chains_split)
            electronMapKeys_Run2 = ROOT.std.map("string", "string")()
            electronMapKeys_Run3 = ROOT.std.map("string", "string")()

            sc_Run2 = ROOT.TrigGlobalEfficiencyCorrectionTool.suggestElectronMapKeys(triggerChainsPerYear_Run2, version_Run2, electronMapKeys_Run2)
            sc_Run3 = ROOT.TrigGlobalEfficiencyCorrectionTool.suggestElectronMapKeys(triggerChainsPerYear_Run3, version_Run3, electronMapKeys_Run3)
            if sc_Run2.code() != 2 or sc_Run3.code() != 2:
                raise RuntimeError("Failed to suggest electron map keys")
            electronMapKeys = dict(electronMapKeys_Run2) | dict(electronMapKeys_Run3)

            # collect configurations
            triggerConfigs = {}
            for year in years:
                triggerChains = self.triggerChainsPerYear.get(int(year), self.triggerChainsPerYear.get(str(year), []))
                for chain in triggerChains:
                    chain = chain.replace(" || ", "_OR_")
                    chain_noHLT = chain.replace("HLT_", "")
                    chain_out = chain_noHLT if self.removeHLTPrefix else chain
                    legs = triggerDict[chain_noHLT]
                    if not legs:
                        if chain_noHLT[0] == 'e' and chain_noHLT[1].isdigit:
                            chain_key = f"{year}_{chain_noHLT}"
                            chain_conf = mapKeysDict[chain_key][0]
                            triggerConfigs[chain_conf if self.useToolKeyAsOutput else chain_out] = chain_conf
                    else:
                        for leg in legs:
                            if leg[0] == 'e' and leg[1].isdigit:
                                leg_out = leg if self.removeHLTPrefix else f"HLT_{leg}"
                                leg_key = f"{year}_{leg}"
                                leg_conf = filterConfFromMap(mapKeysDict[leg_key], electronMapKeys)
                                triggerConfigs[leg_conf if self.useToolKeyAsOutput else leg_out] = leg_conf

            decorations = [self.prefixSF]
            if self.saveEff:
                decorations += [self.prefixEff]

            for label, conf in triggerConfigs.items():
                for deco in decorations:
                    alg = config.createAlgorithm('CP::ElectronEfficiencyCorrectionAlg',
                                                 'EleTrigEfficiencyCorrectionsAlg' + deco +
                                                 '_' + label)
                    config.addPrivateTool( 'efficiencyCorrectionTool',
                                           'AsgElectronEfficiencyCorrectionTool' )

                    # Reproduce config from TrigGlobalEfficiencyAlg
                    alg.efficiencyCorrectionTool.MapFilePath = "ElectronEfficiencyCorrection/" + version + "/map4.txt"
                    alg.efficiencyCorrectionTool.IdKey = self.electronID.replace("LH","")
                    alg.efficiencyCorrectionTool.IsoKey = self.electronIsol
                    alg.efficiencyCorrectionTool.TriggerKey = (
                        ("Eff_" if deco == self.prefixEff else "") + conf)
                    alg.efficiencyCorrectionTool.CorrelationModel = "TOTAL"
                    alg.efficiencyCorrectionTool.ForceDataType = \
                        PATCore.ParticleDataType.Full

                    alg.scaleFactorDecoration = f"el_{deco}_{label}_%SYS%"

                    alg.outOfValidity = 2 #silent
                    alg.outOfValidityDeco = f"bad_eff_ele{deco}_{label}"
                    alg.electrons = config.readName (self.containerName)
                    alg.preselection = config.getPreselection (self.containerName, "")
                    config.addOutputVar (self.containerName, alg.scaleFactorDecoration, f"{deco}_{label}")


class ElectronLRTMergedConfig (ConfigBlock) :
    def __init__ (self) :  
        super (ElectronLRTMergedConfig, self).__init__ ()
        self.addOption (
            'inputElectrons', 'Electrons', type=str,
            noneAction='error',
            info="the name of the input electron container."
        )
        self.addOption (
            'inputLRTElectrons', 'LRTElectrons', type=str,
            noneAction='error',
            info="the name of the input LRT electron container."
        )
        self.addOption (
            'containerName', 'Electrons_LRTMerged', type=str,
            noneAction='error',
            info="the name of the output container after LRT merging."
        )


    def makeAlgs (self, config) :

        if config.isPhyslite() :
            raise(RuntimeError("Electron LRT merging is not available in Physlite mode"))

        alg = config.createAlgorithm( "CP::ElectronLRTMergingAlg", "ElectronLRTMergingAlg" + self.containerName )
        alg.PromptElectronLocation = self.inputElectrons
        alg.LRTElectronLocation = self.inputLRTElectrons
        alg.OutputCollectionName = self.containerName
        alg.CreateViewCollection = False