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
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
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
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
0158 coord = opts.angle
0159 coord = coord.lower()
0160
0161
0162
0163
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
0176 axis = ";#delta" + var + " = " + var + "^{image}_{max hit} - " + var + "_{par}"
0177
0178
0179 hdiff = ROOT.TH1D("hAngRes", axis, 80, -0.2, 0.2)
0180 hdiff.Sumw2()
0181
0182
0183
0184
0185 for ifile in opts.ifiles:
0186
0187
0188 reader = get_reader(ifile)
0189 for iframe, frame in enumerate(reader.get("events")):
0190
0191
0192 rehits = frame.get(opts.hits)
0193 mcpars = frame.get(opts.pars)
0194 assocs = frame.get(opts.assocs)
0195
0196
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
0205
0206 if primary is None:
0207 print(f"Warning! Frame {iframe} has no primary in file:\n -- {ifile}")
0208 continue
0209
0210
0211 pinfo = Info()
0212 pinfo.set_par_info(coord, primary)
0213
0214
0215
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
0227
0228 cluster = None
0229 for assoc in assocs:
0230
0231 if primary != assoc.getSim():
0232 continue
0233 else:
0234 cluster = assoc.getRec()
0235
0236
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
0255
0256 minlayer = min(maxhits.keys())
0257
0258
0259
0260 hinfo = Info()
0261 hinfo.set_hit_info(coord, maxhits[minlayer])
0262
0263
0264 hdiff.Fill(hinfo.angle - pinfo.angle)
0265
0266
0267
0268
0269 muhist = hdiff.GetMean()
0270 rmshist = hdiff.GetRMS()
0271 inthist = hdiff.Integral()
0272
0273
0274 fdiff = ROOT.TF1("fAngRes", "gaus(0)", -0.2, 0.2)
0275 fdiff.SetParameters(
0276 inthist,
0277 muhist,
0278 rmshist
0279 )
0280
0281
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
0289
0290
0291 with ROOT.TFile(opts.ofile, "recreate") as out:
0292 out.WriteObject(fdiff, "fAngRes")
0293 out.WriteObject(hdiff, "hAngRes")
0294 out.Close()
0295
0296
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
0303 return {f"{opts.angle}_resolution" : fdiff.GetParameter(2)}
0304
0305
0306
0307
0308
0309
0310 if __name__ == "__main__":
0311
0312
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
0386 args = parser.parse_args()
0387
0388
0389 inputs = list()
0390 if args.ifiles is None:
0391 inputs.append(DEFAULT_OPTS.ifiles)
0392 else:
0393 inputs.extend(args.ifiles)
0394
0395
0396 excludes = list()
0397 if args.excludes is None:
0398 excludes.append(DEFAULT_OPTS.excludes)
0399 else:
0400 excludes.extend(args.excludes)
0401
0402
0403 opts = Options(inputs, args.ofile, excludes)
0404 opts.set_opts_from_args(args)
0405 CalculateHitAngReso(opts)