File indexing completed on 2026-09-05 08:20:08
0001 """Equivalence test for two Gaussian resolution-fit backends: ROOT's
0002 `TH1::Fit` (via `ActsPlugins::RootHistogramFit`) and `acts.examples.scipy`'s
0003 `curve_fit`-based one. A synthetic `IAlgorithm` writes tracks/particles/hits
0004 straight to the whiteboard; the real `TrackTruthMatcher` then derives the
0005 matching, and two `PythonTrackParameterPerformanceWriter`s differing only in
0006 `fitFunction` see bit-identical histograms so only the fit itself can differ.
0007 """
0008
0009 import numpy as np
0010 import pytest
0011
0012 import acts
0013 import acts.examples
0014 import acts.examples.scipy as acts_scipy
0015 from acts.examples import PythonTrackParameterPerformanceWriter
0016
0017 u = acts.UnitConstants
0018
0019 pytestmark = pytest.mark.root
0020
0021
0022 class _SyntheticTrackAlgorithm(acts.examples.IAlgorithm):
0023 """Writes `nTracks` synthetic tracks/particles/measurement-particle-map
0024 entries to the whiteboard every event: truth d0 = 0, fitted d0 = a
0025 residual drawn from `sampler` (a callable `rng -> float`).
0026
0027 Deliberately does NOT write a TrackParticleMatching itself; instead it
0028 writes one source link per track plus a MeasurementParticlesMap entry, so
0029 the real TrackTruthMatcher (see `_run_backends`) derives the matching
0030 itself, as it would from real digitized hits.
0031 """
0032
0033 def __init__(self, sampler, nTracks, seed):
0034 super().__init__(name="SyntheticTrackAlgorithm", level=acts.logging.WARNING)
0035 self._sampler = sampler
0036 self._nTracks = nTracks
0037 self._rng = np.random.default_rng(seed)
0038
0039 self.outputTracks = acts.examples.WriteDataHandle(
0040 self, acts.examples.ConstTrackContainer, "OutputTracks"
0041 )
0042 self.outputTracks.initialize("tracks")
0043 self.outputParticles = acts.examples.WriteDataHandle(
0044 self, acts.examples.SimParticleContainer, "OutputParticles"
0045 )
0046 self.outputParticles.initialize("particles_selected")
0047 self.outputMeasurementParticlesMap = acts.examples.WriteDataHandle(
0048 self, acts.examples.MeasurementParticlesMap, "OutputMeasurementParticlesMap"
0049 )
0050 self.outputMeasurementParticlesMap.initialize("measurement_particles_map")
0051
0052 def execute(self, context):
0053 tc = acts.examples.TrackContainer()
0054 particles = acts.examples.SimParticleContainer()
0055 measurementParticlesMap = acts.examples.MeasurementParticlesMap()
0056
0057 surface = acts.Surface.createPerigee(acts.Vector3(0, 0, 0))
0058
0059 cov = acts.BoundMatrix.Identity()
0060 geoId = acts.GeometryIdentifier()
0061
0062 for i in range(self._nTracks):
0063 barcode = acts.examples.SimBarcode()
0064 barcode.particle = i
0065 particle = acts.examples.SimParticle(barcode, acts.PdgParticle.eMuon)
0066
0067
0068 particle.direction = acts.Vector3(1, 0, 0)
0069 particle.absoluteMomentum = 1.0 * u.GeV
0070 particles.insert(particle)
0071
0072
0073 hitIndex = i
0074 measurementParticlesMap.insert(hitIndex, barcode)
0075
0076 residual = self._sampler(self._rng)
0077 track = tc.makeTrack()
0078 track.referenceSurface = surface
0079 track.parameters = acts.BoundVector(residual, 0.0, 0.0, np.pi / 2, 1.0, 0.0)
0080 track.covariance = cov
0081 track.particleHypothesis = acts.ParticleHypothesis.muon
0082 track.nMeasurements = 1
0083
0084 state = track.appendTrackState()
0085 state.typeFlags.isMeasurement = True
0086 state.uncalibratedSourceLink = acts.examples.IndexSourceLink(
0087 geoId, hitIndex
0088 ).toSourceLink()
0089
0090 self.outputTracks(context, tc.makeConst())
0091 self.outputParticles(context, particles)
0092 self.outputMeasurementParticlesMap(context, measurementParticlesMap)
0093
0094 return acts.examples.ProcessCode.SUCCESS
0095
0096
0097 def _small_res_plot_config():
0098 """Shrink Eta/Phi/Pt from their 40-bin defaults to 2 bins each -- every
0099 synthetic track lands in the same (eta, phi, pT) bin, so this just avoids
0100 fitting ~1600 empty slices per parameter for nothing.
0101 """
0102 cfg = acts.examples.ResPlotToolConfig()
0103 cfg.varBinning["Eta"] = acts.Axis.regular(2, -4.0, 4.0, "#eta")
0104 cfg.varBinning["Phi"] = acts.Axis.regular(2, -np.pi, np.pi, "#phi")
0105 cfg.varBinning["Pt"] = acts.Axis.regular(2, 0.0, 100.0, "pT [GeV/c]")
0106 return cfg
0107
0108
0109 def _run_backends(sampler, nTracks, seed):
0110 """Run the synthetic algorithm + the real TrackTruthMatcher once, score
0111 the result with both fit backends, and return `{backend: histogram_dict}`.
0112 """
0113 import acts.examples.root as acts_root
0114
0115 s = acts.examples.Sequencer(events=1, numThreads=1, logLevel=acts.logging.WARNING)
0116 s.addAlgorithm(_SyntheticTrackAlgorithm(sampler, nTracks, seed))
0117 s.addAlgorithm(
0118 acts.examples.TrackTruthMatcher(
0119 level=acts.logging.WARNING,
0120 config=acts.examples.TrackTruthMatcher.Config(
0121 inputTracks="tracks",
0122 inputParticles="particles_selected",
0123 inputMeasurementParticlesMap="measurement_particles_map",
0124 outputTrackParticleMatching="track_particle_matching",
0125 outputParticleTrackMatching="particle_track_matching",
0126 ),
0127 )
0128 )
0129
0130 writers = {}
0131 for backend, fitFn in [
0132 ("root", acts_root.makeRootHistogramFitFunction()),
0133 ("scipy", acts_scipy.makeScipyHistogramFitFunction()),
0134 ]:
0135 cfg = acts.examples.PythonTrackParameterPerformanceWriter.Config(
0136 inputTracks="tracks",
0137 inputParticles="particles_selected",
0138 inputTrackParticleMatching="track_particle_matching",
0139 fitFunction=fitFn,
0140 resPlotToolConfig=_small_res_plot_config(),
0141 )
0142 writers[backend] = acts.examples.PythonTrackParameterPerformanceWriter(
0143 config=cfg, level=acts.logging.WARNING
0144 )
0145 s.addWriter(writers[backend])
0146
0147 s.run()
0148
0149 return {backend: w.histograms() for backend, w in writers.items()}
0150
0151
0152 def _fitted_bins(histograms, key, backend):
0153 """`(rootVals, otherVals, both)` for `key`, restricted to bins where both
0154 ROOT and `backend` succeeded (an unfitted bin has error == 0).
0155 """
0156 root = histograms["root"].get(key)
0157 other = histograms[backend].get(key)
0158 assert root is not None, f"ROOT produced no {key} (fit failed everywhere)"
0159 assert other is not None, f"{backend} produced no {key} (fit failed everywhere)"
0160
0161 rootVals = np.asarray(root.histogram.values())
0162 rootErrs = np.asarray(root.histogram.errors())
0163 otherVals = np.asarray(other.histogram.values())
0164 otherErrs = np.asarray(other.histogram.errors())
0165
0166 both = (rootErrs > 0) & (otherErrs > 0)
0167 assert (
0168 np.count_nonzero(both) >= 1
0169 ), f"no bin where both root and {backend} succeeded fitting {key}"
0170 return rootVals[both], otherVals[both], both
0171
0172
0173 def _assert_backend_agrees(histograms, key, backend, rtol, atol):
0174 rootVals, otherVals, _ = _fitted_bins(histograms, key, backend)
0175 np.testing.assert_allclose(
0176 otherVals,
0177 rootVals,
0178 rtol=rtol,
0179 atol=atol,
0180 err_msg=f"{backend} vs root disagree on {key}",
0181 )
0182
0183
0184
0185
0186 _RTOL = 1e-3
0187 _MEAN_ATOL = 1e-3
0188
0189 _SCENARIOS = {
0190 "gaussian": lambda rng: rng.normal(0.0, 0.02),
0191 "gaussian_with_outliers": lambda rng: (
0192 rng.uniform(-0.5, 0.5) if rng.uniform() < 0.02 else rng.normal(0.0, 0.02)
0193 ),
0194 }
0195
0196
0197
0198
0199 _SEEDS = {"gaussian": 2, "gaussian_with_outliers": 3}
0200
0201
0202 @pytest.mark.parametrize("scenario", list(_SCENARIOS.keys()))
0203 def test_fit_backends_agree(scenario):
0204 histograms = _run_backends(
0205 _SCENARIOS[scenario], nTracks=5000, seed=_SEEDS[scenario]
0206 )
0207
0208 _assert_backend_agrees(
0209 histograms, "reswidth_d0_vs_eta", "scipy", rtol=_RTOL, atol=0.0
0210 )
0211 _assert_backend_agrees(
0212 histograms, "resmean_d0_vs_eta", "scipy", rtol=_RTOL, atol=_MEAN_ATOL
0213 )