|
| 1 | +from typing import Tuple |
| 2 | + |
| 3 | +import numpy as np |
| 4 | + |
| 5 | +import autoarray as aa |
| 6 | + |
| 7 | +from autogalaxy.profiles.mass.abstract.abstract import MassProfile |
| 8 | + |
| 9 | + |
| 10 | +class ExternalPotential(MassProfile): |
| 11 | + def __init__( |
| 12 | + self, |
| 13 | + centre: Tuple[float, float] = (0.0, 0.0), |
| 14 | + gamma_1: float = 0.0, |
| 15 | + gamma_2: float = 0.0, |
| 16 | + tau_1: float = 0.0, |
| 17 | + tau_2: float = 0.0, |
| 18 | + delta_1: float = 0.0, |
| 19 | + delta_2: float = 0.0, |
| 20 | + ): |
| 21 | + r""" |
| 22 | + A line-of-sight external potential that generalises the constant external shear used in |
| 23 | + strong-lens models by adding the two next-order terms from Powell et al. 2022 (Eq. 4): |
| 24 | +
|
| 25 | + .. math:: |
| 26 | +
|
| 27 | + \psi(\mathbf{r}) = |
| 28 | + \tfrac{1}{2}\, r^2 \big(\gamma_1 \cos 2\theta + \gamma_2 \sin 2\theta\big) |
| 29 | + + \tfrac{1}{4}\, r^3 \big(\tau_1 \cos\theta + \tau_2 \sin\theta\big) |
| 30 | + + \tfrac{1}{6}\, r^3 \big(\delta_1 \cos 3\theta + \delta_2 \sin 3\theta\big) |
| 31 | +
|
| 32 | + where :math:`(r, \theta)` are polar coordinates centred on the profile's ``centre``. |
| 33 | +
|
| 34 | + Term-by-term: |
| 35 | +
|
| 36 | + - :math:`\gamma_1, \gamma_2` — the constant external shear contribution. With |
| 37 | + :math:`\tau_i = \delta_i = 0` and ``centre = (0, 0)`` this reduces exactly to |
| 38 | + :class:`ExternalShear`. |
| 39 | + - :math:`\tau_1, \tau_2` — a linear gradient in the surface mass density (spin-1), giving a |
| 40 | + non-zero convergence :math:`\kappa(x, y) = \tau_1 x + \tau_2 y`. |
| 41 | + - :math:`\delta_1, \delta_2` — a higher-order spin-3 generalised-shear term. |
| 42 | +
|
| 43 | + Unlike :class:`ExternalShear`, where the deflection field is a constant in the lens plane |
| 44 | + and the source position is degenerate with ``centre``, the :math:`\tau` and :math:`\delta` |
| 45 | + deflections have explicit radial dependence — so ``centre`` is a free parameter (typically |
| 46 | + tied to the primary lens centre when modelling). |
| 47 | +
|
| 48 | + Parameters |
| 49 | + ---------- |
| 50 | + centre |
| 51 | + The (y, x) arc-second coordinates of the profile centre. |
| 52 | + gamma_1, gamma_2 |
| 53 | + The two components of the constant external shear (spin-2). |
| 54 | + tau_1, tau_2 |
| 55 | + The two components of the linear surface-mass-density gradient (spin-1). |
| 56 | + delta_1, delta_2 |
| 57 | + The two components of the higher-order spin-3 generalised shear. |
| 58 | + """ |
| 59 | + |
| 60 | + super().__init__(centre=centre, ell_comps=(0.0, 0.0)) |
| 61 | + self.gamma_1 = gamma_1 |
| 62 | + self.gamma_2 = gamma_2 |
| 63 | + self.tau_1 = tau_1 |
| 64 | + self.tau_2 = tau_2 |
| 65 | + self.delta_1 = delta_1 |
| 66 | + self.delta_2 = delta_2 |
| 67 | + |
| 68 | + @staticmethod |
| 69 | + def _magnitude_from(c1, c2, xp=np): |
| 70 | + return xp.sqrt(c1 * c1 + c2 * c2) |
| 71 | + |
| 72 | + @staticmethod |
| 73 | + def _angle_from(c1, c2, harmonic: int, xp=np): |
| 74 | + r""" |
| 75 | + Return the principal angle in degrees for a harmonic of order ``harmonic``. |
| 76 | +
|
| 77 | + - ``harmonic = 1`` -> angle in [0, 360) |
| 78 | + - ``harmonic = 2`` -> angle in [0, 180) (shear convention) |
| 79 | + - ``harmonic = 3`` -> angle in [0, 120) |
| 80 | + """ |
| 81 | + angle = xp.rad2deg(xp.arctan2(c2, c1)) / harmonic |
| 82 | + period = 360.0 / harmonic |
| 83 | + return angle % period |
| 84 | + |
| 85 | + @classmethod |
| 86 | + def from_magnitudes_and_angles( |
| 87 | + cls, |
| 88 | + centre: Tuple[float, float] = (0.0, 0.0), |
| 89 | + gamma: float = 0.0, |
| 90 | + theta_gamma: float = 0.0, |
| 91 | + tau: float = 0.0, |
| 92 | + theta_tau: float = 0.0, |
| 93 | + delta: float = 0.0, |
| 94 | + theta_delta: float = 0.0, |
| 95 | + ): |
| 96 | + r""" |
| 97 | + Build an :class:`ExternalPotential` from per-term magnitudes and position angles, matching |
| 98 | + the paper-style parameterisation. Angles are in degrees, anticlockwise from the +x axis. |
| 99 | + """ |
| 100 | + tg = np.deg2rad(theta_gamma) |
| 101 | + tt = np.deg2rad(theta_tau) |
| 102 | + td = np.deg2rad(theta_delta) |
| 103 | + |
| 104 | + gamma_1 = gamma * np.cos(2.0 * tg) |
| 105 | + gamma_2 = gamma * np.sin(2.0 * tg) |
| 106 | + tau_1 = tau * np.cos(tt) |
| 107 | + tau_2 = tau * np.sin(tt) |
| 108 | + delta_1 = delta * np.cos(3.0 * td) |
| 109 | + delta_2 = delta * np.sin(3.0 * td) |
| 110 | + |
| 111 | + return cls( |
| 112 | + centre=centre, |
| 113 | + gamma_1=gamma_1, |
| 114 | + gamma_2=gamma_2, |
| 115 | + tau_1=tau_1, |
| 116 | + tau_2=tau_2, |
| 117 | + delta_1=delta_1, |
| 118 | + delta_2=delta_2, |
| 119 | + ) |
| 120 | + |
| 121 | + def gamma_magnitude(self, xp=np): |
| 122 | + return self._magnitude_from(self.gamma_1, self.gamma_2, xp=xp) |
| 123 | + |
| 124 | + def gamma_angle(self, xp=np): |
| 125 | + return self._angle_from(self.gamma_1, self.gamma_2, harmonic=2, xp=xp) |
| 126 | + |
| 127 | + def tau_magnitude(self, xp=np): |
| 128 | + return self._magnitude_from(self.tau_1, self.tau_2, xp=xp) |
| 129 | + |
| 130 | + def tau_angle(self, xp=np): |
| 131 | + return self._angle_from(self.tau_1, self.tau_2, harmonic=1, xp=xp) |
| 132 | + |
| 133 | + def delta_magnitude(self, xp=np): |
| 134 | + return self._magnitude_from(self.delta_1, self.delta_2, xp=xp) |
| 135 | + |
| 136 | + def delta_angle(self, xp=np): |
| 137 | + return self._angle_from(self.delta_1, self.delta_2, harmonic=3, xp=xp) |
| 138 | + |
| 139 | + def convergence_func(self, grid_radius: float) -> float: |
| 140 | + return 0.0 |
| 141 | + |
| 142 | + def average_convergence_of_1_radius(self): |
| 143 | + return 0.0 |
| 144 | + |
| 145 | + @aa.decorators.to_array |
| 146 | + @aa.decorators.transform |
| 147 | + def convergence_2d_from(self, grid: aa.type.Grid2DLike, xp=np, **kwargs): |
| 148 | + r""" |
| 149 | + Returns the convergence :math:`\kappa = \tfrac{1}{2}\nabla^2 \psi` at each grid point. |
| 150 | +
|
| 151 | + Only the :math:`\tau` term contributes; the :math:`\gamma` (spin-2 shear) and |
| 152 | + :math:`\delta` (spin-3) terms are harmonic and yield zero convergence: |
| 153 | +
|
| 154 | + .. math:: |
| 155 | +
|
| 156 | + \kappa(x, y) = \tau_1 \, x + \tau_2 \, y |
| 157 | +
|
| 158 | + where :math:`(x, y)` are coordinates relative to ``centre``. |
| 159 | + """ |
| 160 | + y = grid.array[:, 0] |
| 161 | + x = grid.array[:, 1] |
| 162 | + return self.tau_1 * x + self.tau_2 * y |
| 163 | + |
| 164 | + @aa.decorators.to_array |
| 165 | + @aa.decorators.transform |
| 166 | + def potential_2d_from(self, grid: aa.type.Grid2DLike, xp=np, **kwargs): |
| 167 | + r""" |
| 168 | + Returns the lensing potential of the external potential at each grid point, following |
| 169 | + Powell et al. 2022 Eq. 4. |
| 170 | + """ |
| 171 | + y = grid.array[:, 0] |
| 172 | + x = grid.array[:, 1] |
| 173 | + r2 = x * x + y * y |
| 174 | + r = xp.sqrt(r2) |
| 175 | + theta = xp.arctan2(y, x) |
| 176 | + |
| 177 | + gamma_term = 0.5 * r2 * ( |
| 178 | + self.gamma_1 * xp.cos(2.0 * theta) + self.gamma_2 * xp.sin(2.0 * theta) |
| 179 | + ) |
| 180 | + tau_term = 0.25 * r2 * r * ( |
| 181 | + self.tau_1 * xp.cos(theta) + self.tau_2 * xp.sin(theta) |
| 182 | + ) |
| 183 | + delta_term = (1.0 / 6.0) * r2 * r * ( |
| 184 | + self.delta_1 * xp.cos(3.0 * theta) + self.delta_2 * xp.sin(3.0 * theta) |
| 185 | + ) |
| 186 | + |
| 187 | + return gamma_term + tau_term + delta_term |
| 188 | + |
| 189 | + @aa.decorators.to_vector_yx |
| 190 | + @aa.decorators.transform |
| 191 | + def deflections_yx_2d_from(self, grid: aa.type.Grid2DLike, xp=np, **kwargs): |
| 192 | + r""" |
| 193 | + Returns the deflection vector :math:`\boldsymbol{\alpha} = \nabla \psi` at each grid point. |
| 194 | +
|
| 195 | + Computed in polar form (`alpha_r = d\psi/dr`, `alpha_theta = (1/r)\,d\psi/d\theta`) and |
| 196 | + then projected back to ``(y, x)`` Cartesian components. ``centre`` enters through the |
| 197 | + ``@transform`` decorator, which shifts the grid before the function body runs. |
| 198 | + """ |
| 199 | + y = grid.array[:, 0] |
| 200 | + x = grid.array[:, 1] |
| 201 | + r = xp.sqrt(x * x + y * y) |
| 202 | + theta = xp.arctan2(y, x) |
| 203 | + cos_t = xp.cos(theta) |
| 204 | + sin_t = xp.sin(theta) |
| 205 | + cos_2t = xp.cos(2.0 * theta) |
| 206 | + sin_2t = xp.sin(2.0 * theta) |
| 207 | + cos_3t = xp.cos(3.0 * theta) |
| 208 | + sin_3t = xp.sin(3.0 * theta) |
| 209 | + |
| 210 | + alpha_r = ( |
| 211 | + r * (self.gamma_1 * cos_2t + self.gamma_2 * sin_2t) |
| 212 | + + 0.75 * r * r * (self.tau_1 * cos_t + self.tau_2 * sin_t) |
| 213 | + + 0.5 * r * r * (self.delta_1 * cos_3t + self.delta_2 * sin_3t) |
| 214 | + ) |
| 215 | + alpha_theta = ( |
| 216 | + r * (-self.gamma_1 * sin_2t + self.gamma_2 * cos_2t) |
| 217 | + + 0.25 * r * r * (-self.tau_1 * sin_t + self.tau_2 * cos_t) |
| 218 | + + 0.5 * r * r * (-self.delta_1 * sin_3t + self.delta_2 * cos_3t) |
| 219 | + ) |
| 220 | + |
| 221 | + alpha_y = sin_t * alpha_r + cos_t * alpha_theta |
| 222 | + alpha_x = cos_t * alpha_r - sin_t * alpha_theta |
| 223 | + |
| 224 | + return xp.vstack((alpha_y, alpha_x)).T |
0 commit comments