Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-10-26 08:47:15

0001 #!/usr/bin/env python3
0002 
0003 #########################
0004 # Demo of how to determine and
0005 # apply equality constraints when
0006 # running alignment fits with
0007 # Millepede-II.
0008 #########################
0009 
0010 import os
0011 import argparse
0012 from pathlib import Path
0013 
0014 import acts
0015 import acts.mille
0016 import acts.examples
0017 
0018 from alignment_visualisation import plotAlignImpacts
0019 import numpy as np
0020 
0021 from acts.examples import (
0022     Sequencer,
0023 )
0024 from acts.mille import (
0025     readMillePedeResult,
0026     runMillePedeSolver,
0027     generateMillePedeSteeringFile,
0028     MillePedeEqualityConstraint,
0029     MillePedeParameterSeed,
0030     MillePedeSolutionStrategy,
0031     MillePedeSteeringConfig,
0032     MillePedeSolverConfig,
0033 )
0034 
0035 from millepede_alignment import (
0036     addAlignmentSandbox,
0037     addMisalignmentDeco,
0038     addTrackingSequence,
0039     getTelescopeDetector,
0040 )
0041 
0042 from telescope_alignment_visualiser import plot_all_modes
0043 
0044 
0045 # Helper class to express an eigenvector in the Millepede alignment
0046 class mpEigenVec:
0047     def __init__(
0048         self,
0049         index: int,
0050         EV: float,
0051         labels=None,
0052         components=None,
0053         component_dic: dict = {},
0054     ):
0055         self.index = index
0056         self.EV = EV
0057         if labels is not None and components is not None:
0058             self.labels = labels
0059             self.components = components
0060         elif len(component_dic) > 0:
0061             self.labels = np.array([l for l in component_dic.keys()], dtype=int)
0062             self.components = np.array([v for v in component_dic.values()], dtype=float)
0063 
0064     # for printout purposes, suppress all minor components so that the dominant direction
0065     # becomes immediately visible to the reader
0066     def prune(self, threshold=0.01):
0067         relevant = np.where(np.abs(self.components) > threshold)
0068         return mpEigenVec(
0069             self.index,
0070             self.EV,
0071             self.labels[relevant],
0072             self.components[relevant]
0073             / np.linalg.vector_norm(self.components[relevant]),
0074         )
0075 
0076     # print helper
0077     def print(self):
0078         print(
0079             f"EV # {self.index} with EV {self.EV:.2f} has labels \n   {self.labels}\n and components \n   {np.round(self.components,3)}\n "
0080         )
0081 
0082 
0083 # helper function - read back eigenvectors written by MP-II
0084 def readEvFile(fname):
0085     # The format consists of a header "Eigenvector <INDEX> with eigenvalue <value> "
0086     # followed by a set of "label  value" pairs, multiple possible pert line.
0087     with open(fname, "r") as fin:
0088         evs = []
0089         theIndex = None
0090         theVal = None
0091         elements = {}
0092         for li in fin.readlines():
0093             spl = li.split()
0094             # header line
0095             if "Eigenvector" in li:
0096                 if theIndex is not None and theVal is not None:
0097                     evs.append(mpEigenVec(theIndex, theVal, component_dic=elements))
0098                     elements.clear()
0099                 theIndex = int(spl[1])
0100                 theVal = float(spl[4])
0101             # part of an ongoing eigenvector - account for multiple pairs per line
0102             elif len(spl) > 1:
0103                 elements.update(
0104                     {
0105                         int(spl[2 * k]): float(spl[2 * k + 1])
0106                         for k in range(int(len(spl) / 2))
0107                     }
0108                 )
0109     return evs
0110 
0111 
0112 # package results for visualisation purposes and call
0113 # the dedicated plotter
0114 def drawResult(
0115     paramsMP2,
0116     constraints,
0117     truthInjected: dict,
0118     fname="AlignmentResults",
0119     title=None,
0120     xLim=None,
0121 ):
0122     # package our dictionary of injections to a vector
0123     injectedVals = np.zeros(54, dtype=float)
0124     for label, value in truthInjected.items():
0125         injectedVals[label - 1] = value
0126 
0127     if len(constraints) > 0:
0128         # translate the injected result
0129         # into the sub-space permitted
0130         # by the constraints
0131         blindSpace = np.zeros(shape=(54, len(constraints)), dtype=float)
0132         # build the matrix representing the blind
0133         # directions (constraints as columns)
0134         for i, constraint in enumerate(constraints):
0135             for label, value in constraint.labelsAndWeights:
0136                 blindSpace[label - 1, i] = value
0137         # and then perform the projection
0138         injectedVals = injectedVals - blindSpace @ np.linalg.solve(
0139             blindSpace.T @ blindSpace, blindSpace.T @ injectedVals
0140         )
0141     # read the fit results into a 3D array
0142     mp2Info = np.array([[p.label, p.val, p.sigma] for p in paramsMP2], dtype=float)
0143     # sort ascending by labels
0144     mp2Info = mp2Info[np.argsort(mp2Info[:, 0])]
0145     # and extract the columns with the labels / values / uncertainties.
0146     # For each of those, make one 2D array holding (fit, injected)
0147     labels = mp2Info[:, 0]
0148     central = np.zeros(shape=(2, len(labels)), dtype=float)
0149     sigma = np.zeros(shape=(2, len(labels)), dtype=float)
0150 
0151     # column 0 = fit result
0152     central[0] = mp2Info[:, 1] * -1  # sign convention different between MP2 and ACTS
0153 
0154     # fill column 1 with the injection
0155     if truthInjected is not None:
0156         central[1, :] = injectedVals
0157 
0158     # also update uncertainties, if we have any. Only for fit result
0159     sigmaToUpdate = np.where(np.isfinite(mp2Info[:, 2])) and np.where(mp2Info[:, 2] > 0)
0160     sigma[0, sigmaToUpdate] = mp2Info[sigmaToUpdate, 2]
0161 
0162     # lazily hardcode an "okay-looking" format
0163     formatDict = [
0164         {
0165             "label": "Millepede-II",
0166             "fmt": "o",
0167             "color": "black",
0168             "markersize": 5,
0169         },
0170         {
0171             "label": "Injected" + ", Projected" if len(constraints) > 0 else "",
0172             "marker": "s",
0173             "markeredgecolor": "#f31d1d",
0174             "markerfacecolor": "none",
0175             "linestyle": "none",
0176             "markersize": 11,
0177             "markeredgewidth": 1.6,
0178         },
0179     ]
0180 
0181     # and hand over to the plotter
0182     plotAlignImpacts(
0183         labels,
0184         central,
0185         sigma,
0186         formatDict,
0187         fname=fname,
0188         title=title,
0189         xLim=xLim,
0190     )
0191 
0192 
0193 def main():
0194 
0195     logger = acts.getDefaultLogger("MillePedeResultReader", acts.logging.INFO)
0196     parser = argparse.ArgumentParser(
0197         description="MillePede alignment demo with the Telescope Detector and equality constraints"
0198     )
0199     parser.add_argument(
0200         "--output",
0201         "-o",
0202         help="Output directory",
0203         type=Path,
0204         default=Path.cwd() / "mpali_output",
0205     )
0206     parser.add_argument(
0207         "--events", "-n", help="Number of events", type=int, default=2000
0208     )
0209     parser.add_argument("--skip", "-s", help="Number of events", type=int, default=0)
0210 
0211     args = parser.parse_args()
0212 
0213     # book an output folder
0214     outputDir = args.output
0215     os.makedirs(outputDir, exist_ok=True)
0216 
0217     # Set file locations for results
0218     milleBinaryName = outputDir / "MilleBinary.root"
0219     millePedeSolution = outputDir / "MillePedeAlignment.txt"
0220     millePedeSolutionSoftMode = outputDir / "MillePedeAlignment.SoftModeCut.txt"
0221     millePedeEigenvectors = outputDir / "MillePedeEV.txt"
0222     # More Boilerplate code - for setting up the sequence
0223 
0224     s = Sequencer(
0225         events=args.events,
0226         skip=args.skip,
0227         numThreads=8,
0228         outputDir=str(outputDir),
0229     )
0230 
0231     # Can also use zero-field - but not healthy for track covariance.
0232     field = acts.ConstantBField(acts.Vector3(0, 0, 2 * acts.UnitConstants.T))
0233 
0234     # build the detector
0235     detector = getTelescopeDetector()
0236     trackingGeometry = detector.trackingGeometry()
0237 
0238     # Add a context with the alignment shift - sim and digi will "see" the
0239     # distorted detector, reconstruction will not
0240     addMisalignmentDeco(detector, trackingGeometry, s)
0241 
0242     # Add single particle generation, FATRAS, and reco
0243     # via CKF
0244     addTrackingSequence(s, field, detector)
0245 
0246     # And add our alignment sandbox
0247     addAlignmentSandbox(
0248         s,
0249         trackingGeometry,
0250         field,
0251         fixModules=set(),
0252         milleOutput=str(milleBinaryName),
0253     )
0254 
0255     # We do not schedule the solver algorithm.
0256     # Instead, let's use the MP tooling directly
0257     # in python via the bindings!
0258 
0259     # run the sequence - generates alignment binary
0260     s.run()
0261 
0262     ##### First solver run.
0263     # We pretend we don't know what our tracker is doing.
0264     # In such a case, it can be instructive to run an
0265     # Eigendecomposition of the alignment matrix to
0266     # probe for singular and weak modes.
0267     # We will do so and then constrain them in a
0268     # second fit.
0269     ###########################
0270     print("\n=== Running initial fit to discover singular modes === ")
0271 
0272     # We already know a trivial 3D translation
0273     # or rotation of the entire tracker is singular.
0274     # To save time, lock it down already in
0275     # iteration 1.
0276     constraintsStep1 = []
0277     for j in range(6):
0278         # build constraints: The sum of all layer movements
0279         # along each of the three axes must be zero
0280         # (equivalent: centre of gravity is fixed).
0281         eqc = MillePedeEqualityConstraint()
0282         eqc.labelsAndWeights = [(1 + 6 * k + j, 1) for k in range(9)]
0283         eqc.constraint = 0.0
0284         constraintsStep1.append(eqc)
0285 
0286     # configure the solver and steering file for Millepede.
0287     # For this example, we use the defaults for everything and
0288     # only touch the file names
0289     steerCfgStep1 = MillePedeSteeringConfig(
0290         inputFiles=[str(milleBinaryName)],
0291         # the "Diagonalization" strategy performs eigenvector decomposition,
0292         # which is the diagnostic tool we want.
0293         strategy=MillePedeSolutionStrategy.Diagonalization,
0294         # tell MP to give up after one iteration - we do not yet expect
0295         # it to be able to converge given singular modes.
0296         extraLines=["subido"],
0297         constraints=constraintsStep1,
0298     )
0299     # generate the steering file
0300     fnameSteerStep1 = "steerStep1.txt"
0301     generateMillePedeSteeringFile(fnameSteerStep1, steerCfgStep1, logger)
0302 
0303     # and configure the solver to use our steering.
0304     # Also tell it that it should relocate
0305     # the eigenvector file to a place where
0306     # we can pick it up for analysis.
0307     solverCfgStep1 = MillePedeSolverConfig(
0308         steeringFile=fnameSteerStep1,
0309         workDir="mpTmpConstraints/Step1",
0310         evFileName=str(millePedeEigenvectors),
0311         redirectStdout=str(outputDir / "MillepedeAlignmentStep1.log"),
0312     )
0313     # and run the first alignment fit
0314     runMillePedeSolver(solverCfgStep1, logger)
0315 
0316     # now read back the eigenvectors
0317     theEVset = readEvFile(millePedeEigenvectors)
0318 
0319     # print the least constrained modes - in a
0320     # compressed form limited to the leading components.
0321     # The cut-off value to use can be adjusted - in this example,
0322     # all "good" eigenmodes are well above 500, while
0323     # the three remaining singular modes are all below 1.
0324     theFilteredEv = [e for e in theEVset if e.EV < 500.0]
0325     print("\n=== Found the following weak / singular modes: ===")
0326     for thing in theFilteredEv:
0327         thing.prune(0.03).print()
0328 
0329     # and also make plots of the least constrained eigenmodes
0330     plot_all_modes(theFilteredEv)
0331     print("Please see also 'alignment_mode_XYZ.png' in your working dir")
0332 
0333     # Here, we would usually analyse the result and try to understand the Eigenmodes.
0334     # In this example, we pretend to have already done this.
0335     # If you look at the printouts or plots, you will see
0336     # three additional near-singular modes in addition to
0337     # the three translations we manually vetoed:
0338     # - a longitudinal telescope mode along y
0339     # - two transverse telescope (or shear) modes along x/z as function of y
0340 
0341     # For fun, you can disable the constraints in step 1 and see
0342     # the global translation / rotation modes appear!
0343 
0344     # now form constraints from the additional discovered modes.
0345     constraints = []
0346     for EV in theFilteredEv:
0347         constraints.append(MillePedeEqualityConstraint())
0348         # this way, we can automate the constraint creation
0349         # using the prior problem analysis.
0350         constraints[-1].labelsAndWeights = [
0351             (EV.labels[i], EV.components[i]) for i in range(len(EV.components))
0352         ]
0353         constraints[-1].constraint = 0.0
0354 
0355     # Now we can run the second and final alignment fit.
0356 
0357     print("\n=== Running second fit to perform the alignment with constraints === ")
0358     # New steering config - add the newly found extra constraints.
0359     # This time, we can use the standard "inversion" solver.
0360     steerCfgStep2 = MillePedeSteeringConfig(
0361         inputFiles=[str(milleBinaryName)],
0362         strategy=MillePedeSolutionStrategy.Inversion,
0363         constraints=constraints + constraintsStep1,
0364     )
0365     # generate the steering file
0366     fnameSteerStep2 = "steerStep2.txt"
0367     generateMillePedeSteeringFile(fnameSteerStep2, steerCfgStep2, logger)
0368 
0369     # and configure the solver
0370     solverCfgStep2 = MillePedeSolverConfig(
0371         steeringFile=fnameSteerStep2,
0372         workDir="mpTmpConstraints/Step2",
0373         resFileName=str(millePedeSolution),
0374         redirectStdout=str(outputDir / "MillepedeAlignmentStep2.log"),
0375     )
0376     # run the final fit
0377     runMillePedeSolver(solverCfgStep2, logger)
0378 
0379     ###########
0380     # Finally, let's try an alternative approach:
0381     # We can effectively suppress singular / weak
0382     # modes by adding a penalty term to the objective
0383     # function that inhibits excessive movement.
0384     # ATLAS calls this soft mode cut, Millepede
0385     # has a "regularization"
0386     # setting that is a close analogue.
0387 
0388     print("\n=== Running alignment with soft mode cut === ")
0389 
0390     # Seed all fit parameters with a small pre-sigma that will
0391     # constrain them. Use different values for translations
0392     # and rotations to account for their different units
0393     preSigmas = []
0394     for label in range(54):
0395         s = MillePedeParameterSeed()
0396         s.label = 1 + label
0397         # 100 microns or 2 mrad
0398         s.preSigma = 0.1 if label % 6 < 3 else 0.002
0399         preSigmas.append(s)
0400 
0401     # Third steering config - this time using pre-sigmas
0402     # for regularisation.
0403     # We again use the standard "inversion" solver.
0404     steerCfgSoftModeCut = MillePedeSteeringConfig(
0405         inputFiles=[str(milleBinaryName)],
0406         strategy=MillePedeSolutionStrategy.Inversion,
0407         # this enables regularisation.
0408         # First parameter: Strength
0409         # Second parameter: Default pre-sigma.
0410         # The penalty term will take the form
0411         # <strength> x sum (parameter^2)/pre_sigma^2
0412         # Here, we use the per-parameter pre-sigmas
0413         # from above rather than the default value.
0414         extraLines=["regularisation 10  1."],
0415         seeds=preSigmas,
0416     )
0417     # generate the steering file
0418     fnameSteerSoftModeCut = "steerSoftModeCut.txt"
0419     generateMillePedeSteeringFile(fnameSteerSoftModeCut, steerCfgSoftModeCut, logger)
0420 
0421     # and configure the solver
0422     solverCfgSoftMode = MillePedeSolverConfig(
0423         steeringFile=fnameSteerSoftModeCut,
0424         workDir="mpTmpConstraints/SoftModeCut",
0425         resFileName=str(millePedeSolutionSoftMode),
0426         redirectStdout=str(outputDir / "MillepedeAlignmentSoftMode.log"),
0427     )
0428     # run the final fit
0429     runMillePedeSolver(solverCfgSoftMode, logger)
0430 
0431     # read the results - all solvers follow the Millepede convention,
0432     # so we can read them the same way
0433     res_MPII_constraints = readMillePedeResult(str(millePedeSolution), logger)
0434     res_MPII_softMode = readMillePedeResult(str(millePedeSolutionSoftMode), logger)
0435 
0436     # visualise the outcome!
0437     drawResult(
0438         res_MPII_constraints,
0439         constraints + constraintsStep1,
0440         {9: 0.05},
0441         xLim=(-0.2, 0.2),
0442         fname="AlignmentWithConstraints",
0443     )
0444     drawResult(
0445         res_MPII_softMode,
0446         constraintsStep1,
0447         {9: 0.05},
0448         xLim=(-0.2, 0.2),
0449         fname="AlignmentWithSoftMode",
0450     )
0451 
0452 
0453 if __name__ == "__main__":
0454     main()