Source code for physicskit.relativity.chapters.lensing

"""Point-source gravitational lensing: Einstein rings and multiple images.

When a point source, a point (or compact) lens, and an observer are exactly
aligned, the source's light is bent symmetrically around the lens into a
complete ring -- an Einstein ring, predicted by Einstein in 1936 (though he
believed it would never be observable) and first imaged for a galaxy-scale
lens in 1988. For any other alignment, a point lens splits a single source
into exactly two images, one on each side of the lens, with a combined
brightness that can be dramatically magnified -- the basis of gravitational
microlensing surveys used to detect exoplanets and dark, compact objects.
This module solves the thin-lens equation analytically in the weak-field
(point-mass) limit, and provides the exact (numerically integrated)
deflection angle for exploring strong-field corrections near the photon
sphere.
"""

from __future__ import annotations

import numpy as np

__all__ = ["PointMassLens", "exact_deflection_angle"]


[docs] class PointMassLens: """A point-mass (weak-field) gravitational lens: Einstein ring, images, and magnification. Uses the thin-lens approximation with the leading-order deflection angle :math:`\\hat\\alpha(b) = 4M/b`, valid whenever the source is not aligned so closely that its image angle approaches the photon sphere (see :func:`exact_deflection_angle` for the strong-field case). Parameters ---------- M : float Lens mass, in geometrized units. D_L : float Observer-lens distance, in the same geometrized length units as ``M``. D_S : float Observer-source distance (:math:`D_S > D_L`), in the same units as ``M``. Notes ----- Every formula here is a ratio of ``M``, ``D_L``, ``D_S`` (angles and magnifications, not an absolute strain), so it is scale-invariant: a normalized convention (``M=1``, distances "in units of M") gives exactly the same angles as real SI-consistent geometrized values would. Unlike :class:`~physicskit.relativity.chapters.gw_merger.BinaryMerger`, there is no absolute-scale trap here -- any self-consistent unit choice works. Attributes ---------- D_LS : float Lens-source distance, :math:`D_S - D_L` (flat-space subtraction; this is a local, non-cosmological thin-lens treatment). """ def __init__(self, M, D_L, D_S): if D_S <= D_L: raise ValueError("D_S must exceed D_L (the source must be behind the lens)") self.M = M self.D_L = D_L self.D_S = D_S self.D_LS = D_S - D_L
[docs] def einstein_angle(self): """Einstein ring angular radius, :math:`\\theta_E = \\sqrt{4M D_{LS}/(D_L D_S)}`. The angular radius at which a perfectly aligned (:math:`\\beta=0`) point source appears as a complete ring. Returns ------- float """ return np.sqrt(4.0 * self.M * self.D_LS / (self.D_L * self.D_S))
[docs] def image_angles(self, beta): """Angular positions of the two images of a source at angular offset ``beta``. Solves the thin-lens equation :math:`\\beta = \\theta - \\theta_E^2/\\theta` (equivalent to a quadratic in :math:`\\theta`) exactly: .. math:: \\theta_\\pm = \\frac{\\beta \\pm \\sqrt{\\beta^2 + 4\\theta_E^2}}{2} Parameters ---------- beta : float True (unlensed) angular offset of the source from the lens. Returns ------- theta_plus : float Position of the primary image, on the same side as the source (:math:`\\theta_+ > 0`). theta_minus : float Position of the secondary image, on the opposite side (:math:`\\theta_- < 0`), closer to the lens and fainter. Examples -------- >>> lens = PointMassLens(M=1.0, D_L=1000.0, D_S=2000.0) >>> theta_plus, theta_minus = lens.image_angles(beta=0.0) >>> bool(np.isclose(theta_plus, lens.einstein_angle())) True >>> bool(np.isclose(theta_minus, -lens.einstein_angle())) True """ theta_E = self.einstein_angle() discriminant = np.sqrt(beta**2 + 4.0 * theta_E**2) theta_plus = (beta + discriminant) / 2.0 theta_minus = (beta - discriminant) / 2.0 return theta_plus, theta_minus
[docs] def magnification(self, beta): """Magnification of each image, and their sum (total observed brightening). Standard point-lens (Paczynski 1986) result, with :math:`u = \\beta/\\theta_E`: .. math:: \\mu_\\pm = \\frac{1}{2}\\left[\\frac{u^2+2}{u\\sqrt{u^2+4}} \\pm 1\\right] Parameters ---------- beta : float True angular offset of the source from the lens (``beta != 0``). Returns ------- mu_plus : float Magnification of the primary image (always :math:`> 1`). mu_minus : float Magnification of the secondary image (its absolute value is :math:`< 1`; it has opposite parity, conventionally signed negative). total : float Total observed magnification, :math:`|\\mu_+| + |\\mu_-|`. """ theta_E = self.einstein_angle() u = beta / theta_E base = (u**2 + 2.0) / (u * np.sqrt(u**2 + 4.0)) mu_plus = 0.5 * (base + 1.0) mu_minus = 0.5 * (base - 1.0) return mu_plus, mu_minus, abs(mu_plus) + abs(mu_minus)
def exact_deflection_angle(bh, impact_parameter, r_far=2.0e5, dtau=None, n_steps=300000): """Exact (numerically integrated) Schwarzschild light deflection angle. Unlike the weak-field :math:`4M/b` formula used by :class:`PointMassLens`, this integrates the actual null geodesic from far away, through closest approach, and back out to far away, and measures the total bending directly -- valid arbitrarily close to the photon sphere (where the deflection formally diverges as :math:`b \\to b_c = 3\\sqrt{3}M`). Parameters ---------- bh : SchwarzschildBlackHole The lensing black hole. impact_parameter : float Impact parameter :math:`b`; must exceed ``bh.critical_impact_parameter`` for the photon to escape. r_far : float, default=2e5 Starting (and target ending) radius, used to approximate "infinity." dtau : float, optional Affine-parameter step size. Defaults to ``3 * r_far / n_steps``, which comfortably covers the round trip to and from ``r_far`` while still resolving the near-photon-sphere region for impact parameters not too close to critical. n_steps : int, default=300000 Maximum integration steps. Returns ------- float The deflection angle, in radians. Examples -------- >>> from physicskit.relativity.chapters.schwarzschild import SchwarzschildBlackHole >>> bh = SchwarzschildBlackHole(M=1.0) >>> delta = exact_deflection_angle(bh, impact_parameter=50.0) >>> bool(abs(delta - bh.light_deflection_angle(50.0)) < 0.01) True """ if dtau is None: dtau = 3.0 * r_far / n_steps y0 = bh.null_geodesic_initial_state(r0=r_far, impact_parameter=impact_parameter, ingoing=True) traj = bh.integrate_geodesic(y0, dtau=dtau, n_steps=n_steps) return (traj["phi"][-1] - traj["phi"][0]) - np.pi