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