Usage

The base of phasespace is the GenParticle object. This object, which represents a particle, either stable or decaying, has only one mandatory argument, its name.

In most cases (except for the top particle of a decay), one wants to also specify its mass, which can be either be array-like, or a function. Functions are used to specify the mass of particles such as resonances, which are not fixed but vary according to a broad distribution. These mass functions get three arguments, and must return an array-like object of the same shape as the input arguments.

  • The minimum mass allowed by the decay chain, which will be of shape (n_events,).

  • The maximum mass available, which will be of shape (n_events,).

  • The number of events to generate.

This function signature allows to handle threshold effects cleanly, giving enough information to produce kinematically allowed decays (NB: phasespace will throw an error if a kinematically forbidden decay is requested).

A simple example

With these considerations in mind, one can build a decay chain by using the set_children method of the :py:class:~`phasespace.GenParticle` class. As an example, to build the \(B^{0}\to K^{*}\gamma\) decay in which \(K^*\to K\pi\) with a fixed mass, one would write:

from phasespace import GenParticle
import numpy as np
from particle import literals as lp
import vector

B0_MASS = lp.B_0.mass
KSTARZ_MASS = lp.Kst_892_0.mass
PION_MASS = lp.pi_plus.mass
KAON_MASS = lp.K_plus.mass

pion = GenParticle('pi-', PION_MASS)
kaon = GenParticle('K+', KAON_MASS)
kstar = GenParticle('K*', KSTARZ_MASS).set_children(pion, kaon)
gamma = GenParticle('gamma', 0)
bz = GenParticle('B0', B0_MASS).set_children(kstar, gamma)

Phasespace events can be generated using the generate method, which gets the number of events to generate as input. The method returns:

  • The normalized weights of each event, as an array of dimension (n_events,).

  • The 4-momenta of the generated particles as values of a dictionary with the particle name as key. These momenta are either expressed as arrays of dimension (n_events, 4) or :py:class:~`vector.Vector4D` objects, depending on the as_vectors flag given to generate.

N_EVENTS = 1000

weights, particles = bz.generate(n_events=N_EVENTS, as_vectors=True)
# or
weights, particles = bz.generate(n_events=N_EVENTS)

(The array-like objects can always directly be converted to a numpy array (if really needed) through np.asarray(obj).)

Boosting the particles

The particles are generated in the rest frame of the top particle. To produce them at a given momentum of the top particle, one can pass these momenta with the boost_to argument in generate. This latter approach can be useful if the momentum of the top particle is generated according to some distribution, for example the kinematics of the LHC (see test_kstargamma_kstarnonresonant_lhc and test_k1gamma_kstarnonresonant_lhc in tests/test_physics.py to see how this could be done).

The boost_to argument can be a 4-momentum array of shape (n_events, 4) with (px, py, yz, energy) or a :py:class:~`vector.Vector4D` (both a momentum and a Lorentz vector).

N_EVENTS = 1000

# Generate the top particle with a momentum of 100 GeV
top_momentum = np.array([0, 0, 100, np.sqrt(100**2 + B0_MASS**2)])
# or
top_momentum = vector.array({'px': [0], 'py': [0], 'pz': [100], 'E': [np.sqrt(100**2 + B0_MASS**2)]})
weights, particles = bz.generate(n_events=N_EVENTS, boost_to=top_momentum)

Weights

Additionally, it is possible to obtain the unnormalized weights by using the generate_unnormalized flag in generate. In this case, the method returns the unnormalized weights, the per-event maximum weight and the particle dictionary.

print(particles)
{'K*': <tf.Tensor: shape=(1000, 4), dtype=float64, numpy=
array([[ -947.16415007, -1122.95423297, -2050.20462169,  2676.47061068],
       [ 1308.52150432, -2195.01437824,   259.20092213,  2720.20432477],
       [ 1239.03247656,  1512.3898589 ,  1710.33240524,  2747.68471666],
       ...,
       [  478.28501777,   669.02354668, -2377.39504983,  2670.27453452],
       [-1191.00266947, -1248.08736695, -1845.5451777 ,  2680.346291  ],
       [ 1637.83254451, -1112.00955775,  1680.96549096,  2747.12858903]])>, 'gamma': <tf.Tensor: shape=(1000, 4), dtype=float64, numpy=
array([[  947.16415007,  1122.95423297,  2150.20462169,  2604.13633508],
       [-1308.52150432,  2195.01437824,  -159.20092213,  2560.40262099],
       [-1239.03247656, -1512.3898589 , -1610.33240524,  2532.92222909],
       ...,
       [ -478.28501777,  -669.02354668,  2477.39504983,  2610.33241124],
       [ 1191.00266947,  1248.08736695,  1945.5451777 ,  2600.26065476],
       [-1637.83254451,  1112.00955775, -1580.96549096,  2533.47835673]])>, 'pi-': <tf.Tensor: shape=(1000, 4), dtype=float64, numpy=
array([[ -82.02408852, -436.44256702, -868.28061109,  985.19093519],
       [ 775.60899228, -988.36550687,  341.35701015, 1309.36633002],
       [ 568.33555061, 1028.44597851,  859.43153282, 1462.46671148],
       ...,
       [ 130.9868867 ,   16.02328127,  -82.36496396,  208.99328   ],
       [-802.00447657, -464.83852598, -953.1837641 , 1336.90882919],
       [1102.84004854, -748.43589975, 1218.11217852, 1810.99134231]])>, 'K+': <tf.Tensor: shape=(1000, 4), dtype=float64, numpy=
array([[ -865.14006155,  -686.51166595, -1181.9240106 ,  1691.27967549],
       [  532.91251205, -1206.64887138,   -82.15608802,  1410.83799474],
       [  670.69692595,   483.94388038,   850.90087242,  1285.21800519],
       ...,
       [  347.29813106,   653.00026542, -2295.03008587,  2461.28125452],
       [ -388.99819291,  -783.24884097,  -892.3614136 ,  1343.4374618 ],
       [  534.99249597,  -363.573658  ,   462.85331244,   936.13724672]])>}

Iterative generation can be performed using normal python loops without loss in performance:

for i in range(5):
   weights, particles = bz.generate(n_events=100)
   # ...
   # (do something with weights and particles)
   # ...

Resonances with variable mass

To generate the mass of a resonance, we need to give a function as its mass instead of a floating number. This function should take as input the per-event lower mass allowed, per-event upper mass allowed and the number of events, and should return an array-like object with the generated masses and shape (nevents,). Well suited for this task are the TensorFlow Probability distributions or, for more customized mass shapes, the zfit pdfs (currently an experimental feature is needed, contact the zfit developers to learn more).

Following with the same example as above, and approximating the resonance shape by a gaussian, we could write the \(B^{0}\to K^{*}\gamma\) decay chain as (more details can be found in tests/helpers/decays.py):

from phasespace import numpy as tnp
import tensorflow_probability as tfp
from phasespace import GenParticle

KSTARZ_MASS = 895.81
KSTARZ_WIDTH = 47.4

def kstar_mass(min_mass, max_mass, n_events):
   min_mass = tnp.asarray(min_mass, tnp.float64)
   max_mass = tnp.asarray(max_mass, tnp.float64)
   kstar_width_cast = tnp.asarray(KSTARZ_WIDTH, tnp.float64)
   kstar_mass_cast = tnp.asarray(KSTARZ_MASS, tnp.float64)

   kstar_mass = tnp.broadcast_to(kstar_mass_cast, shape=(n_events,))
   if KSTARZ_WIDTH > 0:
       kstar_mass = tfp.distributions.TruncatedNormal(loc=kstar_mass,
                                                      scale=kstar_width_cast,
                                                      low=min_mass,
                                                      high=max_mass).sample()
   return kstar_mass

bz = GenParticle('B0', B0_MASS).set_children(GenParticle('K*0', mass=kstar_mass)
                                            .set_children(GenParticle('K+', mass=KAON_MASS),
                                                          GenParticle('pi-', mass=PION_MASS)),
                                            GenParticle('gamma', mass=0.0))

bz.generate(n_events=500)

Shortcut for simple decays

The generation of simple n-body decay chains can be done using the nbody_decay function of phasespace, which takes

  • The mass of the top particle.

  • The mass of children particles as a list.

  • The name of the top particle (optional).

  • The names of the children particles (optional).

If the names are not given, top and p_{i} are assigned. For example, to generate \(B^0\to K\pi\), one would do:

import phasespace
from particle import literals as lp

N_EVENTS = 1000

B0_MASS = lp.B_0.mass
PION_MASS = lp.pi_plus.mass
KAON_MASS = lp.K_plus.mass

decay = phasespace.nbody_decay(B0_MASS, [PION_MASS, KAON_MASS],
                               top_name="B0", names=["pi", "K"])
weights, particles = decay.generate(n_events=N_EVENTS)

In this example, decay is simply a GenParticle with the corresponding children.

Eager execution

By default, phasespace uses JIT (just-in-time) compilation of TensorFlow to greatly speed up the generation of events. Simplified, this means that the first time a decay is generated, a symbolic array without a concrete value is used and the computation is remembered. As a user calling the function, you will not notice this, the output will be the same as if the function was executed eagerly. The consequence is two-fold: on one hand the initial overhead is higher with a significant speedup for subsequent generations, on the other hand, the values of the generated particles inside the function are not available in pure Python (e.g. for debugging basically).

If you need to debug the internals, using tf.config.run_functions_eagerly(True) (or the environment variable "PHASESPACE_EAGER=1") will make everything run numpy-like.

Random numbers

The random number generation inside phasespace is transparent in order to allow for deterministic behavior if desired. A function that uses random number generation inside always takes a seed (or rng) argument. The behavior is as follows

  • if no seed is given, the global random number generator of TensorFlow will be used. Setting this instance explicitly or by setting the seed via tf.random.set_seed allows for a deterministic execution of a whole _script_.

  • if the seed is a number it will be used to create a random number generator from this. Using the same seed again will result in the same output.

  • if the seed is an instance of tf.random.Generator, this instance will directly be used and advances an undefined number of steps.