Skip to content

Commit b32e4f5

Browse files
Jammy2211Jammy2211
authored andcommitted
feat(mass): add ExternalPotential mass profile (Powell 2022 Eq 4)
New `ag.mp.ExternalPotential` line-of-sight profile that generalises `ExternalShear` by adding the τ (linear surface-mass-density gradient, spin-1) and δ (spin-3 generalised-shear) terms from Powell et al. 2022 Eq 4. Six free parameters plus a free centre (unlike ExternalShear's fixed (0,0), since τ and δ have radial deflections). γ-only reduces exactly to ExternalShear. Includes a `from_magnitudes_and_angles` classmethod and per-term magnitude/angle accessors. Convergence κ = τ₁·x + τ₂·y is non-zero for the τ term (the prototype's κ=0 placeholder is wrong — derived from Laplacian of ψ). 14 new unit tests; full mass-profiles suite (405 tests) still green. Closes #419
1 parent 24b7160 commit b32e4f5

4 files changed

Lines changed: 352 additions & 1 deletion

File tree

autogalaxy/profiles/mass/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,4 @@
6060
Chameleon,
6161
ChameleonSph,
6262
)
63-
from .sheets import ExternalShear, MassSheet
63+
from .sheets import ExternalPotential, ExternalShear, MassSheet
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from .mass_sheet import MassSheet
22
from .external_shear import ExternalShear
3+
from .external_potential import ExternalPotential
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import autogalaxy as ag
2+
import numpy as np
3+
import pytest
4+
5+
6+
grid_diag = ag.Grid2DIrregular([[0.1, 0.1]])
7+
grid_unit_x = ag.Grid2DIrregular([[0.0, 1.0]])
8+
grid_multi = ag.Grid2DIrregular([[0.1, 0.1], [0.2, 0.2], [0.3, 0.3]])
9+
10+
11+
def test__convergence_2d_from__gamma_only_is_zero():
12+
mp = ag.mp.ExternalPotential(gamma_1=0.1, gamma_2=-0.05)
13+
convergence = mp.convergence_2d_from(grid=grid_multi)
14+
assert convergence == pytest.approx(np.zeros(3), 1.0e-8)
15+
16+
17+
def test__convergence_2d_from__delta_only_is_zero():
18+
mp = ag.mp.ExternalPotential(delta_1=0.07, delta_2=0.04)
19+
convergence = mp.convergence_2d_from(grid=grid_multi)
20+
assert convergence == pytest.approx(np.zeros(3), 1.0e-8)
21+
22+
23+
def test__convergence_2d_from__tau_only_is_linear_in_xy():
24+
mp = ag.mp.ExternalPotential(tau_1=0.1, tau_2=0.2)
25+
grid = ag.Grid2DIrregular([[0.0, 1.0], [0.5, 1.0], [1.0, 0.0]])
26+
convergence = mp.convergence_2d_from(grid=grid)
27+
# kappa(x, y) = tau_1 * x + tau_2 * y
28+
expected = np.array([0.1 * 1.0 + 0.2 * 0.0, 0.1 * 1.0 + 0.2 * 0.5, 0.1 * 0.0 + 0.2 * 1.0])
29+
assert convergence == pytest.approx(expected, 1.0e-6)
30+
31+
32+
def test__convergence_2d_from__tau_with_nonzero_centre_shifts_origin():
33+
mp = ag.mp.ExternalPotential(centre=(1.0, 1.0), tau_1=0.1, tau_2=0.0)
34+
grid = ag.Grid2DIrregular([[1.0, 1.0], [1.0, 2.0]])
35+
convergence = mp.convergence_2d_from(grid=grid)
36+
assert convergence == pytest.approx(np.array([0.0, 0.1]), 1.0e-6)
37+
38+
39+
def test__potential_2d_from__gamma_only_matches_external_shear():
40+
shear = ag.mp.ExternalShear(gamma_1=0.1, gamma_2=-0.05)
41+
pot = ag.mp.ExternalPotential(gamma_1=0.1, gamma_2=-0.05)
42+
expected = shear.potential_2d_from(grid=grid_multi)
43+
actual = pot.potential_2d_from(grid=grid_multi)
44+
assert actual == pytest.approx(np.asarray(expected), 1.0e-6)
45+
46+
47+
def test__potential_2d_from__tau_only_unit_x_axis():
48+
mp = ag.mp.ExternalPotential(tau_1=0.05, tau_2=0.0)
49+
# at (y=0, x=1): r=1, theta=0 -> psi = 0.25 * 1^3 * (0.05 * 1 + 0) = 0.0125
50+
potential = mp.potential_2d_from(grid=grid_unit_x)
51+
assert potential == pytest.approx(np.array([0.0125]), 1.0e-6)
52+
53+
54+
def test__potential_2d_from__delta_only_unit_x_axis():
55+
mp = ag.mp.ExternalPotential(delta_1=0.1, delta_2=0.0)
56+
# at (y=0, x=1): r=1, theta=0 -> psi = (1/6) * 1^3 * (0.1 * 1 + 0) = 0.1/6
57+
potential = mp.potential_2d_from(grid=grid_unit_x)
58+
assert potential == pytest.approx(np.array([0.1 / 6.0]), 1.0e-6)
59+
60+
61+
def test__deflections_yx_2d_from__gamma_only_matches_external_shear():
62+
shear = ag.mp.ExternalShear(gamma_1=-0.17320, gamma_2=0.1)
63+
pot = ag.mp.ExternalPotential(gamma_1=-0.17320, gamma_2=0.1)
64+
grid = ag.Grid2DIrregular([[0.1625, 0.1625]])
65+
expected = np.asarray(shear.deflections_yx_2d_from(grid=grid))
66+
actual = np.asarray(pot.deflections_yx_2d_from(grid=grid))
67+
assert actual == pytest.approx(expected, 1.0e-5)
68+
69+
70+
def test__deflections_yx_2d_from__tau_only_radial_unit_x_axis():
71+
mp = ag.mp.ExternalPotential(tau_1=0.05, tau_2=0.0)
72+
# at (y=0, x=1): r=1, theta=0
73+
# alpha_r = 0.75 * 1 * 0.05 = 0.0375 ; alpha_theta = 0
74+
# alpha_y = 0, alpha_x = 0.0375
75+
deflections = mp.deflections_yx_2d_from(grid=grid_unit_x)
76+
assert deflections[0, 0] == pytest.approx(0.0, 1.0e-6)
77+
assert deflections[0, 1] == pytest.approx(0.0375, 1.0e-6)
78+
79+
80+
def test__deflections_yx_2d_from__delta_only_unit_x_axis():
81+
mp = ag.mp.ExternalPotential(delta_1=0.1, delta_2=0.0)
82+
# at (y=0, x=1): alpha_r = 0.5 * 1 * 0.1 = 0.05 ; alpha_theta = 0
83+
deflections = mp.deflections_yx_2d_from(grid=grid_unit_x)
84+
assert deflections[0, 0] == pytest.approx(0.0, 1.0e-6)
85+
assert deflections[0, 1] == pytest.approx(0.05, 1.0e-6)
86+
87+
88+
def test__deflections_yx_2d_from__nonzero_centre_shifts_origin():
89+
mp = ag.mp.ExternalPotential(centre=(1.0, 1.0), gamma_1=0.1, gamma_2=0.0)
90+
# at (y=1, x=2) post-shift becomes (y=0, x=1): r=1, theta=0
91+
# alpha_r = 1 * (0.1 * cos(0) + 0) = 0.1 ; alpha_theta = 0
92+
# alpha_y = 0, alpha_x = 0.1
93+
grid = ag.Grid2DIrregular([[1.0, 2.0]])
94+
deflections = mp.deflections_yx_2d_from(grid=grid)
95+
assert deflections[0, 0] == pytest.approx(0.0, 1.0e-6)
96+
assert deflections[0, 1] == pytest.approx(0.1, 1.0e-6)
97+
98+
99+
def test__from_magnitudes_and_angles__roundtrip_gamma():
100+
gamma = 0.1
101+
theta_gamma = 30.0 # degrees
102+
mp = ag.mp.ExternalPotential.from_magnitudes_and_angles(
103+
gamma=gamma, theta_gamma=theta_gamma
104+
)
105+
assert mp.gamma_magnitude() == pytest.approx(gamma, 1.0e-6)
106+
assert mp.gamma_angle() == pytest.approx(theta_gamma, 1.0e-6)
107+
108+
109+
def test__from_magnitudes_and_angles__roundtrip_tau():
110+
tau = 0.05
111+
theta_tau = 200.0 # degrees, spin-1 so [0, 360)
112+
mp = ag.mp.ExternalPotential.from_magnitudes_and_angles(
113+
tau=tau, theta_tau=theta_tau
114+
)
115+
assert mp.tau_magnitude() == pytest.approx(tau, 1.0e-6)
116+
assert mp.tau_angle() == pytest.approx(theta_tau, 1.0e-6)
117+
118+
119+
def test__from_magnitudes_and_angles__roundtrip_delta():
120+
delta = 0.02
121+
theta_delta = 40.0 # degrees, spin-3 so [0, 120)
122+
mp = ag.mp.ExternalPotential.from_magnitudes_and_angles(
123+
delta=delta, theta_delta=theta_delta
124+
)
125+
assert mp.delta_magnitude() == pytest.approx(delta, 1.0e-6)
126+
assert mp.delta_angle() == pytest.approx(theta_delta, 1.0e-6)

0 commit comments

Comments
 (0)