Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-10-20 08:42:59

0001 """Compute angular resolution of BIC AstroPix layers
0002 
0003 This example script runs a simple calculation to compute the angular resolution
0004 of the BIC AstroPix (imaging) layers for a specified particle species. Used in
0005 the BIC example of an ePIC workflow.
0006 
0007 Must be run inside the eic-shell. To install following instructions here:
0008 
0009     https://eic.github.io/tutorial-setting-up-environment/
0010 
0011 Project: AID2E v0.0.0 - AI assisted Detector Design for EIC
0012 """
0013 
0014 import argparse as ap
0015 import json
0016 import numpy as np
0017 import sys
0018 from dataclasses import dataclass
0019 from typing import Dict
0020 
0021 import ROOT
0022 from podio.reading import get_reader
0023 
0024 
0025 # =============================================================================
0026 # Helper classes for the calculation
0027 # ============================================================================= 
0028 
0029 @dataclass
0030 class Options:
0031     """Options for calculation
0032 
0033     Attributes:
0034         ifiles: list of input files
0035         ofile: output file
0036         excludes: list of layer indices to exclude 
0037         angle: angular coordinate to use (theta, eta, ...)
0038         pdg: PDG code to use (optional)
0039         hits: input reco hit collection
0040         pars: input MC particle collection
0041         assocs: input cluster-particle associations
0042     """
0043     ifiles: list[str]
0044     ofile: str
0045     excludes: list[int]
0046     angle: str = "eta"
0047     pdg: int = 11
0048     hits: str = "EcalBarrelImagingRecHits"
0049     pars: str = "MCParticles"
0050     assocs: str = "EcalBarrelImagingClusterAssociations"
0051 
0052     def set_opts_from_args(self, args):
0053         """Set optional members from CLI arguments"""
0054         self.angle  = args.angle
0055         self.pdg    = args.pdg
0056         self.hits   = args.hits
0057         self.pars   = args.pars
0058         self.assocs = args.assocs
0059 
0060 # default options
0061 DEFAULT_OPTS = Options(
0062     ifiles = ["root://dtn-eic.jlab.org//volatile/eic/EPIC/RECO/26.02.0/epic_craterlake/SINGLE/e-/5GeV/45to135deg/e-_5GeV_45to135deg.0099.eicrecon.edm4eic.root"],
0063     ofile = "e-_5GeV_45to135deg.0099.angreso.hist.root",
0064     excludes = [],
0065 )
0066 
0067 
0068 @dataclass
0069 class Info:
0070     """Hit and Particle Info
0071 
0072     Helper class to store key information
0073     on hits and particles
0074 
0075     Attributes:
0076         energy: energy of hit/particle
0077         angle: anglular coordinate (theta, eta, ...)
0078         perp: radial coordinate (r/pt) of hit/particle
0079         layer: most upstream layer with hits
0080         vector: 3D position/momentum of hit/particle
0081     """
0082 
0083     energy: float = -999.0
0084     angle: float = -999.0
0085     perp: float = -999.0
0086     layer: int = -999
0087     vector: ROOT.Math.XYZVector = ROOT.Math.XYZVector(-999.0, -999.0, -999.0)
0088 
0089     def _set_vector(self, edmvec):
0090         """Set position vector from an edm4hep::Vector3f"""
0091         self.vector = ROOT.Math.XYZVector(
0092             edmvec.x,
0093             edmvec.y,
0094             edmvec.z
0095         )
0096 
0097     def _set_angle(self, coord):
0098         """Set angle based on provided angular coordinate name"""
0099         match coord:
0100             case "theta":
0101                 self.angle = self.vector.Theta()
0102             case "eta":
0103                 self.angle = self.vector.Eta()
0104             case "phi":
0105                 self.angle = self.vector.Phi()
0106             case _:
0107                 raise ValueError("Unknown coordinate specified!")
0108 
0109     def set_par_info(self, cname, par):
0110         """Extract info from an edm4hep::MCParticle"""
0111         self._set_vector(par.getMomentum())
0112         self._set_angle(cname)
0113         self.energy = par.getEnergy()
0114         self.perp   = self.vector.Rho()
0115 
0116     def set_hit_info(self, cname, hit):
0117         """Extract info from an edm4eic::CalorimeterHit"""
0118         self._set_vector(hit.getPosition())
0119         self._set_angle(cname)
0120         self.energy = hit.getEnergy()
0121         self.perp   = self.vector.Rho()
0122         self.layer  = hit.getLayer()
0123 
0124 # =============================================================================
0125 # Angular Resolution Calculation
0126 # ============================================================================= 
0127 
0128 def CalculateHitAngReso(opts: Options = DEFAULT_OPTS) -> Dict[str, float]:
0129     """Calculate angular resolution
0130 
0131     A function to calculate angular resolution for a 
0132     specified species of particle from BIC imaging
0133     hits according to this algorithm:
0134 
0135         1. Find the imaging cluster associated to thrown
0136            electron
0137         2. Locate the most energetic hit in each layer
0138            of the imaging cluster
0139         3. From these, select the hit in the most
0140            upstream layer and calculate the difference
0141            in angle
0142         4. Fit the main peak of the distribution of
0143            differences and extract the RMS of the
0144            peak
0145         5. Return the RMS as the resolution
0146 
0147     Args:
0148         opts: calculation options
0149 
0150     Returns:
0151         Dictionary of {key, value} where
0152         - key: the name of the objective associated with this script,
0153           in this case "resolution"
0154         - value: the value of the objective, in this case the RMS of
0155           the fit to the mc-reco differences
0156     """
0157 
0158     # sanitize coordinate input
0159     coord = opts.angle
0160     coord = coord.lower()
0161 
0162     # set up histograms, etc. -------------------------------------------------
0163 
0164     # set variable for axis accordingly 
0165     var = "x"
0166     match coord:
0167         case "theta":
0168             var = "#theta"
0169         case "eta":
0170             var = "#eta"
0171         case "phi":
0172             var = "#phi"
0173         case _:
0174             raise ValueError("Unknown coordinate specified!")
0175 
0176     # construct axis title
0177     axis = ";#delta" + var + " = " + var + "^{image}_{max hit} - " + var + "_{par}"
0178 
0179     # create histogram from extracting resolution
0180     hdiff = ROOT.TH1D("hAngRes", axis, 80, -0.2, 0.2)
0181     hdiff.Sumw2()
0182 
0183     # event loops -------------------------------------------------------------
0184 
0185     # loop through input files
0186     for ifile in opts.ifiles:
0187 
0188         # loop through all events
0189         reader = get_reader(ifile)
0190         for iframe, frame in enumerate(reader.get("events")):
0191 
0192             # grab relevant branches
0193             rehits = frame.get(opts.hits)
0194             mcpars = frame.get(opts.pars)
0195             assocs = frame.get(opts.assocs)
0196 
0197             # pick out the primary particle
0198             primary = None
0199             for par in mcpars:
0200                 status = par.getGeneratorStatus()
0201                 if par.getPDG() == opts.pdg and status == 1:
0202                     primary = par
0203                     break
0204 
0205             # if for some reason no primary was found,
0206             # skip event
0207             if primary is None:
0208                 print(f"Warning! Frame {iframe} has no primary in file:\n  -- {ifile}")
0209                 continue
0210 
0211             # scrape particle info for histogramming
0212             pinfo = Info()
0213             pinfo.set_par_info(coord, primary)
0214 
0215             # dictionaries to keep track of max energy
0216             # hits in each layer
0217             maxenes = {
0218                 1 : 0.0,
0219                 2 : 0.0,
0220                 3 : 0.0,
0221                 4 : 0.0,
0222                 5 : 0.0,
0223                 6 : 0.0
0224             }
0225             maxhits = dict()
0226 
0227             # now identify the most energetic hit in
0228             # each layer associated with the primary
0229             cluster = None
0230             for assoc in assocs:
0231 
0232                 if primary != assoc.getSim():
0233                     continue
0234                 else:
0235                     cluster = assoc.getRec()
0236 
0237                 # loop through hits to check layers
0238                 for hit in assoc.getRec().getHits():
0239 
0240                     layer = hit.getLayer()
0241                     if layer > 6:
0242                         print(f"Warning! Hit {hit.getObjectID().index} has a layer above 6 ({layer})!")
0243                         continue
0244 
0245                     if layer in excludes:
0246                         continue
0247 
0248                     if hit.getEnergy() > maxenes[layer]:
0249                         maxenes[layer] = hit.getEnergy()
0250                         maxhits[layer] = hit
0251 
0252             if cluster is None or len(maxhits) == 0:
0253                 continue
0254 
0255             # pick out most upstream layer from
0256             # most energetic hits
0257             minlayer = min(maxhits.keys())
0258 
0259             # scrape info from max hit in most
0260             # upstream layer
0261             hinfo = Info()
0262             hinfo.set_hit_info(coord, maxhits[minlayer])
0263 
0264             # calculate difference
0265             hdiff.Fill(hinfo.angle - pinfo.angle)
0266 
0267     # resolution calculation --------------------------------------------------
0268 
0269     # extract hist properties to initialize fit
0270     muhist  = hdiff.GetMean()
0271     rmshist = hdiff.GetRMS()
0272     inthist = hdiff.Integral()
0273 
0274     # set up a gaussian to extract main peak
0275     fdiff = ROOT.TF1("fAngRes", "gaus(0)", -0.2, 0.2)
0276     fdiff.SetParameters(
0277         inthist,
0278         muhist,
0279         rmshist
0280     )
0281 
0282     # fit histogram over nonzero bins
0283     ifirst   = hdiff.FindFirstBinAbove(0.0)
0284     ilast    = hdiff.FindLastBinAbove(0.0)
0285     first_lo = hdiff.GetBinLowEdge(ifirst)
0286     last_hi  = hdiff.GetBinLowEdge(ilast + 1)
0287     hdiff.Fit("fAngRes", "", "", first_lo, last_hi)
0288 
0289     # wrap up script ----------------------------------------------------------
0290 
0291     # save root objects
0292     with ROOT.TFile(opts.ofile, "recreate") as out:
0293         out.WriteObject(fdiff, "fAngRes")
0294         out.WriteObject(hdiff, "hAngRes")
0295         out.Close()
0296 
0297     # save metrics to a json file
0298     metrics = {f"{opts.angle}_resolution" : fdiff.GetParameter(2)}
0299     js_out  = opts.ofile.replace(".root", ".json")
0300     with open(js_out, 'w') as o:
0301        json.dump(metrics, o, ensure_ascii = False)
0302 
0303     # and return fit width as resolution
0304     return {f"{opts.angle}_resolution" : fdiff.GetParameter(2)}
0305 
0306 
0307 # =============================================================================
0308 # Main Entry Point
0309 # =============================================================================
0310 
0311 if __name__ == "__main__":
0312 
0313     # set up argments
0314     parser = ap.ArgumentParser()
0315     parser.add_argument(
0316         "-i",
0317         "--ifiles",
0318         help = "Add an input file",
0319         nargs = '?',
0320         action = 'append',
0321         type = str
0322     )
0323     parser.add_argument(
0324         "-o",
0325         "--ofile",
0326         help = "Output file",
0327         nargs = '?',
0328         const = DEFAULT_OPTS.ofile,
0329         default = DEFAULT_OPTS.ofile,
0330         type = str
0331     )
0332     parser.add_argument(
0333         "-c",
0334         "--angle",
0335         help = "Angular coordinate to calculate resolution on",
0336         nargs = '?',
0337         const = DEFAULT_OPTS.angle,
0338         default = DEFAULT_OPTS.angle,
0339         type = str
0340     )
0341     parser.add_argument(
0342         "-s",
0343         "--pdg",
0344         help = "PDG code of particle species to look for",
0345         nargs = '?',
0346         const = DEFAULT_OPTS.pdg,
0347         default = DEFAULT_OPTS.pdg,
0348         type = int
0349     )
0350     parser.add_argument(
0351         "-r",
0352         "--hits",
0353         help = "Reco hit collection to use",
0354         nargs = '?',
0355         const = DEFAULT_OPTS.hits,
0356         default = DEFAULT_OPTS.hits,
0357         type = str
0358     )
0359     parser.add_argument(
0360         "-p",
0361         "--pars",
0362         help = "MC particle collection to use",
0363         nargs = '?',
0364         const = DEFAULT_OPTS.pars,
0365         default = DEFAULT_OPTS.pars,
0366         type = str
0367     )
0368     parser.add_argument(
0369         "-a",
0370         "--assocs",
0371         help = "Cluster-particle associations to use",
0372         nargs = '?',
0373         const = DEFAULT_OPTS.assocs,
0374         default = DEFAULT_OPTS.assocs,
0375         type = str
0376     )
0377     parser.add_argument(
0378         "-e",
0379         "--excludes",
0380         help = "Add a layer to exclude",
0381         nargs = '?',
0382         action = 'append',
0383         type = int
0384     )
0385 
0386     # grab arguments
0387     args = parser.parse_args()
0388 
0389     # if no input files provided, use default one
0390     inputs = list()
0391     if args.ifiles is None:
0392         inputs.append(DEFAULT_OPTS.ifiles)
0393     else:
0394         inputs.extend(args.ifiles)
0395 
0396     # if no excluded layers provided, use default one
0397     excludes = list()
0398     if args.excludes is None:
0399         excludes.append(DEFAULT_OPTS.excludes)
0400     else:
0401         excludes.extend(args.excludes)
0402 
0403     # pack options and run analysis
0404     opts = Options(inputs, args.ofile, excludes)
0405     opts.set_opts_from_args(args)
0406     CalculateHitAngReso(opts)