"""
Integration methods for Pisces.
This module provides numerical integration routines for computing
enclosed mass profiles and cumulative integrals of functions defined over radial profiles.
These methods are useful for astrophysical applications where analytical solutions
are not feasible or practical.
"""
import numpy as np
from scipy.integrate import quad, solve_ivp
[docs]
def integrate_mass(profile, rr, **kwargs):
r"""
Compute enclosed mass profile via numerical integration.
For a density profile :math:`\rho(r)`, this evaluates:
.. math::
M(r) = 4 \pi \int_0^r \rho(r') \; r'^2 \; dr'
Parameters
----------
profile : callable
Function of radius :math:`r` returning the density :math:`\rho(r)`
as a scalar or array.
rr : array-like
Radii at which to compute enclosed mass. Must be ascending and positive.
**kwargs : dict, optional
Additional keyword arguments passed to :func:`scipy.integrate.quad`.
Returns
-------
mass : ndarray
Array of enclosed mass values at each radius.
"""
rr = np.atleast_1d(rr)
mass = np.zeros_like(rr)
def _mass_integrand(r_):
return profile(r_) * r_**2
for i, r in enumerate(rr):
mass[i] = quad(_mass_integrand, 0, r, **kwargs)[0]
return 4 * np.pi * mass
[docs]
def integrate(profile, rr, rmax=None, **kwargs):
r"""
Compute cumulative integral from each radius :math:`r` to :math:`r_{\mathrm{max}}`.
Evaluates:
.. math::
I(r) = \int_{r}^{r_{\mathrm{max}}} f(r') \; dr'
Parameters
----------
profile : callable
Function of radius :math:`r` returning the integrand :math:`f(r)`.
rr : array-like
Radii at which to compute cumulative integrals. Must be sorted ascending.
rmax : float or None, optional
Upper bound of integration. If None, defaults to ``rr[-1]``.
**kwargs : dict, optional
Additional keyword arguments passed to :func:`scipy.integrate.quad`.
Returns
-------
integral : ndarray
Array of integral values :math:`I(r)` for each radius in ``rr``.
Raises
------
ValueError
If any radius in ``rr`` exceeds ``rmax``.
"""
rr = np.atleast_1d(rr)
rmax = rr[-1] if rmax is None else rmax
if not np.all(rr <= rmax):
raise ValueError("All input radii rr must lie within [min(rr), rmax].")
integral = np.zeros_like(rr)
for i, r in enumerate(rr):
integral[i] = quad(profile, r, rmax, **kwargs)[0]
return integral
[docs]
def integrate_toinf(profile, rr, rmax=None, **kwargs):
r"""
Compute cumulative integral from each radius :math:`r` to infinity.
Evaluates:
.. math::
I(r) = \int_{r}^{\infty} f(r') \; dr'
The integration is performed in two parts:
1. From :math:`r` to :math:`r_{\mathrm{max}}`.
2. From :math:`r_{\mathrm{max}}` to :math:`\infty` (computed once and added to all results).
Parameters
----------
profile : callable
Function of radius :math:`r` returning the integrand :math:`f(r)`.
rr : array-like
Radii at which to compute cumulative integrals. Must be sorted ascending.
rmax : float or None, optional
Upper bound for intermediate integration. If None, defaults to ``rr[-1]``.
**kwargs : dict, optional
Additional keyword arguments passed to :func:`scipy.integrate.quad`.
Returns
-------
integral : ndarray
Array of integral values :math:`I(r)` for each radius in ``rr``.
Raises
------
ValueError
If any radius in ``rr`` exceeds ``rmax``.
"""
rr = np.atleast_1d(rr)
rmax = rr[-1] if rmax is None else rmax
if not np.all(rr <= rmax):
raise ValueError("All input radii rr must lie within [min(rr), rmax].")
integral = np.zeros_like(rr)
for i, r in enumerate(rr):
integral[i] = quad(profile, r, rmax, **kwargs)[0]
remainder = quad(profile, rmax, np.inf, **kwargs)[0]
integral += remainder
return integral
[docs]
def compute_lame_emden_solution(
xi_max: float, n: float = 1, xi_min: float = 1e-5, end_at_first_zero: bool = False, **kwargs
):
r"""
Compute a numerical solution to the Lane-Emden equation for a given polytropic index :math:`n`.
The Lane-Emden equation models the structure of polytropic spheres such as stars, and is given by:
.. math::
\frac{1}{\xi^2} \frac{d}{d\xi} \left( \xi^2 \frac{d\theta}{d\xi} \right) + \theta^n = 0,
with boundary conditions:
.. math::
\theta(0) = 1, \quad \theta'(0) = 0.
Because the equation is singular at :math:`\xi = 0`, we begin integration at a small nonzero value
:math:`\xi_{\rm min}` using a Taylor expansion to approximate the initial conditions.
Parameters
----------
xi_max : float
Maximum value of the dimensionless radial coordinate :math:`\xi` to integrate to. Depending on the
index and the value of the `end_at_first_zero` flag, this may not be reached if the solution crosses zero.
If the solution does not trigger a termination event, the integration will continue to this value.
In general, it is best to set this well beyond the expected extent of the solution, such as 10 or 20.
n : float, optional
Polytropic index. Default is 1.
xi_min : float, optional
Starting value of :math:`\xi` to avoid the singularity at zero. Default is ``1e-5``.
end_at_first_zero : bool, optional
If True, halt integration when :math:`\theta` first crosses below zero. This is overruled if `n` is
a non-integer, in which case the integration will always truncate to avoid non-real solutions.
**kwargs
Additional keyword arguments passed to :func:`scipy.integrate.solve_ivp`. This can be used to
control the integration method, tolerances, and other parameters.
Returns
-------
solution : scipy.integrate.OdeResult
Full solution object from :func:`scipy.integrate.solve_ivp`. Includes:
- ``solution.t``: array of :math:`\xi` values
- ``solution.y``: 2D array of corresponding :math:`\theta(\xi)` and auxiliary variables
- ``solution.status``: solver status code (0=success, 1=event-triggered)
- ``solution.t_events``: event times, if any
- ``solution.message``: diagnostic message
Notes
-----
For small :math:`\xi`, we approximate:
.. math::
\theta(\xi) \approx 1 - \frac{1}{6} \xi^2, \quad \theta'(\xi) \approx -\frac{1}{3} \xi,
which are valid for small deviations from the origin. These values are used to initialize the solver
at :math:`\xi = \xi_{\rm min}`.
Additionally, when ``end_at_first_zero`` is True, or when `n` is not an integer, we define an event function
that triggers when :math:`\theta` crosses below a small threshold (e.g., 0.01). This allows the integration
to stop gracefully when the solution is no longer valid, avoiding numerical instability.
Examples
--------
.. code-block:: python
from pisces.math_utils.integration import (
compute_lame_emden_solution,
)
xi, theta = compute_lame_emden_solution(
xi_max=10.0,
n=1.5,
end_at_first_zero=True,
max_step=0.1,
)
"""
# Define the RHS of the ODE using our formalism.
def _lame_emden_rhs(xi, y):
theta, psi = y
dtheta_dxi = psi / xi**2
dpsi_dxi = -(theta**n) * xi**2
return [dtheta_dxi, dpsi_dxi]
# Construct the initial values. We need to avoid the singularity at xi=0,
# so we start at a small positive value xi_min. Taylor expansion is used
# to propagate.
theta_approx = 1 - (1 / 6) * xi_min**2
dtheta_dxi_approx = -(1 / 3) * xi_min
psi_approx = xi_min**2 * dtheta_dxi_approx
y0 = [theta_approx, psi_approx]
# For non-integer n, we need to catch instances where we start getting
# close to theta = 0 because we will not have stability in that case.
# We use an event in the integrator to achieve this.
if not float(n).is_integer() or end_at_first_zero:
def _theta_crossing_event_callable(xi, y):
return y[0] - 1e-2
_theta_crossing_event_callable.terminal = True
_theta_crossing_event_callable.direction = -1
events = [_theta_crossing_event_callable]
else:
events = None
# Solve the ODE
result = solve_ivp(_lame_emden_rhs, (xi_min, xi_max), y0, events=events, **kwargs)
# Check for failure in the solver and then
# return the correct result.
if result.status not in [0, 1]:
raise RuntimeError(f"ODE solver failed with status {result.status}: {result.message}")
return result