Tutorial: Fast self-forced trajectories

An essential component of the FastEMRIWaveforms (FEW) framework is the rapid evaluation of the inspiral trajectory of the secondary compact object. FEW provides the tools required for users to generate and visualiase these trajectories. Users can also implement their own trajectory models to incorporate additional physics (such as environmental effects or modifications to gravity). We will demonstrate these capabilities in this tutorial.

ODE classes

Parameter conventions

FEW constructs trajectories by integrating the equations of motion of the compact binary; these are a system of ordinary differential equations (ODEs) that describe the evolution of the following parameters:

Parameter

Definition

\(p\)

(Dimensionless) semi-latus rectum

\(e\)

Eccentricity

\(x_I\)

Cosine of the inclination of the orbital plane

\(\Phi_\phi\)

Azimuthal GW phase

\(\Phi_\theta\)

Polar GW phase

\(\Phi_r\)

Radial GW phase

The ODE system can be integrated efficiently with adaptive Runge-Kutta techniques. These solvers obtain trajectories by integrating the right-hand side (RHS) of the ODE with respect to a time parameter.

Stock trajectory models

In order to handle the necessary information for evaluating the RHS of the ODE, FEW defines ODE classes. The following stock options are available:

Model

Features

Notes

KerrEqEccFlux

Adiabatic; equatorial eccentric inspiral with spinning primary

Our most up-to-date model

SchwarzEccFlux

Adiabatic; inspiral with non-spinning primary

An older model (currently outdated)

PN5

Post-Newtonian; eccentric inclined inspirals with spinning primary

Most complete model, but inaccurate for larger \(e\) and/or lower \(p\)

Obtaining the right-hand side of the trajectory ODE

As an example, let’s examine the SchwarzEccFlux model:

[1]:
from few.trajectory.ode import SchwarzEccFlux

rhs = SchwarzEccFlux()

Before evaluating the RHS, we must first define the parameters of the system that remain fixed during an inspiral. At adiabatic order, these are the component masses (\(M\) and \(\mu\)) and the dimensionless spin parameter of the primary (\(a\)):

[2]:
M = 1e6  # Solar masses
mu = 1e1  # Solar masses
a = 0.0  # For a Schwarzschild inspiral, the spin parameter is zero

rhs.add_fixed_parameters(M, mu, a)

We can then access the ODE derivatives:

[3]:
p = 10.0
e = 0.3
x = 1.0  # Schwarzschild inspiral is equatorial by definition

pdot, edot, xIdot, Omega_phi, Omega_theta, Omega_r = rhs([p, e, x])
pdot, edot, xIdot, Omega_phi, Omega_theta, Omega_r
[3]:
(-0.017771723696175704,
 -0.000779281500397855,
 0.0,
 0.028647063536752972,
 0.028647063536752972,
 0.018040932375307846)

FEW integrates trajectories on the radiation-reaction timescale \(t_\mathrm{rr} = t \epsilon\), where \(\epsilon = \mu / M\) is the mass ratio of the system. The RHS of the ODE is therefore defined such that the \((\dot{p}, \dot{e}, \dot{x}_I)\) are scaled by \(\epsilon^{-1}\). We can easily undo this rescaling if required:

[4]:
pdot, edot, xIdot, Omega_phi, Omega_theta, Omega_r = rhs([p, e, x], scale_by_eps=True)
pdot, edot, xIdot, Omega_phi, Omega_theta, Omega_r
[4]:
(-1.7771723696175706e-07,
 -7.792815003978551e-09,
 0.0,
 0.028647063536752972,
 0.028647063536752972,
 0.018040932375307846)

Checking the properties of an ODE system

The ODE class also defines a number of properties that describe the physical and systematic assumptions of the model:

[5]:
from few.trajectory.ode.base import get_ode_properties

get_ode_properties(rhs)
[5]:
{'convert_Y': False,
 'equatorial': True,
 'circular': False,
 'supports_ELQ': True,
 'background': 'Schwarzschild',
 'separatrix_buffer_dist': 0.1,
 'nparams': 6,
 'flux_output_convention': 'ELQ'}

Trajectory evaluation

The EMRIInspiral class

Trajectories are evaluated using the EMRIInspiral class, which interfaces with the underlying integrator classes and methods in order to integrate the trajectory from its initial conditions until either the maximum alloted duration elapses or pre-determined stopping conditions are satisfied.

The EMRIInspiral class offers a number of features that can be adjusted according to the desired output. We will demonstrate these below, working with the KerrEccEqFlux trajectory model as an example.

[6]:
from few.trajectory.inspiral import EMRIInspiral
from few.trajectory.ode import KerrEccEqFlux
[7]:
# You can also instantiate this as EMRIInspiral(func="KerrEccEqFlux") to save an import.
traj_model = EMRIInspiral(func=KerrEccEqFlux)
[8]:
M = 1e6
mu = 1e2
a = 0.9  # This model supports a spinning primary compact object
p0 = 10.0
e0 = 0.8
xI0 = 1.0  # +1 for prograde, -1 for retrograde inspirals

T = 4.0  # duration of trajectory in years (as defined by few.utils.constants.YRSID_SI)

traj_pars = [M, mu, a, p0, e0, xI0]
[9]:
import matplotlib.pyplot as plt
import numpy as np

t, p, e, xI, Phi_phi, Phi_theta, Phi_r = traj_model(*traj_pars, T=T)

fig, axes = plt.subplots(2, 3)
plt.subplots_adjust(wspace=0.35)
fig.set_size_inches(14, 8)
axes = axes.ravel()

ylabels = [r"$e$", r"$p$", r"$e$", r"$\Phi_\phi$", r"$\Phi_\theta$", r"$\Phi_r$"]
xlabels = [r"$p$", r"$t$", r"$t$", r"$t$", r"$t$", r"$t$"]
ys = [e, p, e, Phi_phi, Phi_theta, Phi_r]
xs = [p, t, t, t, t, t]

for i, (ax, x, y, xlab, ylab) in enumerate(zip(axes, xs, ys, xlabels, ylabels)):
    ax.plot(x, y)
    ax.set_xlabel(xlab, fontsize=16)
    ax.set_ylabel(ylab, fontsize=16)
../_images/tutorial_Trajectory_tutorial_24_0.png

Trajectory stopping conditions

In principle, the inspiral trajectory continues until \(p\) intersects with the separatrix, \(p_\mathrm{sep}(a, e, x_I)\). However, FEW truncates the inspiral at the buffer point \(p_\mathrm{stop} = p_\mathrm{sep} + \Delta p\) for numerical stability, performing a root-finding operation to place the last trajectory point within \(\sim 10^{-8}\) of \(p_\mathrm{stop}\).

The size of \(\Delta p\) can vary based on the trajectory model, and can be obtained directly from the ODE object:

[10]:
from few.utils.utility import get_separatrix

Delta_p = KerrEccEqFlux().separatrix_buffer_dist
print("Delta_p:", Delta_p)
p_sep = get_separatrix(a, e[-1], xI[-1])  # the separatrix at the trajectory end-point.
p[-1] - (p_sep + Delta_p)
Delta_p: 0.05
[10]:
1.9775479032091425e-09

Output on a requested time grid

In addition to the sparse output of the adaptive solver, the user can also construct trajectories on pre-defined time grids. For uniform grids, the sampling cadence \(\mathrm{d}t\) can be supplied and the grid will be constructed automatically. Non-uniform grids can be supplied via the new_t keyword argument. In either case, the fix_t keyword argument can be used to truncate the returned trajectory at its end-point.

[11]:
dt = 100.0
new_t = np.arange(0, 2e7, dt)

# a warning will be thrown if the new_t array goes beyond the time array output from the trajectory
t1, p1, e1, x1, Phi_phi1, Phi_theta1, Phi_r1 = traj_model(
    *traj_pars, T=T, new_t=new_t, upsample=True
)

# you can cut the excess on these arrays by setting fix_t to True
t2, p2, e2, x2, Phi_phi2, Phi_theta2, Phi_r2 = traj_model(
    *traj_pars, T=T, new_t=new_t, upsample=True, fix_t=True
)

plt.plot(t1, Phi_phi1)
plt.plot(t2, Phi_phi2, ls="--")

print("t1 max:", t1.max(), "t2 max:", t2.max())
t1 max: 19999900.0 t2 max: 17361000.0
../_images/tutorial_Trajectory_tutorial_30_1.png

Return trajectory in dimensionless time coordinates

Trajectories are returned in coordinate time by default, but may be obtained in dimensionless time units by passing is_coordinate_time=False:

[12]:
t, p, e, x, Phi_phi, Phi_theta, Phi_r = traj_model(*traj_pars, in_coordinate_time=False)

plt.plot(t, Phi_phi, label=r"$\Phi_\phi$")
plt.plot(t, Phi_r, label=r"$\Phi_r$")
plt.legend(frameon=False)
plt.show()
print("Dimensionless time step:", t[1] - t[0])
../_images/tutorial_Trajectory_tutorial_33_0.png
Dimensionless time step: 2.030254435033953

Integrating trajectories with a fixed time-step

The adaptive stepping of the solver can be disabled with the DENSE_STEPPING keyword argument, which switches to taking uniform steps of size dt. This is more expensive and memory-intensive than adaptive stepping.

[13]:
T_dense = 0.005

t_dense, p, e, x, Phi_phi, Phi_theta, Phi_r = traj_model(
    *traj_pars, T=T_dense, DENSE_STEPPING=1
)
t_adaptive, p, e, x, Phi_phi, Phi_theta, Phi_r = traj_model(*traj_pars, T=T_dense)
t_dense.shape, t_adaptive.shape
[13]:
((15780,), (8,))

We can directly verify the accuracy of the adaptive solution by evaluating its output on the densely-stepped trajectory’s time grid. The phase errors will be larger than the error in the orbital elements by a factor of \(M / \mu\) due to conventions chosen during integration:

[14]:
t_dense, p, e, x, Phi_phi, Phi_theta, Phi_r = traj_model(
    *traj_pars, T=T_dense, DENSE_STEPPING=1
)
t_adaptive, p_adaptive, e, x, Phi_phi_adaptive, Phi_theta, Phi_r = traj_model(
    *traj_pars, T=T_dense, new_t=t_dense, upsample=True
)

plt.semilogy(t_dense, abs(Phi_phi_adaptive - Phi_phi), label="Phase error")
plt.semilogy(t_dense, abs(p_adaptive - p), label="p error")
plt.legend(frameon=False)
plt.show()
../_images/tutorial_Trajectory_tutorial_38_0.png

Evolving trajectories backwards in time

Trajectories can also be evolved in reverse via the integrate_backwards keyword argument. Trajectories evolved in either direction will agree up to the numerical tolerance of the integrator:

[15]:
traj_pars_back = traj_pars.copy()

T = 0.55

# forward
t, p, e, xI, Phi_phi, Phi_theta, Phi_r = traj_model(*traj_pars, T=T)

traj_pars_back[3] = p[-1]
traj_pars_back[4] = e[-1]
traj_pars_back[5] = xI[-1]

# backward
t_back, p_back, e_back, xI_back, Phi_phi_back, Phi_theta_back, Phi_r_back = traj_model(
    *traj_pars_back, T=T, integrate_backwards=True
)

plt.plot(p, e)
plt.plot(p_back, e_back, ls="--")
p_back[-1] - p[0]
[15]:
-3.977270068844518e-09
../_images/tutorial_Trajectory_tutorial_41_1.png

Setting the integrator error tolerance

The integrator tolerance (which is an absolute tolerance) can be adjusted by setting the err keyword argument. Higher error tolerances reduce the number of points in the trajectory, but at the cost of numerical accuracy. Note that err is a local error tolerance, not a global one, and must therefore be set conservatively with respect to the desired global error tolerance. The default setting of \(10^{-11}\) is a reasonable value and should not need to be changed for typical scenarios.

[16]:
p = traj_model(*traj_pars, T=T, err=1e-11)[1]
p_hightol = traj_model(*traj_pars, T=T, err=1e-9)[1]

p.size, p_hightol.size, (p[-1] - p_hightol[-1]).item()
[16]:
(64, 39, -1.5885511306379385e-07)

Integrating trajectories in \((E, L, Q)\) coordinates

Trajectories can also be solved in the constants of motion parameterisation \((E, L, Q)\). This is supported for all stock trajectory models. Given input \((p_0, e_0, x_{I,0})\), the integrator converts these initial conditions to \((E, L, Q)\) and integrates these quantities alongside the inspiral phases. The output trajectory is converted back to \((p, e, x_I)\) coordinates if the keyword argument convert_pex=True (which it is by default).

Integrating in \((E, L, Q)\) can offer some advantages, especially near the separatrix (see Hughes 2024 for more information). Typically the trajectory also consists of fewer points than its \((p, e, x_I)\) counterpart, and is therefore faster to compute. However, it can also be numerically unstable for very small eccentricities, where small errors in the constants of motion can be amplified when transformed back to \((p, e, x_I)\). Usage of this method should be considered somewhat experimental, especially for eccentric and inclined (generic) inspirals.

[17]:
M = 1e6
mu = 1e2
a = 0.0
p0 = 10.0
e0 = 0.7
xI0 = 1.0
T = 1.0

traj_model_ELQ = EMRIInspiral(func=SchwarzEccFlux, integrate_constants_of_motion=True)
traj_model_pex = EMRIInspiral(func=SchwarzEccFlux, integrate_constants_of_motion=False)

fig, axes = plt.subplots(2, 3, figsize=(14, 8))
plt.subplots_adjust(wspace=0.35)
axes = axes.ravel()
ylabels = [r"$e$", r"$p$", r"$e$", r"$\Phi_\phi$", r"$\Phi_\theta$", r"$\Phi_r$"]
xlabels = [r"$p$", r"$t$", r"$t$", r"$t$", r"$t$", r"$t$"]

lens = []
phase_ends = []
for traj_model, label, ls in zip(
    [traj_model_ELQ, traj_model_pex], ["ELQ", "pex"], ["-", "--"]
):
    t, p, e, xI, Phi_phi, Phi_theta, Phi_r = traj_model(M, mu, a, p0, e0, xI0, T=T)

    # Plot the results for comparison
    ys = [e, p, e, Phi_phi, Phi_theta, Phi_r]
    xs = [p, t, t, t, t, t]

    for i, (ax, x, y, xlab, ylab) in enumerate(zip(axes, xs, ys, xlabels, ylabels)):
        ax.plot(x, y, label=label, ls=ls)
        ax.set_xlabel(xlab, fontsize=16)
        ax.set_ylabel(ylab, fontsize=16)

    lens.append(len(t))
    phase_ends.append(Phi_phi[-1])
axes[0].legend(frameon=False)

plt.show()

print("(ELQ, pex) trajectory lengths:", lens)
print("(ELQ, pex) phase difference:", phase_ends[1] - phase_ends[0])
../_images/tutorial_Trajectory_tutorial_47_0.png
(ELQ, pex) trajectory lengths: [34, 48]
(ELQ, pex) phase difference: 0.0005445422793854959

User-defined trajectory models

In addition to FEW’s built-in trajectory models, users can readily implement their own models in the same framework. They can achieve this by:

  • Subclassing the ODEBase base class,

  • Defining the evaluate_rhs method, and

  • Updating any relevant properties of the model (by default, they are set for generic Kerr inspirals).

You do not need to handle any boundary checking in your modified class (e.g., ensuring quantities remain physical) as this is performed automatically by the base class.

Let’s implement a Post-Newtonian trajectory in Schwarzschild eccentric as an example:

[18]:
# Some elliptic functions for evaluating geodesic frequencies
from few.utils.elliptic import EllipK, EllipE, EllipPi

# base classes
from few.trajectory.ode.base import ODEBase


# for common interface with C/mathematica
def Power(x, n):
    return x**n


def Sqrt(x):
    return np.sqrt(x)


# this class defines the right-hand side of the ODE
# we define the method "evaluate_rhs" according to our derivatives
# we set the "equatorial" and "background" properties accordingly
# we also set the "flux_output_convention" property to "pex" to tell the trajectory module what
# the RHS derivatives correspond to
class Schwarzschild_PN(ODEBase):
    @property
    def equatorial(self):
        return True

    @property
    def background(self):
        return "Schwarzschild"

    @property
    def flux_output_convention(self):
        return "pex"

    def evaluate_rhs(self, y):
        # guard against bad integration steps
        p, e, xI = y[:3]

        # perform elliptic calculations
        ellipE = EllipE(4 * e / (p - 6.0 + 2 * e))
        ellipK = EllipK(4 * e / (p - 6.0 + 2 * e))
        ellipPi1 = EllipPi(
            16 * e / (12.0 + 8 * e - 4 * e * e - 8 * p + p * p),
            4 * e / (p - 6.0 + 2 * e),
        )
        ellipPi2 = EllipPi(
            2 * e * (p - 4) / ((1.0 + e) * (p - 6.0 + 2 * e)), 4 * e / (p - 6.0 + 2 * e)
        )

        # Azimuthal frequency
        Omega_phi = (2 * Power(p, 1.5)) / (
            Sqrt(-4 * Power(e, 2) + Power(-2 + p, 2))
            * (
                8
                + (
                    (
                        -2
                        * ellipPi2
                        * (6 + 2 * e - p)
                        * (3 + Power(e, 2) - p)
                        * Power(p, 2)
                    )
                    / ((-1 + e) * Power(1 + e, 2))
                    - (ellipE * (-4 + p) * Power(p, 2) * (-6 + 2 * e + p))
                    / (-1 + Power(e, 2))
                    + (
                        ellipK
                        * Power(p, 2)
                        * (28 + 4 * Power(e, 2) - 12 * p + Power(p, 2))
                    )
                    / (-1 + Power(e, 2))
                    + (
                        4
                        * (-4 + p)
                        * p
                        * (2 * (1 + e) * ellipK + ellipPi2 * (-6 - 2 * e + p))
                    )
                    / (1 + e)
                    + 2
                    * Power(-4 + p, 2)
                    * (
                        ellipK * (-4 + p)
                        + (ellipPi1 * p * (-6 - 2 * e + p)) / (2 + 2 * e - p)
                    )
                )
                / (ellipK * Power(-4 + p, 2))
            )
        )

        # Post-Newtonian calculations
        yPN = pow(Omega_phi, 2.0 / 3.0)

        EdotPN = (
            (96 + 292 * Power(e, 2) + 37 * Power(e, 4))
            / (15.0 * Power(1 - Power(e, 2), 3.5))
            * pow(yPN, 5)
        )
        LdotPN = (
            (4 * (8 + 7 * Power(e, 2)))
            / (5.0 * Power(-1 + Power(e, 2), 2))
            * pow(yPN, 7.0 / 2.0)
        )

        # flux
        Edot = -EdotPN
        Ldot = -LdotPN

        # time derivatives
        pdot = (
            -2
            * (
                Edot
                * Sqrt((4 * Power(e, 2) - Power(-2 + p, 2)) / (3 + Power(e, 2) - p))
                * (3 + Power(e, 2) - p)
                * Power(p, 1.5)
                + Ldot * Power(-4 + p, 2) * Sqrt(-3 - Power(e, 2) + p)
            )
        ) / (4 * Power(e, 2) - Power(-6 + p, 2))

        edot = -(
            (
                Edot
                * Sqrt((4 * Power(e, 2) - Power(-2 + p, 2)) / (3 + Power(e, 2) - p))
                * Power(p, 1.5)
                * (
                    18
                    + 2 * Power(e, 4)
                    - 3 * Power(e, 2) * (-4 + p)
                    - 9 * p
                    + Power(p, 2)
                )
                + (-1 + Power(e, 2))
                * Ldot
                * Sqrt(-3 - Power(e, 2) + p)
                * (12 + 4 * Power(e, 2) - 8 * p + Power(p, 2))
            )
            / (e * (4 * Power(e, 2) - Power(-6 + p, 2)) * p)
        )

        xIdot = 0.0

        Phi_phi_dot = Omega_phi
        Phi_theta_dot = Omega_phi

        Phi_r_dot = (
            p * Sqrt((-6 + 2 * e + p) / (-4 * Power(e, 2) + Power(-2 + p, 2))) * np.pi
        ) / (
            8 * ellipK
            + (
                (-2 * ellipPi2 * (6 + 2 * e - p) * (3 + Power(e, 2) - p) * Power(p, 2))
                / ((-1 + e) * Power(1 + e, 2))
                - (ellipE * (-4 + p) * Power(p, 2) * (-6 + 2 * e + p))
                / (-1 + Power(e, 2))
                + (ellipK * Power(p, 2) * (28 + 4 * Power(e, 2) - 12 * p + Power(p, 2)))
                / (-1 + Power(e, 2))
                + (
                    4
                    * (-4 + p)
                    * p
                    * (2 * (1 + e) * ellipK + ellipPi2 * (-6 - 2 * e + p))
                )
                / (1 + e)
                + 2
                * Power(-4 + p, 2)
                * (
                    ellipK * (-4 + p)
                    + (ellipPi1 * p * (-6 - 2 * e + p)) / (2 + 2 * e - p)
                )
            )
            / Power(-4 + p, 2)
        )

        dydt = [pdot, edot, xIdot, Phi_phi_dot, Phi_theta_dot, Phi_r_dot]

        return dydt
[19]:
M = 1e6
mu = 1e2
p0 = 10.0
e0 = 0.7
T = 1.0

traj = EMRIInspiral(func=Schwarzschild_PN)

test = traj(M, mu, 0.0, p0, e0, 1.0, T=T, dt=10.0)

traj2 = EMRIInspiral(func=SchwarzEccFlux)

flux = traj2(M, mu, 0.0, p0, e0, 1.0, T=T, dt=10.0)

p = test[1]
e = test[2]

plt.plot(flux[1], flux[2], label="flux")
plt.plot(p, e, label="pn")
plt.ylabel("e")
plt.xlabel("p")

plt.legend()
plt.show()
../_images/tutorial_Trajectory_tutorial_51_0.png

Modifying an existing trajectory model

In cases where the desired trajectory model can be expressed as a function of one that is supported out-of-the-box, the inbuilt model can be subclassed and supplemented as required by defining the modify_rhs method. This method takes as input the current system state and the corresponding derivatives produced by the model, which can then be modified as required.

This method does not support a return argument for efficiency; the content of the first argument derivs should be modified in-place.

[20]:
from few.trajectory.ode.flux import KerrEccEqFlux


class ModifiedKerrEccEqFlux(KerrEccEqFlux):
    def modify_rhs(self, ydot, y):
        # in-place modification of the derivatives
        ydot[0] *= 1 + self.additional_args[0]
        # ydot[1] *= (1 + self.additional_args[0]**0.5)


M = 1e6
mu = 1e2
a = 0.7
p = 10.0
e = 0.3
x = 1.0

additional_args = [
    0.01,
]

# Example usage
modified_ode = ModifiedKerrEccEqFlux()
modified_ode.add_fixed_parameters(M, mu, a, additional_args)

modified_ode([p, e, x])
[20]:
array([-0.01162928, -0.00053601,  0.        ,  0.02761918,  0.02656476,
        0.02068311])

Passing additional arguments to a modified trajectory model

When calling a trajectory, the additional_args attribute of the ODE class will be populated with any positional arguments following \(x_I\):

[21]:
traj = EMRIInspiral(func=ModifiedKerrEccEqFlux)

test = traj(M, mu, a, p, e, x, 0.01, T=T, dt=10.0)

traj2 = EMRIInspiral(func=KerrEccEqFlux)

flux = traj2(M, mu, a, p, e, x, T=T, dt=10.0)

plt.plot(flux[1], flux[2], label="flux")
plt.plot(test[1], test[2], label="flux + mod", ls="--")
plt.ylabel("e")
plt.xlabel("p")

plt.legend()
plt.show()
../_images/tutorial_Trajectory_tutorial_57_0.png

The flux-based stock models perform interpolation to obtain the integral fluxes \(\dot{E}\), \(\dot{L}_{z}\), and then apply a Jacobian transformation in order to obtain \(\dot{p}\), \(\dot{e}\) if these are the variables of integration (and they are by default). In some cases, the user may wish to adjust \(\dot{E}\), \(\dot{L}_z\) directly if the modifications desired are more easily computed in this parameterisation. This can be achieved similarly to what was shown above, but with the modify_rhs_before_Jacobian method:

[22]:
# A physically-motivated example: dissipation of energy and angular momentum due to dynamical friction with an accretion disk
# Modify the angular momentum derivative according to Eq(2.2) of https://journals.aps.org/prx/pdf/10.1103/PhysRevX.13.021035
class KerrEccEqFluxAccretionDisk(KerrEccEqFlux):
    def modify_rhs(self, ydot, y):
        # in-place modification of the derivatives
        LdotAcc = (
            self.additional_args[0]
            * pow(y[0] / 10.0, self.additional_args[1])
            * 32.0
            / 5.0
            * pow(y[0], -7.0 / 2.0)
        )
        dL_dp = (
            -3 * pow(a, 3)
            + pow(a, 2) * (8 - 3 * y[0]) * np.sqrt(y[0])
            + (-6 + y[0]) * pow(y[0], 2.5)
            + 3 * a * y[0] * (-2 + 3 * y[0])
        ) / (2.0 * pow(2 * a + (-3 + y[0]) * np.sqrt(y[0]), 1.5) * pow(y[0], 1.75))
        # transform back to pdot from Ldot abd add GW contribution
        # ydot[0] = (1+ LdotAcc/dL_dp) * ydot[0]
        ydot[0] = ydot[0] + LdotAcc / dL_dp


M = 1e6
mu = 5e1
a = 0.9
p = 15.0
# assume circular orbits, for extension to eccentricity see https://arxiv.org/pdf/2411.03436
e = 0.0
x = 1.0
T = 4.0

traj = EMRIInspiral(
    func=KerrEccEqFluxAccretionDisk, integrate_constants_of_motion=False
)

test = traj(M, mu, a, p, e, x, 0.0, 8.0, T=T, dt=2.0)

traj2 = EMRIInspiral(func=KerrEccEqFlux, integrate_constants_of_motion=False)

flux = traj2(M, mu, a, p, e, x, T=T, dt=2.0)

plt.plot(flux[0] / 86400, flux[4], label="flux")
plt.plot(test[0] / 86400, test[4], label="flux + mod", ls="--")
plt.ylabel(r"$\Phi$")
plt.xlabel("t [days]")

plt.legend()
plt.show()
../_images/tutorial_Trajectory_tutorial_59_0.png
[23]:
# some nice italian-style plotting
!pip install pastamarkers
Requirement already satisfied: pastamarkers in /home/few/.local/few-venv/lib/python3.12/site-packages (0.1.0)
Requirement already satisfied: matplotlib<4.0.0,>=3.4.3 in /home/few/.local/few-venv/lib/python3.12/site-packages (from pastamarkers) (3.10.0)
Requirement already satisfied: numpy<2.0.0,>=1.21.2 in /home/few/.local/few-venv/lib/python3.12/site-packages (from pastamarkers) (1.26.4)
Requirement already satisfied: contourpy>=1.0.1 in /home/few/.local/few-venv/lib/python3.12/site-packages (from matplotlib<4.0.0,>=3.4.3->pastamarkers) (1.3.1)
Requirement already satisfied: cycler>=0.10 in /home/few/.local/few-venv/lib/python3.12/site-packages (from matplotlib<4.0.0,>=3.4.3->pastamarkers) (0.12.1)
Requirement already satisfied: fonttools>=4.22.0 in /home/few/.local/few-venv/lib/python3.12/site-packages (from matplotlib<4.0.0,>=3.4.3->pastamarkers) (4.56.0)
Requirement already satisfied: kiwisolver>=1.3.1 in /home/few/.local/few-venv/lib/python3.12/site-packages (from matplotlib<4.0.0,>=3.4.3->pastamarkers) (1.4.8)
Requirement already satisfied: packaging>=20.0 in /home/few/.local/few-venv/lib/python3.12/site-packages (from matplotlib<4.0.0,>=3.4.3->pastamarkers) (24.2)
Requirement already satisfied: pillow>=8 in /home/few/.local/few-venv/lib/python3.12/site-packages (from matplotlib<4.0.0,>=3.4.3->pastamarkers) (11.1.0)
Requirement already satisfied: pyparsing>=2.3.1 in /home/few/.local/few-venv/lib/python3.12/site-packages (from matplotlib<4.0.0,>=3.4.3->pastamarkers) (3.2.1)
Requirement already satisfied: python-dateutil>=2.7 in /home/few/.local/few-venv/lib/python3.12/site-packages (from matplotlib<4.0.0,>=3.4.3->pastamarkers) (2.9.0.post0)
Requirement already satisfied: six>=1.5 in /home/few/.local/few-venv/lib/python3.12/site-packages (from python-dateutil>=2.7->matplotlib<4.0.0,>=3.4.3->pastamarkers) (1.17.0)
[24]:
# interpolate and compare the two trajectories
from scipy.interpolate import interp1d
from few.utils.utility import get_p_at_t
from few.utils.constants import YRSID_SI
from pastamarkers import markers
import time

traj_args = [M, mu, a, e0, 1.0]
traj_kwargs = {}
index_of_p = 3
t_out = 1.5
results = {"orange": [], "blue": []}
for A, n, marker, col in zip(
    [0.4e-5, 0.1e-5],
    [59 / 10, 8.0],
    [markers.tortellini, markers.ravioli],
    ["orange", "blue"],
):
    for t_out in [2.0, 3.0, 4.0, 5.0, 6.0]:
        # obtain the initial p value for the desired trajectory duration, see below for futher explanations
        p_new = get_p_at_t(
            traj2,
            t_out,
            traj_args,
            index_of_p=3,
            index_of_a=2,
            index_of_e=4,
            index_of_x=5,
            traj_kwargs={},
            xtol=2e-12,
            rtol=8.881784197001252e-16,
        )
        print(
            "p0 = {} will create a waveform that is {} years long, given the other input parameters.".format(
                p_new, t_out
            )
        )

        print(A)

        T = 10.0
        # T = t_out
        tic = time.time()
        test = traj(M, mu, a, p_new, e, x, A, 8.0, T=T, dt=10.0)
        toc = time.time()
        print("Time taken for trajectory:", toc - tic)
        tic = time.time()
        flux = traj2(M, mu, a, p_new, e, x, T=T, dt=10.0)
        toc = time.time()
        print("Time taken for flux:", toc - tic)

        interp_flux = interp1d(flux[0], flux[4])
        interp_test = interp1d(test[0], test[4])

        # t = np.arange(len(flux[0]))*dt
        if t_out == 4.0:
            label = f"A={A}, n={n}"
        else:
            label = None

        # use the shortest trajectory as the reference
        t_f = flux[0][-1]
        delta_phi = interp_flux(t_f) - interp_test(t_f)
        results[col].append((t_f / YRSID_SI, delta_phi, marker, label, col, toc - tic))

for A, n, marker, col in zip(
    [0.4e-5, 0.1e-5],
    [59 / 10, 8.0],
    [markers.tortellini, markers.ravioli],
    ["orange", "blue"],
):
    results[col] = np.array(results[col])
p0 = 12.141066507022591 will create a waveform that is 2.0 years long, given the other input parameters.
4e-06
Time taken for trajectory: 0.03345513343811035
Time taken for flux: 0.02951502799987793
p0 = 13.449285254725707 will create a waveform that is 3.0 years long, given the other input parameters.
4e-06
Time taken for trajectory: 0.034697771072387695
Time taken for flux: 0.029799699783325195
p0 = 14.462412588355715 will create a waveform that is 4.0 years long, given the other input parameters.
4e-06
Time taken for trajectory: 0.032881736755371094
Time taken for flux: 0.029918193817138672
p0 = 15.300552512965773 will create a waveform that is 5.0 years long, given the other input parameters.
4e-06
Time taken for trajectory: 0.03376007080078125
Time taken for flux: 0.029978513717651367
p0 = 16.02124072505033 will create a waveform that is 6.0 years long, given the other input parameters.
4e-06
Time taken for trajectory: 0.041049957275390625
Time taken for flux: 0.029675722122192383
p0 = 12.141066507022591 will create a waveform that is 2.0 years long, given the other input parameters.
1e-06
Time taken for trajectory: 0.0329432487487793
Time taken for flux: 0.028829574584960938
p0 = 13.449285254725707 will create a waveform that is 3.0 years long, given the other input parameters.
1e-06
Time taken for trajectory: 0.03320956230163574
Time taken for flux: 0.03012561798095703
p0 = 14.462412588355715 will create a waveform that is 4.0 years long, given the other input parameters.
1e-06
Time taken for trajectory: 0.036415815353393555
Time taken for flux: 0.03203415870666504
p0 = 15.300552512965773 will create a waveform that is 5.0 years long, given the other input parameters.
1e-06
Time taken for trajectory: 0.03495359420776367
Time taken for flux: 0.030391454696655273
p0 = 16.02124072505033 will create a waveform that is 6.0 years long, given the other input parameters.
1e-06
Time taken for trajectory: 0.036464691162109375
Time taken for flux: 0.030130863189697266
[25]:
plt.title("Dephasing vs. time to plunge $t_p$ for different accretion disk parameters")
plt.scatter(
    results["orange"][:, 0],
    results["orange"][:, 1],
    color="orange",
    marker=markers.tortellini,
    label=results["orange"][:, 3][2],
    s=200,
    linewidth=0.2,
)
plt.scatter(
    results["blue"][:, 0],
    results["blue"][:, 1],
    color="blue",
    marker=markers.ravioli,
    label=results["blue"][:, 3][2],
    s=200,
    linewidth=0.2,
)
# plt.ylim(1e-3, 1e6)
plt.ylabel(r"$\Delta \Phi_{\Phi}$")
plt.xlabel("$t_p$ [years]")
plt.grid()
# import values from https://journals.aps.org/prx/pdf/10.1103/PhysRevX.13.021035 fig 1
plt.scatter(
    4,
    1e2,
    marker=markers.farfalle,
    label="PhysRevX.13.021035",
    s=400,
    linewidth=0.2,
)
plt.legend()
plt.show()
../_images/tutorial_Trajectory_tutorial_62_0.png

The dephasing \(\Delta \Phi_{\Phi}\) obtained from the difference between the final orbital phase of an evolution with and without migration torques in \(\alpha-\) or \(\beta-\) accretion disks. For \(\alpha-\)disks, the slope is expected to be \(n=8\), for \(\beta-\) disks, \(n=59/10\). The obtained value in previous work for \(t_p=4 yr\) is shown in the Figure as a tortellino (see fig 1 of PhysRevX.13.021035)

[26]:
plt.scatter(
    results["orange"][:, 0],
    results["orange"][:, -1],
    color="orange",
    marker=markers.tortellini,
    label=results["orange"][:, 3][2],
    s=200,
    linewidth=0.2,
)
plt.scatter(
    results["blue"][:, 0],
    results["blue"][:, -1],
    color="blue",
    marker=markers.ravioli,
    label=results["blue"][:, 3][2],
    s=200,
    linewidth=0.2,
)
# plt.ylim(1e-3, 1e6)
plt.ylabel("Timing [s]")
plt.xlabel("$t_p$ [years]")
plt.grid()
# import values from https://journals.aps.org/prx/pdf/10.1103/PhysRevX.13.021035 fig 1
plt.legend()
plt.show()
../_images/tutorial_Trajectory_tutorial_64_0.png

User-defined trajectories can be readily implemented in waveform models in the FEW framework - see TODO.