Rivet analyses

Muon neutrino cross-section and flux at 13.6 TeV

Experiment: FASER (LHC)

Inspire ID: 2855783

Status: VALIDATED

Authors: - Felix Kling

References: - arXiv: 2412.03186 - PRL 134 (2025) 21, 211801

Beams: p+ p+

Beam energies: (6800.0, 6800.0)GeV

Run details: - min bias

This letter presents the measurement of the energy-dependent neutrino-nucleon cross section in tungsten and the differential flux of muon neutrinos and anti-neutrinos. The analysis is performed using proton-proton collision data at a center-of-mass energy of 13.6TeV and corresponding to an integrated luminosity of (65.6$1.4)fb^{-1}.UsingtheactiveelectroniccomponentsoftheFASERdetector, 338.1$21.0 charged current muon neutrino interaction events are identified, with backgrounds from other processes subtracted. We unfold the neutrino events into a fiducial volume corresponding to the sensitive regions of the FASER detector and interpret the results in two ways: We use the expected neutrino flux to measure the cross section, and we use the predicted cross section to measure the neutrino flux. Both results are presented in six bins of neutrino energy, achieving the first differential measurement in the TeV range. The observed distributions align with Standard Model predictions. Using this differential data, we extract the contributions of neutrinos from pion and kaon decays.

Source code:FASER_2024_I2855783.cc

#include "Rivet/Analysis.hh"
#include "Rivet/Projections/FinalState.hh"
#include "Rivet/Projections/HeavyHadrons.hh"
#include "Rivet/Projections/UnstableParticles.hh"
#include "Rivet/Tools/Random.hh"

namespace Rivet {

    /// @brief Muon neutrino cross-section and flux at 13.6 TeV
    class FASER_2024_I2855783 : public Analysis {
    public:

    // Default analysis constructor
    RIVET_DEFAULT_ANALYSIS_CTOR(FASER_2024_I2855783);

    /// @name Analysis methods
    ///@{

    /// Book histograms and initialise projections for run
    void init() {

      // initialize used particle groups
      declare(UnstableParticles(Cuts::abspid==PID::K0S        || Cuts::abspid==PID::K0L       ||
                                Cuts::abspid==PID::PIPLUS     || Cuts::abspid==PID::KPLUS     ||
                                Cuts::abspid==PID::SIGMAMINUS || Cuts::abspid==PID::SIGMAPLUS ||
                                Cuts::abspid==PID::XI0        || Cuts::abspid==PID::XIMINUS   ||
                                Cuts::abspid==PID::LAMBDA), "UP");

      declare(HeavyHadrons(Cuts::abspid==PID::DPLUS  || Cuts::abspid==PID::D0 ||
                           Cuts::abspid==PID::DSPLUS || Cuts::abspid==PID::LAMBDACPLUS), "HH");

      // inialize histograms for electronic neutrino analysis
      const vector<double> edges{-1./100., -1./1000., 1./1000., 1./100.};
      book(_e_num, 9, 1, 2);
      book(_h_num, "_aux_num", refData(9, 1, 2));

      book(_h_int, edges);
      book(_h_int->bin(1), 10, 1, 2);
      book(_h_int->bin(2), 12, 1, 2);
      book(_h_int->bin(3), 11, 1, 2);

      book(_h_flux, edges);
      book(_h_flux->bin(1), 22, 1, 1);
      book(_h_flux->bin(2), 24, 1, 1);
      book(_h_flux->bin(3), 23, 1, 1);

      _aper = refData<YODA::BinnedEstimate<int,double>>("_aux_aper");
      _xsint = refData<YODA::BinnedEstimate<int,double>>("_aux_xsint");
      _decay_branchings = refData<YODA::BinnedEstimate<int,int>>("_aux_decay_branchings");
      for (PdgId pid : {130, 211, 310, 321, 3112, 3122, 3312, 3322, 411, 421, 431, 4122}) {
        _energy_cdf[pid] = refData<YODA::Estimate1D>("_aux_energy_"+to_string(pid));
      }
      _bfield = refData<YODA::Estimate1D>("_aux_bfield");
      _bprime = refData<YODA::Estimate1D>("_aux_bprime");
      _kickers = refData<YODA::Estimate1D>("_aux_kickers");

      // initializations of geomery, particle properties, decay CDFs
      _mass = { {PID::PIPLUS,     0.13957}, {PID::KPLUS,      0.49368},
                {PID::K0S,        0.49761}, {PID::K0L,        0.49761},
                {PID::LAMBDA,     1.11568}, {PID::SIGMAPLUS,  1.18937},
                {PID::SIGMAMINUS, 1.19745}, {PID::XI0,        1.31486},
                {PID::XIMINUS,    1.32171}, {PID::OMEGAMINUS, 1.67245} };

      _ctau = { {PID::PIPLUS,     c_light*2.603e-08}, {PID::KPLUS,      c_light*1.238e-08},
                {PID::K0S,        c_light*8.954e-11}, {PID::K0L,        c_light*5.116e-08},
                {PID::LAMBDA,     c_light*2.60e-10},  {PID::SIGMAPLUS,  c_light*8.018e-11},
                {PID::SIGMAMINUS, c_light*1.479e-10}, {PID::XI0,        c_light*2.90e-10},
                {PID::XIMINUS,    c_light*1.639e-10}, {PID::OMEGAMINUS, c_light*8.21e-11} };

      _decay_cdf = { {  211, YODA::Axis<double>({0.1,1.0})},
                     { -211, YODA::Axis<double>({-1.0,0.0,0.1,1.0})},
                     {  321, YODA::Axis<double>({0.5,1.0})},
                     { -321, YODA::Axis<double>({-1.0,0.0,0.5,1.0})},
                     {  310, YODA::Axis<double>({0.25,0.50,0.75,1.0})},
                     {  130, YODA::Axis<double>({0.25,0.50,0.75,1.0})},
                     { 3122, YODA::Axis<double>({-1.0,0.0,0.9,1.0})},
                     {-3122, YODA::Axis<double>({0.9,1.0})},
                     { 3222, YODA::Axis<double>({1.0})},
                     {-3222, YODA::Axis<double>({-1.0,0.0,1.0})},
                     { 3112, YODA::Axis<double>({0.5,1.0})},
                     {-3112, YODA::Axis<double>({-1.0,0.0,0.5,1.0})},
                     { 3322, YODA::Axis<double>({0.5,1.0})},
                     {-3322, YODA::Axis<double>({-1.0,0.0,0.5,1.0})},
                     { 3312, YODA::Axis<double>({-1.0,0.0,0.5,1.0})},
                     {-3312, YODA::Axis<double>({0.5,1.0})}, };
    }

    void analyze(const Event& event) {
      // light hadrons
      for (const Particle& hadron : apply<UnstableParticles>(event, "UP").particles()) {
        analyze_light(hadron);
      }
      // heavy hadrons
      for (const Particle& hadron : apply<HeavyHadrons>(event, "HH").particles()) {
        analyze_heavy(hadron);
      }
    }

    void analyze_light(const Particle& hadron) {

      // get hadron pid, position and momentum
      int hpid = hadron.pid();
      Vector3 x = hadron.origin().vector3()/meter;
      Vector3 p = hadron.momentum().vector3()/GeV;

      // rotate with beam crossing angle
      const double angle = -160./1000./1000. ;
      const double cosang = cos(angle);
      const double sinang = sin(angle);
      p = Vector3(p.x(), p.y()*cosang+p.z()*sinang, p.z()*cosang-p.y()*sinang);
      x = Vector3(x.x(), x.y()*cosang+x.z()*sinang, x.z()*cosang-x.y()*sinang);

      // reject hadrons produced outside of pipe or moving backwards
      if (p.z() < 0 || !inpipe(x)) return;

      // reject displaced hadrons from charged parent hadron decay for z>22m
      if (x.z()>22. && hadron.parents()[0].isCharged()) return;

      // process particle
      process_particle(x, p, hpid, hadron.charge());
    }

    void analyze_heavy(const Particle& hadron) {

      // read particle
      int pid  = hadron.pid();
      int apid = hadron.abspid();

      // rotate with beam crossing angle
      FourMomentum phad = hadron.momentum();
      const double angle = -160./1000./1000.;
      const double cosang = cos(angle);
      const double sinang = sin(angle);
      phad.setXYZM(phad.x(),
                   phad.y()*cosang+phad.z()*sinang,
                   phad.z()*cosang-phad.y()*sinang,
                   phad.mass());

      // re-decay rate
      size_t nrepeat = 2500;

      // loop through all possible decays
      for (PdgId vpid : {14, -14}) {
        // repeat decays nrepeat times for enhanced statistics
        for (size_t irepeat=0; irepeat<nrepeat; ++irepeat) {

          // get neutrino id
          const double br = _decay_branchings.binAt(pid,vpid).val();
          if (br <= 0) continue;
          const double weight = br/double(nrepeat);
          if (isnan(weight))  continue;

          //construct neutrino in hadron rest frame
          const double enu = _energy_cdf[apid].binAt(rand01()).val();
          const double phi = rand01() * TWOPI;
          const double costh = 0.9999999 * (2.*rand01() - 1.);

          // boost neutrino in lab frame
          FourMomentum vrest;
          vrest.setThetaPhiME(acos(costh), phi, 0, enu);
          LorentzTransform ltf;
          ltf.setBetaVec(phad.betaVec());
          FourMomentum p = ltf.transform(vrest);

          // save neutrino
          if (p.z()>0 && p.E()>10) {
            save_neutrino(pid, vpid, Vector3(0,0,0), p, weight);
          }
        }
      }
    }

    // Function that interpolates arrays with constant function (used for cross section).
    double linear_interpolation(int vpid, double y) const {
      const auto& b = _xsint.binAt(vpid,y);
      const auto& xaxis = _xsint.binning().axis<0>();
      const auto& yaxis = _xsint.binning().axis<1>();
      const auto& bplus = _xsint.bin(xaxis.index(vpid), yaxis.index(y)+1);
      const double frac = (y - b.yMin())/b.yWidth();
      const double dsigma = bplus.val() - b.val();
      return b.val() + frac * dsigma;
    }

    // Function that evaluates offset as function of the location.
    double get_offset(double z) const {
      if (z < 139.3) return 0;
      if (z > 158)   return 0.097;
      return (z-75.)*0.097 / (158.-75.);
    }

    // Function that checks if particle is inside beam pipe.
    bool inpipe(const Vector3& x) const {
      if (isnan(x.z()) || isnan(x.z()) || isnan(x.z())) return false;
      if (x.z()<-0.1 || x.z()>220.) return false;
      const double offset = get_offset(x.z());
      if (abs(abs(x.x())-offset) > _aper.binAt(1,x.z()).val()) return false;
      if (abs(x.y()) > _aper.binAt(2,x.z()).val()) return false;
      if ( sqr((abs(x.x())-offset)/_aper.binAt(3,x.z()).val()) +
           sqr(x.y()/_aper.binAt(4,x.z()).val()) > 1) return false;
      return true;
    }

    // Function that performs a numerical integration step for trajectory.
    vector<Vector3> integration_step(Vector3 x, Vector3 p, double charge, double length,
                                     double bfield=0., double bprime=0.) const {

      const double zstart = x.z();
      const double absbprime = abs(bprime);
      const double absbfield = abs(bfield);

      // warning if B and B' are both != 0
      if (absbprime > 0. && absbfield > 0.) {
        MSG_WARNING("Warning: bprime and bfield are non-zero: B="
                    << bfield << " and B'=" << bprime << " at z=" <<x.z());
      }

      // drift tube
      if (isZero(absbfield) && isZero(absbprime)) {
        x = x + p.unit() * length;
      }

      // dipole magnets
      if (absbfield>0 && isZero(absbprime)) {
        double rho   = add_quad(p.x(), p.z()) / bfield;
        double theta = length / rho;
        Vector3 punit = p.unit();
        Vector3 funit = (punit.cross(Vector3(0,1,0))).unit();
        x = x + rho * ( sin(theta) * punit + (1.-cos(theta)) * funit );
        p = p.mod() * cos(theta) * punit + p.mod() * sin(theta) * funit;
      }

      // quadrupole magnets
      if (absbprime>0 && isZero(absbfield)) {
        double kappa = 0.299 * bprime / p.mod();
        double rk = sqrt(abs(kappa));
        double rkh = length*rk;

        double offset = get_offset(x.z());
        if (offset > 0 && x.x()<0) offset =-offset;
        double x0 = x.x()-offset;
        double y0 = x.y();

        double xx,px,xy,py;
        Vector3 punit = p.unit();
        if (kappa>0) {
          const double cosrkh  = cos(rkh);
          const double sinrkh  = sin(rkh);
          const double coshrkh = cosh(rkh);
          const double sinhrkh = sinh(rkh);
          xx =  x0*cosrkh     + punit.x()*sinrkh/rk;
          px = -x0*sinrkh*rk  + punit.x()*cosrkh;
          xy =  y0*coshrkh    + punit.y()*sinhrkh/rk;
          py =  y0*sinhrkh*rk + punit.y()*coshrkh;
        }
        else {
          const double cosrkh  = cos(rkh);
          const double sinrkh  = sin(rkh);
          const double coshrkh = cosh(rkh);
          const double sinhrkh = sinh(rkh);
          xx =  x0*coshrkh    + punit.x()*sinhrkh/rk;
          px =  x0*sinhrkh*rk + punit.x()*coshrkh;
          xy =  y0*cosrkh     + punit.y()*sinrkh/rk;
          py = -y0*sinrkh*rk  + punit.y()*cosrkh;
        }
        const double pz = sqrt(1.-px*px-py*py);
        const double xz = x.z() + length;
        x = Vector3(xx+offset,xy,xz);
        p = p.mod()*Vector3(px,py,pz);
      }

      // kickers
      if (!isZero(charge)) {
        for (const auto& b : _kickers.bins(true)) {
          const double zkick = b.xMin();
          if (zstart < zkick && x.z() >= zkick) {
            const double py = p.y() + 6800. * b.val() * charge;
            p = Vector3(p.x(),py,p.z());
          }
        }
      }
      return {x,p};
    }

    // Function that obtains particle trajectory in beam pipe.
    vector<vector<Vector3>> get_trajectory(Vector3 x, Vector3 p, double charge) const {

      vector<Vector3> array_x, array_p, array_s;
      const double stepsize = 0.1;
      double length = stepsize;

      while (inpipe(x)) {
        // get fields
        const double bfield = _bfield.binAt(x.z()).val();
        const double bprime = _bprime.binAt(x.z()).val();
        // optimise stepsize
        if (charge!=0) {
          if (abs(bfield)>0 || abs(bprime)>0) length=stepsize/4.; // divide by field factor
          else length=stepsize;
          const double distance_to_boundary = min({ _bfield.binAt(x.z()).xMax(),
                                                    _bprime.binAt(x.z()).xMax(),
                                                    _kickers.binAt(x.z()).xMax() }) - x.z();
          if (distance_to_boundary<length) length = distance_to_boundary+0.001;
        }
        // record position
        array_x.push_back(x);
        array_p.push_back(p);
        array_s.push_back(Vector3(0,0,length));
        // integration step
        vector<Vector3> step = integration_step(x, p, charge, length, bfield*charge, bprime*charge);
        x=step[0];
        p=step[1];
      }
      return {array_x, array_p, array_s};
    }

    // Decay a hadron into neutrinos.
    void decay_particle(int hpid, Vector3 x, Vector3 p, double wdecay) {

      //create hadron momentum
      FourMomentum phadron;
      phadron.setXYZM(p.x(),p.y(),p.z(),_mass[abs(hpid)]);

      // get decay channel: 0 ve, 1 vm, 2 vebar, 3 vmbar
      size_t nuidx = _decay_cdf[hpid].index(rand01());
      if (nuidx % 2 == 0)  return;
      PdgId vpid = nuidx==1? 14 : -14;
      double wbranching = _decay_branchings.binAt(hpid,vpid).val()/_decay_cdf[hpid].width(nuidx);
      if (isnan(wbranching))  return;

      //get neutrino in hadron rest frame
      double phi = rand01() * TWOPI;
      double costh = 0.9999999 * (2.*rand01() - 1.);
      double enu = _energy_cdf.at(abs(hpid)).binAt(rand01()).val();
      FourMomentum vrest;
      vrest.setThetaPhiME(acos(costh), phi, 0, enu);

      // boost neutrino in lab frame
      LorentzTransform ltf;
      ltf.setBetaVec( phadron.betaVec() );
      FourMomentum pneutrino = ltf.transform(vrest);

      // save neutrino
      if (pneutrino.z()>0. && pneutrino.E()>10.) {
        save_neutrino(hpid, vpid, x, pneutrino, wdecay*wbranching);
      }
    }

    // Fill histos for this neutrino
    void save_neutrino(int hpid, int vpid, Vector3 x, FourMomentum p, double weight) {
      // get position at FASER interface plane at L=480m
      const Vector3 position = x + p.vector3()/p.vector3().z() * (480.-x.z());
      if ( add_quad(position.x(), position.y()+0.012) >= 0.1 )  return;
      const double en = p.E()/GeV;
      const double xs_cc = linear_interpolation(vpid, en) * en;
      double val = -vpid/abs(vpid)/en;
      _h_num->fill(val, weight*xs_cc*_facGeo2);
      if (abs(val) < 0.001)  val = 0.0009;
      _h_int->fill(val, 1./abs(val), weight*xs_cc*_facGeo2);
      _h_flux->fill(val, 1./abs(val), weight/314.15e-3);
    }

    // function that obtains trajetctory and decays for each particle
    void process_particle(Vector3 x0, Vector3 p0, int hpid, double charge) {

      // get decay length
      const double decaylength = _ctau[abs(hpid)] * p0.mod() / _mass[abs(hpid)];

      // get trajectory
      vector<vector<Vector3>> trajectory = get_trajectory(x0, p0, charge);

      // loop over trejectory and decay hadron
      for (size_t itraj=0; itraj<trajectory[0].size(); ++itraj) {
        Vector3 x = trajectory[0][itraj];
        Vector3 p = trajectory[1][itraj];
        const double s = trajectory[2][itraj].z();
        double wdecay = exp(-(x.z()-x0.z()) / decaylength) * (1. - exp(- s / decaylength));
        if (isnan(wdecay))  continue;
        decay_particle(hpid, x, p, wdecay);
      }
    }

    void finalize() {
      const double sf = crossSection()/picobarn/sumOfWeights();
      scale(_h_num, sf);
      barchart(_h_num, _e_num);
      scale({_h_int, _h_flux}, sf);
    }

    ///@}

  private:

    /// @name Histograms
    ///@{

    // Histos
    Histo1DPtr _h_num;
    Estimate1DPtr _e_num;
    Histo1DGroupPtr _h_int, _h_flux;

    ///@}

    //length[cm] * density[g/cm3] / protonmass [g] * lumi [ipb]
    const double _facGeo2= 1.022e27 * 65.6 * 1000.;

    // other input
    map<PdgId, double> _mass, _ctau;
    map<PdgId, YODA::Axis<double>> _decay_cdf;
    map<PdgId, YODA::Estimate1D> _energy_cdf;
    YODA::BinnedEstimate<int,int> _decay_branchings;
    YODA::BinnedEstimate<int,double> _xsint, _aper;
    YODA::Estimate1D _bfield, _bprime, _kickers;

  };

  RIVET_DECLARE_PLUGIN(FASER_2024_I2855783);

}