Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-08-12 08:24:55

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     energy: float = -999.0
0083     angle: float = -999.0
0084     perp: float = -999.0
0085     layer: int = -999
0086     vector: ROOT.Math.XYZVector = ROOT.Math.XYZVector(-999.0, -999.0, -999.0)
0087 
0088     def _set_vector(self, edmvec):
0089         """Set position vector from an edm4hep::Vector3f"""
0090         self.vector = ROOT.Math.XYZVector(
0091             edmvec.x,
0092             edmvec.y,
0093             edmvec.z
0094         )
0095 
0096     def _set_angle(self, coord):
0097         """Set angle based on provided angular coordinate name"""
0098         match coord:
0099             case "theta":
0100                 self.angle = self.vector.Theta()
0101             case "eta":
0102                 self.angle = self.vector.Eta()
0103             case "phi":
0104                 self.angle = self.vector.Phi()
0105             case _:
0106                 raise ValueError("Unknown coordinate specified!")
0107 
0108     def set_par_info(self, cname, par):
0109         """Extract info from an edm4hep::MCParticle"""
0110         self._set_vector(par.getMomentum())
0111         self._set_angle(cname)
0112         self.energy = par.getEnergy()
0113         self.perp   = self.vector.Rho()
0114 
0115     def set_hit_info(self, cname, hit):
0116         """Extract info from an edm4eic::CalorimeterHit"""
0117         self._set_vector(hit.getPosition())
0118         self._set_angle(cname)
0119         self.energy = hit.getEnergy()
0120         self.perp   = self.vector.Rho()
0121         self.layer  = hit.getLayer()
0122 
0123 # =============================================================================
0124 # Angular Resolution Calculation
0125 # ============================================================================= 
0126 
0127 def CalculateHitAngReso(opts: Options = DEFAULT_OPTS) -> Dict[str, float]:
0128     """Calculate angular resolution
0129 
0130     A function to calculate angular resolution for a 
0131     specified species of particle from BIC imaging
0132     hits according to this algorithm:
0133 
0134         1. Find the imaging cluster associated to thrown
0135            electron
0136         2. Locate the most energetic hit in each layer
0137            of the imaging cluster
0138         3. From these, select the hit in the most
0139            upstream layer and calculate the difference
0140            in angle
0141         4. Fit the main peak of the distribution of
0142            differences and extract the RMS of the
0143            peak
0144         5. Return the RMS as the resolution
0145 
0146     Args:
0147         opts: calculation options
0148 
0149     Returns:
0150         Dictionary of {key, value} where
0151         - key: the name of the objective associated with this script,
0152           in this case "resolution"
0153         - value: the value of the objective, in this case the RMS of
0154           the fit to the mc-reco differences
0155     """
0156 
0157     # sanitize coordinate input
0158     coord = opts.angle
0159     coord = coord.lower()
0160 
0161     # set up histograms, etc. -------------------------------------------------
0162 
0163     # set variable for axis accordingly 
0164     var = "x"
0165     match coord:
0166         case "theta":
0167             var = "#theta"
0168         case "eta":
0169             var = "#eta"
0170         case "phi":
0171             var = "#phi"
0172         case _:
0173             raise ValueError("Unknown coordinate specified!")
0174 
0175     # construct axis title
0176     axis = ";#delta" + var + " = " + var + "^{image}_{max hit} - " + var + "_{par}"
0177 
0178     # create histogram from extracting resolution
0179     hdiff = ROOT.TH1D("hAngRes", axis, 80, -0.2, 0.2)
0180     hdiff.Sumw2()
0181 
0182     # event loops -------------------------------------------------------------
0183 
0184     # loop through input files
0185     for ifile in opts.ifiles:
0186 
0187         # loop through all events
0188         reader = get_reader(ifile)
0189         for iframe, frame in enumerate(reader.get("events")):
0190 
0191             # grab relevant branches
0192             rehits = frame.get(opts.hits)
0193             mcpars = frame.get(opts.pars)
0194             assocs = frame.get(opts.assocs)
0195 
0196             # pick out the primary particle
0197             primary = None
0198             for par in mcpars:
0199                 status = par.getGeneratorStatus()
0200                 if par.getPDG() == opts.pdg and status == 1:
0201                     primary = par
0202                     break
0203 
0204             # if for some reason no primary was found,
0205             # skip event
0206             if primary is None:
0207                 print(f"Warning! Frame {iframe} has no primary in file:\n  -- {ifile}")
0208                 continue
0209 
0210             # scrape particle info for histogramming
0211             pinfo = Info()
0212             pinfo.set_par_info(coord, primary)
0213 
0214             # dictionaries to keep track of max energy
0215             # hits in each layer
0216             maxenes = {
0217                 1 : 0.0,
0218                 2 : 0.0,
0219                 3 : 0.0,
0220                 4 : 0.0,
0221                 5 : 0.0,
0222                 6 : 0.0
0223             }
0224             maxhits = dict()
0225 
0226             # now identify the most energetic hit in
0227             # each layer associated with the primary
0228             cluster = None
0229             for assoc in assocs:
0230 
0231                 if primary != assoc.getSim():
0232                     continue
0233                 else:
0234                     cluster = assoc.getRec()
0235 
0236                 # loop through hits to check layers
0237                 for hit in assoc.getRec().getHits():
0238 
0239                     layer = hit.getLayer()
0240                     if layer > 6:
0241                         print(f"Warning! Hit {hit.getObjectID().index} has a layer above 6 ({layer})!")
0242                         continue
0243 
0244                     if layer in excludes:
0245                         continue
0246 
0247                     if hit.getEnergy() > maxenes[layer]:
0248                         maxenes[layer] = hit.getEnergy()
0249                         maxhits[layer] = hit
0250 
0251             if cluster is None or len(maxhits) == 0:
0252                 continue
0253 
0254             # pick out most upstream layer from
0255             # most energetic hits
0256             minlayer = min(maxhits.keys())
0257 
0258             # scrape info from max hit in most
0259             # upstream layer
0260             hinfo = Info()
0261             hinfo.set_hit_info(coord, maxhits[minlayer])
0262 
0263             # calculate difference
0264             hdiff.Fill(hinfo.angle - pinfo.angle)
0265 
0266     # resolution calculation --------------------------------------------------
0267 
0268     # extract hist properties to initialize fit
0269     muhist  = hdiff.GetMean()
0270     rmshist = hdiff.GetRMS()
0271     inthist = hdiff.Integral()
0272 
0273     # set up a gaussian to extract main peak
0274     fdiff = ROOT.TF1("fAngRes", "gaus(0)", -0.2, 0.2)
0275     fdiff.SetParameters(
0276         inthist,
0277         muhist,
0278         rmshist
0279     )
0280 
0281     # fit histogram over nonzero bins
0282     ifirst   = hdiff.FindFirstBinAbove(0.0)
0283     ilast    = hdiff.FindLastBinAbove(0.0)
0284     first_lo = hdiff.GetBinLowEdge(ifirst)
0285     last_hi  = hdiff.GetBinLowEdge(ilast + 1)
0286     hdiff.Fit("fAngRes", "", "", first_lo, last_hi)
0287 
0288     # wrap up script ----------------------------------------------------------
0289 
0290     # save root objects
0291     with ROOT.TFile(opts.ofile, "recreate") as out:
0292         out.WriteObject(fdiff, "fAngRes")
0293         out.WriteObject(hdiff, "hAngRes")
0294         out.Close()
0295 
0296     # save metrics to a json file
0297     metrics = {f"{opts.angle}_resolution" : fdiff.GetParameter(2)}
0298     js_out  = opts.ofile.replace(".root", ".json")
0299     with open(js_out, 'w') as o:
0300        json.dump(metrics, o)
0301 
0302     # and return fit width as resolution
0303     return {f"{opts.angle}_resolution" : fdiff.GetParameter(2)}
0304 
0305 
0306 # =============================================================================
0307 # Main Entry Point
0308 # =============================================================================
0309 
0310 if __name__ == "__main__":
0311 
0312     # set up argments
0313     parser = ap.ArgumentParser()
0314     parser.add_argument(
0315         "-i",
0316         "--ifiles",
0317         help = "Add an input file",
0318         nargs = '?',
0319         action = 'append',
0320         type = str
0321     )
0322     parser.add_argument(
0323         "-o",
0324         "--ofile",
0325         help = "Output file",
0326         nargs = '?',
0327         const = DEFAULT_OPTS.ofile,
0328         default = DEFAULT_OPTS.ofile,
0329         type = str
0330     )
0331     parser.add_argument(
0332         "-c",
0333         "--angle",
0334         help = "Angular coordinate to calculate resolution on",
0335         nargs = '?',
0336         const = DEFAULT_OPTS.angle,
0337         default = DEFAULT_OPTS.angle,
0338         type = str
0339     )
0340     parser.add_argument(
0341         "-s",
0342         "--pdg",
0343         help = "PDG code of particle species to look for",
0344         nargs = '?',
0345         const = DEFAULT_OPTS.pdg,
0346         default = DEFAULT_OPTS.pdg,
0347         type = int
0348     )
0349     parser.add_argument(
0350         "-r",
0351         "--hits",
0352         help = "Reco hit collection to use",
0353         nargs = '?',
0354         const = DEFAULT_OPTS.hits,
0355         default = DEFAULT_OPTS.hits,
0356         type = str
0357     )
0358     parser.add_argument(
0359         "-p",
0360         "--pars",
0361         help = "MC particle collection to use",
0362         nargs = '?',
0363         const = DEFAULT_OPTS.pars,
0364         default = DEFAULT_OPTS.pars,
0365         type = str
0366     )
0367     parser.add_argument(
0368         "-a",
0369         "--assocs",
0370         help = "Cluster-particle associations to use",
0371         nargs = '?',
0372         const = DEFAULT_OPTS.assocs,
0373         default = DEFAULT_OPTS.assocs,
0374         type = str
0375     )
0376     parser.add_argument(
0377         "-e",
0378         "--excludes",
0379         help = "Add a layer to exclude",
0380         nargs = '?',
0381         action = 'append',
0382         type = int
0383     )
0384 
0385     # grab arguments
0386     args = parser.parse_args()
0387 
0388     # if no input files provided, use default one
0389     inputs = list()
0390     if args.ifiles is None:
0391         inputs.append(DEFAULT_OPTS.ifiles)
0392     else:
0393         inputs.extend(args.ifiles)
0394 
0395     # if no excluded layers provided, use default one
0396     excludes = list()
0397     if args.excludes is None:
0398         excludes.append(DEFAULT_OPTS.excludes)
0399     else:
0400         excludes.extend(args.excludes)
0401 
0402     # pack options and run analysis
0403     opts = Options(inputs, args.ofile, excludes)
0404     opts.set_opts_from_args(args)
0405     CalculateHitAngReso(opts)