""" Module providing a class to go from matrix elements squared,
statistics and thermal masses to the numerical evaluation of the
rate with necessary HTL resummations """
# This software is covered by the GNU General Public License 3.
# Copyright (C) 2024-2025 Killian Bouzoud, Jacopo Ghiglieri, Greg Jackson
#use this for typing
from typing import Tuple, Union, Optional
#handle warnings
import warnings
#start by importing sympy and other needed modules
import sympy
import numpy
from scipy import LowLevelCallable
from scipy.integrate import nquad, IntegrationWarning
import integrator # type: ignore
#create low-level scipy functions from the cython functions
#these are slightly faster for numerics
t4fast=LowLevelCallable.from_cython(integrator,'dim4tvec')
s4fast=LowLevelCallable.from_cython(integrator,'dim4svec')
t5fast=LowLevelCallable.from_cython(integrator,'dim5tvec')
s5fast=LowLevelCallable.from_cython(integrator,'dim5svec')
[docs]
class AutothermWarning(Warning):
"""Empty placeholder class to be able to customize warnings
occurring during numerical integration"""
#we want these warning to be always displayed (can of course be turned off by the user)
warnings.filterwarnings("always", category=AutothermWarning)
[docs]
def decompose(msq: str,stats: Tuple[int],couplings: Tuple[str]) -> dict:
r"""
Decomposes a string matrix element squared ``msq`` with statistics
``stats``, couplings ``couplings`` into the coefficients :math:`a_1`,
:math:`a_2`, :math:`b_1` (and :math:`b_2` for dimension 5). In the
case of higher-dimensional couplings, the function returns :math:`a_t`
and :math:`b_s`, where the former contains all pieces of the original matrix
element squared that feature positive powers of :math:`t` at the denominator
and the latter is the remainder.
``stats`` should be a tuple (t1,s1,s2) for the statistics of the other outgoing
particle t1 and the two incoming ones s1 and s2. +1 for a boson, -1 for a fermion.
``couplings`` should be a tuple of strings. Interactions of dimension different
from 4 or 5 are supported only if IR finite.
:param str msq: A string for a matrix element squared
:param tuple stats: A tuple (t1,s1,s2) of integers
:param tuple couplings: A tuple of strings for the couplings
:return: A dict with stats as keys and a list of [a1,a2,b1], [a1,a2,b1,b2] or
[at,bs] as values, depending on the dimensionality of the interaction
:rtype: dict
Examples
========
.. code-block:: python
from numerical.manipulate import *
decompose("kappa^2*g1**2*(s*t/u + s*u/t + t*u/s)",(1,1,1),("kappa","g1"))
"""
#warning: input is not fully sanitized:
# errors might not be thrown if msq and couplings are not str objects
#first of all add mandelstam variables and couplings to the list of known symbols
nslist={}
for coupling in couplings:
nslist[coupling] = sympy.Symbol(coupling)
s = sympy.Symbol("s")
t = sympy.Symbol("t")
u = sympy.Symbol("u")
for mandelstam in ["s","t","u"]:
nslist[mandelstam] = sympy.Symbol(mandelstam)
#create a list of the Mandelstam variables and of the type of denominators we expect
mandvars=[s,t,u]
wanteddenons=[sympy.S("1"),s,t,u,s**2,t**2,u**2]
#create a symbolic version of the matrix element squared with sympy
#expects a matrix element squared of the form \sum_i e^a g_i^b f_i(s,t,u)
# with g_i the couplings of the bath particles and e the bath-relic
#coupling. I guess it would be easy to generalize to multiple such
# couplings at once. And also to account for some
# masses for the bath particles??
msq_symb_start=sympy.sympify(msq,strict=False,locals=nslist)
#handle directly the case in which the expression trivially simplifies
# to zero (vanishing contribution in a flavor index projection, for instance)
if msq_symb_start == 0 :
return {}
#extract the dimensionality of the matrix elements squared
#to do so, rescale s, t and u by a dimensionful constant and see how the expression scales in it
energyscale=sympy.S("energyscale")
msq_symb_scale=msq_symb_start.subs({s: energyscale*s,t: energyscale*t,u:energyscale*u})
dim = 4+sympy.simplify(sympy.Derivative(msq_symb_scale,energyscale)*energyscale/msq_symb_scale)
if not isinstance(dim,sympy.core.numbers.Integer):
raise RuntimeError(f"could not work out the dimensionality {dim} "
f"of the interaction for the expression {msq}")
else:
dim=int(dim)
#fail if unsupported dimensionality of the interaction operator
fallback=False
if dim !=4 and dim != 5:
warnings.warn("Interaction of unsupported dimensionality, "
"using C fallback mode",AutothermWarning,stacklevel=2)
fallback=True
#now prepare to do some manipulations in case the matrix element is not
# in a clean form such as (s u/t+tu/s+st/u) but in some convoluted one like
#(s^2+st+t^2)^2/(stu)
#to do that, introduce some control variables for a while loop
fixed_denominators=False
niters=0
#we want to avoid an infinite loop
nitersmax=20
#expand out the matrix element squared
msq_symb=msq_symb_start.expand()
while (not fixed_denominators and niters<nitersmax):
newexpr=0
fixed_denominators=True
#the way to proceed is to decompose the matrix element into its basis elements
#with this we separate the matrix element squared into each component of a sum
for term in msq_symb.as_coeff_add()[1]:
#and for each term we look at the numerator and denominator
num, denom = sympy.cancel(term).as_numer_denom()
#if each denominator is in the form of a constant times
# one of the accepted forms (1,s,t,u,s^2,t^2,u^2) then
#we are good. Otherwise we need to do extra work
denomtest=True
for den in wanteddenons:
ratioden = denom/den
#we want this ratio to be independent of the mandelstam variables
if (sympy.simplify(sympy.Derivative(ratioden,s))==0 and\
sympy.simplify(sympy.Derivative(ratioden,t))==0 and\
sympy.simplify(sympy.Derivative(ratioden,u))==0):
denomtest=False
if denomtest:
fixed_denominators=False
#now decompose each numerator in a dictionary of powers, e.g. 2*s*t - > 2:1, s:1, t:1
ndict=num.as_powers_dict()
for var in mandvars:
if var in ndict:
#if a variable is at the numerator, exclude it from the denominator
newdenom=denom.subs(var,var-s-t-u)
#rewrite the numerator by lowering one power of that variable
newnum=num/var*sympy.simplify(var-s-t-u)
#add this new term to the new expression
newexpr=newexpr+newnum/newdenom
#do this only once
break
#add an escape route after a certain number of iterations
niters = niters+1
if not fixed_denominators:
msq_symb=sympy.simplify(newexpr).expand()
if niters>= nitersmax:
raise RuntimeError(f"failed to reduce the matrix element squared {msq} "
f"to a compatible form after {niters} iterations. Obtained {msq_symb}")
#the way to proceed is to decompose the matrix element into its basis elements
#with this we separate the matrix element squared into each component of a sum
if msq_symb.as_coeff_add()[0] != 0:
raise RuntimeError(f"The matrix element squared {msq_symb} "
"does not appear to be in the form of a sum")
newmsq=sympy.S("0")
#define the overall sign factor that accounts for n_F=-n_{-1}
#BETA: account for double production by adding the possibility that stats[0]==0. In
#case of double production the two incoming states [1] and [2] have the same statistics
if stats[0] == 0:
overall =1
else:
#if not dealing with pair production, the sign is given by tau_1, i.e. stats[0]
overall = stats[0]
#create a dict for the result
result={}
for term in msq_symb.as_coeff_add()[1]:
finalterm = term
#find the u dependence of each term
ucoeff = term.as_coeff_exponent(u)
#ucoeff is ( a,b) for each a u^b
#if u is at the denominator: do a u<->t switch
if ucoeff[1]<0:
finalterm = term.subs({t: -s-t, u:t },simultaneous=True)
if ucoeff[1] ==-1 and stats[1]!= stats[2]:
#create a new tuple with exchanged s1 s2 statistics
statterm = stats[0], stats[2], stats[1]
#destroy the variable, so that it does not end up in the sum later
strterm=str(finalterm)
finalterm=sympy.S("0")
#recursively call the function on the new matrix element
newresult = decompose(strterm,statterm,couplings)
#if such a new statistics has already been called, we must add this new term to it
if statterm in result:
tempresult = result[statterm]
for i, tempres in enumerate(tempresult):
tempresult[i]=sympy.sympify(tempres)+sympy.sympify(newresult[statterm][i])
result[statterm] =tempresult
else:
#otherwise we just add it to the dict of the results
result.update(newresult)
else:
#if the denominator is more IR sensitive than 1/u we throw an exception
if ucoeff[1]<-1:
raise RuntimeError(f"This code cannot handle a term like {term} "
f"for a dimension-{dim} coupling")
#if u is at the numerator: do a u = -s -t substitution
if ucoeff[1]>0:
finalterm = term.subs(u,-s-t)
newmsq = newmsq+finalterm*overall
#create lists for the terms that go like s/t, 1 (in t-channel parametrisation)
# and u/s and (s^2+u^2)/t, t, t^2/s and s in dim 5
a1=sympy.S("0")
a2=sympy.S("0")
b1=sympy.S("0")
if dim==5:
b2=sympy.S("0")
#again throw an exception if the result does not appear in the form of a sum
if newmsq.simplify().expand().as_coeff_add()[0] != 0:
raise RuntimeError(f"The matrix element squared {newmsq} "
"does not appear to be in the form of a sum")
for term in newmsq.simplify().expand().as_coeff_add()[1]:
#assume that the result term by term is a power-law in t,
# and look separately at each possible power
#we later check that no piece of the original matrix element squared is left behind
tsplit = term.as_coeff_exponent(t)
if dim ==4:
if tsplit[1]==-1:
#then this is a s/t
a1 =a1 +tsplit[0]/s
if tsplit[1]==1:
#then this is t/s
b1= b1 -tsplit[0]*s
a2 = a2 -tsplit[0]*s
if tsplit[1]==0:
#then this is a constant
a2 = a2 +tsplit[0]
if dim ==5:
if tsplit[1]==-1:
#then this is a s^2/t
a1 =a1 +tsplit[0]/s/s/2
a2 = a2- tsplit[0]/s/s/2
b2 = b2 - tsplit[0]/s/s
if tsplit[1]==1:
#then this is t
a2 = a2 +tsplit[0]
if tsplit[1]==2:
#then this is t^2/s
b1 = b1 +tsplit[0]*s
if tsplit[1]==0:
#then this is s
b2 = b2 +tsplit[0]/s
#if the dimension is not 4 or 5 and we are falling back to 4D numerics,
# then we just need to separate t at the numerator (s-parametrisation)
# from t at the denominator (t-parametrisation). These will be divergent, so
#their evaluation will not be possible at the moment
#we use a1 to store t denominators and b1 to store the rest
if fallback:
if tsplit[1]<0:
a1 = a1 + term
else:
b1 = b1 + term
#test the new expression
checkmsq=sympy.sympify('0')
if dim ==4:
checkmsq = a1*s/t+b1*(-t/s-1)+a2
if dim ==5:
checkmsq = a1*(2*s*s/t+2*s+t)+a2*t+b1*t*t/s+b2*s
if fallback:
checkmsq= a1 + b1
#check that the new expression agrees with the old one,
# i.e. that the basis decomposition went well
if sympy.simplify(checkmsq-newmsq) != 0:
raise RuntimeError(f"The dimension-{dim} decomposition "
f"for expression {sympy.simplify(newmsq)} has failed")
#return the dict of the coefficients
if dim==4:
result.update({stats: [a1,a2,b1] })
if dim==5:
#exploit \int s+2t=0 when s_1=s_2
if stats[1]==stats[2]:
result.update({stats: [a1,a2-2*b2,b1,sympy.S("0")] })
else:
result.update({stats: [a1,a2,b1,b2] })
if fallback:
result.update({stats: [a1,b1]})
return result
[docs]
class NumRate:
r"""
This class maps the 2<->2 phase-space integration for a dict
``statmsqdict`` of matrix elements squared and statistics to
all the coefficients :math:`a_i` and :math:`b_i`. Its method
:py:func:`~NumRate.rate` returns a function performing the
two-dimensional numerical integration.
In cases where IR divergences are present, HTL resummation is
performed in the strict LO, subtracted and tuned schemes.
In cases of matrix elements squared arising from higher-dimension
operators, a C fallback routine is called for
their 4D integrations if no t-channel divergence is present
:param dict statsmsqdict: uses tuples of (t1,s1,s2) statistics as keys
and strings for the corresponding matrix elements squared. t1,s1 and s2 are +1
for bosons and -1 for fermions. They correspond to the statistics of the
outgoing and two incoming bath particles.
:param dict couplingdict: is a dict with three keys. ``noneq`` is the
non-equilibrium coupling, ``gauge`` are the gauge couplings (a
tuple) and ``others`` all other couplings
:param tuple masses: is needed for IR-divergent processes. In case of
a dimension-4 operator a string with the analytical expression of
:math:`m_f^2/T^2` is expected, with :math:`m_f^2` the asymptotic mass
of the fermion coupling to the non-equilibrium particle. In case of
dimension 5 it is a tuple of strings containing :math:`m_D^2/T^2`
for each gauge boson, with :math:`m_D` the Debye mass
:param int deg: is the degeneracy of the produced particle. The returned
rate is then the production rate per degree of freedom. If this is
not needed, ``deg`` can be set to 1
Examples
========
Axion production in the KSVZ model
.. code-block:: python
from numerical.manipulate import *
axiondict = {(1,1,1):"32*kappa^2*8*g3^6*3*(s*u/t+t*u/s+s*t/u)",\
(-1,-1,1):"32*kappa^2*(s^2+u^2)/t*(-1/2*Nf*8*g3^6)",\
(-1,1,-1):"32*kappa^2*(s^2+t^2)/u*(-1/2*Nf*8*g3^6)",\
(1,-1,-1):"32*kappa^2*(u^2+t^2)/s*(1/2*Nf*8*g3^6)"}
axionclass=NumRate(axiondict, {"noneq":("kappa",),\
"gauge": ("g3",), "others": ("Nf",)},("g3^2*(1+Nf/6)",),1)
axionclass.rate(numpy.array([1.,3.,5.]),1,(numpy.sqrt(4.*numpy.pi*0.3),3),0)
"""
def __init__(self,statmsqdict: dict, couplingdict: dict,\
masses: Union[str,Tuple[str]],deg: int):
self.deg=deg
"""Stores the degeneracy of the produced particle"""
#this constructor takes as input the R.H.S of a Boltzmann eq.
# in the form of eq:prodtot in the autotherm note summed over all matrix
# elements and statistics and iterates over each combination of
# statistics x matrix elements to yield a function to compute
# the rate decomposed in all the basis elements, as a function of
# the momentum k/T and of the numerical value of all coupling
#The factor of 1/(4 deg k) is pulled out, where deg is the
# degeneracy of the particle being produced, e.g. 1 for axion, 2 for photon
#it takes as input a dict of {stats : "msq"} and another dict of the couplings
#msq is to be intended as summed over the degeneracies of all species
#dim is the dimensionality of the coupling to the bath
# couplingdict has to have the following structure, with keys 'noneq',
# 'gauge' (needed for gauge-boson masses), 'others' (which may also
# include stuff like Nf)
#for each key we have a tuple of string variable
#masses is the expression for the mass. if dim 4 it is the fermion's mass squared over T^2.
#if dim 5 it is a tuple of the masses for each gauge boson, in the same order
# or the couplings in the 'gauge' dict entry
#get the symbolic names of the couplings
noneq = couplingdict["noneq"]
gauge = couplingdict["gauge"]
others = couplingdict["others"]
couplings=noneq+gauge+others
#first of all add mandelstam variables and couplings to the list of known symbols
nslist={}
for coupling in couplings:
nslist[coupling] = sympy.Symbol(coupling)
#save the non-equilibrium coupling
self.ynoneq=nslist[noneq[0]]
"""Stores the :external+sympy:py:class:`sympy symbol <sympy.core.symbol.Symbol>`
for the non-equilibrium coupling"""
s = sympy.Symbol("s")
t = sympy.Symbol("t")
for mandelstam in ["s","t","u"]:
nslist[mandelstam] = sympy.Symbol(mandelstam)
#create a list of symbols for the bath and gauge couplings
self.bathcouplings=[]
"""Stores the :external+sympy:py:class:`sympy symbol <sympy.core.symbol.Symbol>`
for the equilibrium couplings"""
self.gaugecouplings=[]
"""Stores the :external+sympy:py:class:`sympy symbol <sympy.core.symbol.Symbol>`
for the gauge couplings"""
for coupling in gauge+others:
if coupling in gauge:
self.gaugecouplings.append(sympy.Symbol(coupling))
self.bathcouplings.append(sympy.Symbol(coupling))
#TODO: fix when there are no bathcouplings
#create an empty dict for the results
self.results={}
"""Stores a ``stats``, ``msq`` dict for the decomposition into the basis functions.
This follows the definition given in :py:func:`decompose`"""
#run the decomposition on each stats*msq pair
for stats, msq in statmsqdict.items():
#run the decomposition
temp = decompose(msq,stats,couplings)
#if there is already an entry in the dictionary for this statistics, update it
for resstat in list(temp):
if resstat in self.results:
oldresults = self.results[resstat]
for i,oldres in enumerate(oldresults):
oldresults[i]=sympy.simplify(sympy.sympify(oldres)\
+sympy.sympify(temp[resstat][i]))
self.results[resstat] = oldresults
#if not, just add it
else:
self.results[resstat]=temp[resstat]
#reconstruct the dimension from the output of the decomposition
self.dim = len(list(self.results.values())[0])+1
"""Stores the dimension of the operator coupling the produced particle to the bath"""
#prepare to use the fall-back method if D=!4 and D!=5
self.fallback=False
"""If ``True`` the C fallback mode is active"""
if self.dim != 4 and self.dim != 5:
self.fallback=True
#prepare to ignore dict entries having vanishing coefficients
emptyset = [0,0,0]
if self.dim ==5:
emptyset.append(0)
#switch to find out if we need HTL resummation
self.irdiv = False
"""If ``True`` the naive rate is IR divergent"""
#dict in which the divergent pieces are stored
ircoeffs={}
#dict in which the clean results are stored in the form of lambdified
# functions of the bath couplings
self.cleanresults={}
#create a set to store the power of the non-equilibrium coupling
powerlaws=set()
for stats, msq in self.results.items():
#start by filtering out entries having vanishing coefficients
if msq != emptyset:
#create a vector to store the cleaned-up matrix elements
msqsimple=emptyset.copy()
#deal with the 0th coefficient (a1) separately, as it is the
#one which is divergent
msq[0]= msq[0].simplify()
#If it has a non-zero component for any statistics, we have an IR divergence
#and we need resummation
if msq[0] !=0:
self.irdiv = True
#if the dimensionality is unsupported, we can't treat this case at the moment.
# For instance, Gs->Gs (graviton-scalar scattering) would go like
# \kappa^4 s^2u^2/t^2 and we would need the graviton HTL to
# address its IR behavior
if self.fallback:
warnings.warn(f"unsupported divergent expression {msq[0]} for "
f"statistics {stats}. Setting it to zero",AutothermWarning,stacklevel=2)
self.irdiv = False
#in dimension four, simply store this coefficient
if self.dim==4:
ircoeffs.update({stats:msq[0]})
#each msq[i] will be of the form a noneq^b. extract a and b
coeffexp=msq[0].as_coeff_exponent(self.ynoneq)
#create a lamdbdified function for this coefficient
if self.dim ==4:
msqsimple[0] = sympy.lambdify(self.bathcouplings,\
coeffexp[0],modules="numpy")
if self.dim ==5:
#in this case we need to go gauge coupling by gauge
# coupling and create a vector function
#start by converting this expression to a poly object
polya1 = coeffexp[0].as_poly(self.gaugecouplings)
#create a dict of the coefficient of the different powers
# in the gauge couplings
polya1dict= polya1.as_dict()
#create an array to store the output split by gauge group
a1gauge=len(self.gaugecouplings)*[0]
#go through them and create the coefficient for each coupling
# such as g_i^2 g_j^2 (s^2+u^2)/t
for powers, a1coeff in polya1dict.items():
for i, gaugec in enumerate(self.gaugecouplings):
if powers[i] !=0:
# a1gauge[i] ==0
# else:
a1gauge[i]=a1coeff*gaugec**powers[i]
#now create a lambdified vector function (will return a numpy array)
msqsimple[0] = sympy.lambdify(self.bathcouplings,a1gauge,modules="numpy")
#and store this function in the dict
ircoeffs.update({stats:a1gauge})
#add the power law exponent of the non-eq coupling to the list
powerlaws.add(coeffexp[1])
else:
#we still need a function to give 0 if there is no divergence
if self.dim==4:
msqsimple[0] = sympy.lambdify(self.bathcouplings,0,modules="numpy")
if self.dim==5:
msqsimple[0] = sympy.lambdify(self.bathcouplings,\
len(self.gaugecouplings)*[0],modules="numpy")
#now go through the coefficients of the non-divergent elements
for i in range(1,len(msq)):
msq[i]= msq[i].simplify()
#each msq[i] will be of the for a noneq^b. extract a and b
coeffexp=msq[i].as_coeff_exponent(self.ynoneq)
if self.fallback and i==1:
#in this case we need to create the type of structure
# handled by the fallback C integration assuming the
# non-divergent contribution is of the form
# \sum_i c_i s^{m_i} t^{l_i}, with m_i+l_i=dim -4
# and l_i>=0, we need the (c_i, m_i, l_i) elements
#TODO: add a check for this!
#the polynomial method fails if there are negative powers,
# so we keep multiplying by s until it works
tempexpr=coeffexp[0].simplify().expand()
spower=0
while tempexpr.as_poly(s,t) is None:
tempexpr = s*tempexpr
spower = spower+1
#now we can create a dict
polydict = tempexpr.as_poly(s,t).as_dict(s,t)
# initialise an empty array to store the results
fallbackvec=[]
for stpowers, coeffs in polydict.items():
#create a lambidified function for the c_i
fallbackvec.append(sympy.lambdify(self.bathcouplings,\
coeffs.as_expr(),modules="numpy"))
#add the values of m_i and l_i. For the former fix the extra
# powers of s we added before
fallbackvec.append(stpowers[0]-spower)
fallbackvec.append(stpowers[1])
msqsimple[i]= fallbackvec
else:
#create a lambdified function of the bath couplings for each coefficient
msqsimple[i] = sympy.lambdify(self.bathcouplings,\
coeffexp[0],modules="numpy")
#do not consider cases where the matrix element coefficient is zero
if msq[i] !=0:
powerlaws.add(coeffexp[1])
#add this cleaned-up matrix elements to the new dict
self.cleanresults.update({stats:msqsimple})
#check that we have no more than a single power law
if len(powerlaws) >1:
raise NotImplementedError("Non-homogeneous matrix element in terms of the "
f"non-equilibrium coupling {noneq[0]}\n"
"This is currently unsupported, please split your matrix "
"element squared into homogeneous subsets.")
else:
#save the exponent in a variable
self.powerlawexp=powerlaws.pop()
#if we have an IR divergence, we need to make sure the thermal masses
# match with what comes out of the IR limit of the matrix elements.
if self.irdiv:
#start with the 4-dimensional case
if self.dim==4:
#here I can only compute the IR limit of the integrations,
#first a sanity check: the coeff of the (-1,1,whatever) piece must be opposite to
#that of the (1,-1,whatever) coefficient. We also check that there is a single
#value of "whatever" contributing and fail otherwise (it would mean that the
# non-equilbrium particle is both a boson and a fermion)
#start by introducing a control variable
foundcoeff = False
poscoeff=sympy.sympify('0')
for i in (-1,1):
if (-1,1,i) in ircoeffs:
coeff = ircoeffs[(-1,1,i)]
if coeff !=0:
#store for later use the value of s2
self.s2div=i
if sympy.simplify(coeff + ircoeffs[(1,-1,i)]) != 0 :
raise RuntimeError("Lack of crossing symmetry for dimension-4 IR "
"coefficients "+str(coeff)+" and "\
+str(-ircoeffs[(1,-1,i)]))
else:
poscoeff=coeff
if foundcoeff:
raise RuntimeError("Multiple nonzero dimension-4 IR "
"coefficients "+str(coeff)+" "\
+str(-ircoeffs[(1,-1,i)]))
foundcoeff = True
#let us start by stripping the dependence on the noneq
# coupling constant and multiply by pi^2/4,
#which is the typical T^2 \pi^2/4 accompanying a fermion mass
#ratsimp does a rational simplification
poscoeffstrip=sympy.ratsimp(poscoeff.subs(noneq[0],1)*sympy.pi*sympy.pi/4)
self.mass=(sympy.sympify(masses)).ratsimp()
#check that the ratio between the mass and the IR-divergence coefficient is real
#meaning that it is a pure number and does not depend on any constant, which
#would mean that there is a mismatch between the two.
#NOTE: This could be problematic for some models, say if an overall constant
#considered as a "coupling" multiplies the divergent matrix element
#for this reason we print out a warning but do not fail
#TODO: consider using the is_constant() method
self.massratio_expr=sympy.ratsimp(poscoeffstrip/self.mass)
if not self.massratio_expr.is_real :
warnings.warn("The ratio between the fermion mass "\
+str(self.mass)+" and the coefficient of the IR "
"divergence "+str(poscoeffstrip)+" depends on"
" the coupling constants entering the mass.\n"
"This is most likely not problematic, continuing with "
"evaluation.",AutothermWarning,stacklevel=2)
#lambdify this coefficient
self.massratio=sympy.lambdify(self.bathcouplings,self.massratio_expr)
#the value of \xi is
self.xi=0.5*numpy.exp(1.)
#create a lambdified function for the mass
self.massfun=sympy.lambdify(self.bathcouplings,self.mass)
#continue with the gauge-boson exchange in the dim-5 case
if self.dim==5:
#here I can only compute the IR limit of the integrations, along the lines
#of what is done starting around eq. eq:irlmitstart in the autotherm note
#first we collect the bosonic and fermionic parts of the divergence, i.e. the
#coefficients of the (1,1,whatever) and (-1,-1,whatever) statistics.
#note that whatever has to correspond to the statistics of the tracked
# particle of momentum k, as we are looking at a t-channel gauge-boson exchange
#these are tracked by the ircoeffs vector
#we now use this to add a safety check on the masses
massescoeffs=len(self.gaugecouplings)*[0]
for i in range(0,len(self.gaugecouplings)):
for stats, a1coeffs in ircoeffs.items():
if stats[0]==1 and stats[1]==1:
#we have a boson. The corresponding factor is \pi^2/6
massescoeffs[i]=massescoeffs[i]-a1coeffs[i]/6*sympy.pi*sympy.pi
if stats[0]==-1 and stats[1]==-1:
#we have a fermion. The corresponding factor is -\pi^2/12,
# with a factor of -1/2 from statistics and \tau_1 normalisation
# FIXME refer to equations in the manual
massescoeffs[i]=massescoeffs[i]+a1coeffs[i]/12*sympy.pi*sympy.pi
#if the two statistics are different, something is very wrong
if stats[0]!= stats[1]:
raise RuntimeError("Different statistics for the two particles "
f"connected to a t-channel gauge boson {a1coeffs}")
massescoeffs[i]=sympy.ratsimp(massescoeffs[i])
#compare the IR coefficients with the masses we obtained
self.massvec=[]
if isinstance(masses,tuple):
for item in masses:
masstmp=(sympy.sympify(item)).ratsimp()
self.massvec.append(masstmp)
#in this case we have a single mass
else:
masstmp=(sympy.sympify(masses)).ratsimp()
self.massvec.append(masstmp)
self.massratio=[]
self.massratio_expr=[]
for i in range(0,len(self.gaugecouplings)):
#compute the ratio between the two and store it in a function,
# it will be needed later to compute the coefficient of the HTL log
massratiotemp=sympy.ratsimp(massescoeffs[i]/self.massvec[i])
self.massratio_expr.append(massratiotemp)
self.massratio.append(sympy.lambdify(self.bathcouplings,\
massratiotemp,modules="numpy"))
#do again the check that the ratio is a pure number. This fails in the axion
# case if the explicit powers of the gauge coupling in the
# effective axion-gauge coupling are not included in non-eq
if not massratiotemp.is_real :
warnings.warn("The ratio between the gauge boson "
"mass "+str(self.massvec[i])+" and "
"the coefficient of the IR divergence "+str(massescoeffs[i])+" depends on"
" the coupling constants entering the mass.\n"
"This is most likely not problematic, continuing with evaluation.",\
AutothermWarning,stacklevel=2)
#the value of \xi is
self.xi=0.5*numpy.exp(1./3.)
#create a lambdified function for the mass
self.massfun=sympy.lambdify(self.bathcouplings,self.massvec)
[docs]
def get_coeffs(self):
"""This method returns the :external+sympy:py:class:`symbolic form <sympy.core.expr.Expr>`
of the coefficients"""
return self.results
[docs]
def get_leadlog(self):
"""This method returns the :external+sympy:py:class:`symbolic form <sympy.core.expr.Expr>`
of the leading-logarithmic part"""
#in this case there is no log
if not self.irdiv :
return sympy.sympify('0')
else:
k=sympy.S('k')
temp=sympy.S('T')
prefac = self.ynoneq**self.powerlawexp/(128*sympy.pi**3*self.deg*k)
if self.dim ==4:
msquared = self.mass*temp**2
logcoeff = self.massratio_expr*msquared*(sympy.exp(k/temp)+self.s2div)
logcoeff = logcoeff*sympy.log(4*k*k/msquared)/(sympy.exp(k/temp)-self.s2div)
if self.dim==5:
msquared= self.massvec
logcoeff = 0
#in this case we need to sum over the gauge groups
for i, msqi in enumerate(msquared):
if msqi != 0:
logcoeff += 8*k*self.massratio_expr[i]*msqi*temp**3*\
sympy.log(4*k*k/(msqi*temp**2))
return sympy.simplify(prefac*logcoeff)
[docs]
def rate(self,k: Union[float,numpy.ndarray], y: float, numcouplings: Tuple[float],
mode: int, intoptions : Optional[list]=None) ->numpy.ndarray:
r"""
This method returns the rate :math:`\Gamma/T^{1+2dim-8}` as a function of an
array ``k`` of :math:`k/T` values, ``y`` value for the non-equilibrium coupling, a
``numcoupling`` tuple with the numerical values of the other couplings.
:param ndarray k: an array ``k`` of :math:`k/T` values
:param float y: the non-equilibrium coupling
:param tuple numcoupling: the numerical values of the other couplings
:param int mode: sets the desired output. 0 returns everything, 1 returns
the strict rate only, 2 the subtr rate only, 3 the tuned rate only.
A negative value uses the C fallback routines
:param list intoptions: can be passed optionally as a dict or lists of
dicts for the two nested integrations in :math:`q_+` and :math:`q_-`.
See the :external+scipy:py:func:`scipy documentation<scipy.integrate.quad>`.
The default options are ``{'limit':1000,'epsrel':1e-5}`` for both integrations.
:return: a list of ndarrays() with `k` in the first position and the
requested rates according to `mode`
:rtype: ndarray
"""
#first of all, fail if k is not a numpy array or a single float or integer
if not isinstance(k,numpy.ndarray):
if isinstance(k,float) or isinstance(k,int) or isinstance(k,numpy.float64):
k=numpy.array([k])
else:
raise TypeError("unexpected type of variable k")
klen=len(k)
#mode works as follows
# <0 - use fallback C routines
# 0 - returns k/T, strict, subtr, tuned
# 1 - returns k/T, strict
# 2 - returns k/T, subtr,
# 3 - returns k/T, tuned
# for positive modes, ignore this distinction if the expression is not
# infrared divergent, as all methods are bound to agree
cfallback=False
if mode <0:
cfallback = True
if not self.irdiv :
mode = 1
mass = 0.
else:
#compute m/T
msquared= self.massfun(*numcouplings)
mass = numpy.sqrt(msquared)
if mode >3 or not isinstance(mode, int):
raise ValueError(f"Invalid value of mode: {mode}")
if mode ==1 or mode ==2:
tuned = False
else:
tuned = True
if mode !=3:
subtract = True
else:
subtract = False
#compute the overall prefactor
prefac= y**self.powerlawexp/(4*self.deg*k)
if not self.fallback and not cfallback :
prefac=prefac/((4.*numpy.pi)**3*k)
#create the variables for the t and s parts of the result in the subtr
# and strict schemes, as well as in the C fallback
if subtract:
tpartsubtr=numpy.zeros(klen)
if tuned:
tparttuned=numpy.zeros(klen)
spart = numpy.zeros(klen)
#in the dim-5 case it is convenient to split out the finite
# part (a2-proportional) of the t-channel
tpartfinite=numpy.zeros(klen)
# Save the original warning handler
original_showwarning = warnings.showwarning
#prepare a custom function for the integration warnings thrown by scipy.integrate
def create_custom_warning_handler(k,stat,intmessage,aibi):
def custom_warning_handler(message, category, filename, lineno, file=None, line=None):
if category is IntegrationWarning:
print(f"Warning raised by the integration routine for the {intmessage} for "
f"momentum {k}, statistics {stat} and coefficients {aibi}.\nThis is "
f"possibly not problematic. The warning message is:\n\n{message}")
else:
original_showwarning(message, category, filename, lineno, file, line)
return custom_warning_handler
# Restore the original warning handler
warnings.showwarning = original_showwarning
#set up the default integration options
if intoptions is None:
intoptions=[{'limit':1000,'epsrel':1e-5},{'limit':1000,'epsrel':1e-5}]
#now start to cycle through the statistics
if self.fallback:
for stats, msqfun in self.cleanresults.items():
coeffslist=msqfun[1]
n=int(len(coeffslist)/3)
#create a new list with evaluated lambdified functions
coeffslistnew=coeffslist.copy()
for i in range(0,n):
coeffslistnew[3*i]=coeffslist[3*i](*numcouplings)
for j, kj in enumerate(k):
spart[j] = spart[j]+integrator.fallback_s_poly_vec(kj,*stats, n,\
numpy.asarray(coeffslistnew,dtype=float))
else:
for stats, msqfun in self.cleanresults.items():
#evaluate the lambdified functions of the couplings
a1 = msqfun[0](*numcouplings)
a2 = msqfun[1](*numcouplings)
b1 = msqfun[2](*numcouplings)
if self.dim==5:
b2 =msqfun[3](*numcouplings)
#now prepare for the integrations. First we handle the case where we
# are using the C fallback routines
if cfallback:
#work through the different cases
if self.dim ==4:
if a1 !=0 or a2!=0:
paramvec = numpy.asarray([a1,a2,self.xi*mass])
for j, kj in enumerate(k):
tpartfinite[j] += integrator.fallback_t_poly_vec(kj,\
*stats,self.dim,paramvec)
if b1 !=0:
paramvec = numpy.array([-b1,0.,0.,-b1,-1.,1.])
for j, kj in enumerate(k):
spart[j] += integrator.fallback_s_poly_vec(kj,*stats,2,paramvec)
if self.dim ==5:
#store separately the finite part
if a2!=0:
paramvec=numpy.array([0.,a2,0.])
for j, kj in enumerate(k):
tpartfinite[j] += integrator.fallback_t_poly_vec(kj,*stats,\
self.dim,paramvec)
#now go gauge group by gauge group
for i, a1i in enumerate(a1):
if a1i!=0. or a1i!=0:
paramvec=numpy.array([a1i,0.,self.xi*mass[i]])
for j, kj in enumerate(k):
tpartfinite[j] += integrator.fallback_t_poly_vec(kj,\
*stats,self.dim,paramvec)
#now the s-channel
if b1 !=0 or b2!=0:
paramvec= numpy.array([b1,-1.,2.,b2,1.,0.])
for j, kj in enumerate(k):
spart[j] += integrator.fallback_s_poly_vec(kj,*stats,2,paramvec)
else:
if self.dim==4:
if a1 !=0 or a2!=0:
#deal with the different cases
if subtract:
for j, kj in enumerate(k):
warnings.showwarning = create_custom_warning_handler(kj,stats,\
"dim-4 t-channel subtracted",[a1,a2])
tpartsubtr[j] += nquad(t4fast,[[0.,kj],[-numpy.inf,0.]],\
args=(kj,a1,a2,*stats,-1),opts=intoptions)[0]
if tuned:
for j, kj in enumerate(k):
warnings.showwarning = create_custom_warning_handler(kj,stats,\
"dim-4 t-channel tuned",[a1,a2])
#recall that t4fast and t5fast take care automatically of the
# a_1,a_2 manipulations needed to deal with the tuned scheme
tparttuned[j] += nquad(t4fast,[[0.,kj],[-numpy.inf,0.]],\
args=(kj,a1,a2,*stats,self.xi*mass),opts=intoptions)[0]
if b1 !=0:
for j, kj in enumerate(k):
warnings.showwarning = create_custom_warning_handler(kj,stats,\
"dim-4 s channel",1)
spart[j] += b1*nquad(s4fast,[[kj,numpy.inf],[0.,kj]],\
args=(kj,*stats),opts=intoptions)[0]
if self.dim==5:
#store separately the finite part
if a2!=0:
for j, kj in enumerate(k):
warnings.showwarning = create_custom_warning_handler(kj,\
stats,"dim-5 t-channel a_2",1)
tpartfinite[j] += a2*nquad(t5fast,[[0.,kj],[-numpy.inf,0.]],\
args=(kj,0.,1.,*stats,-1),opts=intoptions)[0]
if subtract:
#in this case we do not need to go gauge group by gauge group,
# we can sum the a1 vector
a1tot=numpy.sum(a1)
if a1tot !=0:
for j, kj in enumerate(k):
warnings.showwarning = create_custom_warning_handler(kj,\
stats,"dim-5 t-channel subtracted",1)
tpartsubtr[j] += a1tot*nquad(t5fast,[[0.,kj],[-numpy.inf,0.]]\
,args=(kj,1.,0.,*stats,-1),opts=intoptions)[0]
if tuned:
for i,a1i in enumerate(a1):
if a1i!=0. or a1i!=0:
for j, kj in enumerate(k):
warnings.showwarning = create_custom_warning_handler(kj,\
stats,"dim-5 t-channel tuned",1)
tparttuned[j] += a1i*nquad(t5fast,[[0.,kj],[-numpy.inf,0.]]\
,args=(kj,1.,0.,*stats,self.xi*mass[i]),opts=intoptions)[0]
if b1 !=0 or b2!=0:
for j, kj in enumerate(k):
warnings.showwarning = create_custom_warning_handler(kj,stats,\
"dim-5 s channel",[b1,b2])
spart[j] += nquad(s5fast,[[kj,numpy.inf],[0.,kj]],\
args=(kj,b1,b2,*stats),opts=intoptions)[0]
#add tpartfinite to spart to simplify the expressions below
spart = spart +tpartfinite
#in the subtract case, find the logarithmic parts
if subtract:
if not self.irdiv :
logcoeffstrict=numpy.zeros(klen)
logcoeffsubtr=numpy.zeros(klen)
else:
if self.dim ==4:
logcoeffstrict=self.massratio(*numcouplings)*msquared*2*k*\
(1+self.s2div*numpy.exp(-k))/(1-self.s2div*numpy.exp(-k))
logcoeffsubtr=logcoeffstrict*numpy.log1p(4*k*k/msquared)
logcoeffstrict=logcoeffstrict*numpy.log(4*k*k/msquared)
if self.dim==5:
logcoeffstrict=numpy.zeros(klen)
logcoeffsubtr=numpy.zeros(klen)
#in this case we need to sum over the gauge groups
for i, msqi in enumerate(msquared):
if msqi != 0:
logcoeffstrict += 16*k*k*((self.massratio[i])(*numcouplings))\
*msqi*numpy.log(4*k*k/msqi)
logcoeffsubtr += 16*k*k*((self.massratio[i])(*numcouplings))\
*msqi*numpy.log1p(4*k*k/msqi)
#prepare to return the output
#NOTE: the dimensionful factor of T is scaled out
if mode ==0:
return [k,prefac*(spart + tpartsubtr+logcoeffstrict),\
prefac*(spart + tpartsubtr+logcoeffsubtr),\
prefac*(spart + tparttuned)]
if mode ==1:
return [k,prefac*(spart + tpartsubtr+logcoeffstrict)]
if mode ==2:
return [k,prefac*(spart + tpartsubtr+logcoeffsubtr)]
if mode ==3:
return [k,prefac*(spart + tparttuned)]
if mode < 0:
return [k,prefac*spart]