Skip to content

Commit 9ccd1c1

Browse files
Jammy2211claude
authored andcommitted
Add input pixelized mass profiles (potential correction phase 2)
Adds autogalaxy/profiles/mass/input/: InputDeflections and InputPotential (pixelized mass models from known deflections / lensing-potential values on a masked grid, derivatives via the PyAutoArray sparse mask operators from phase 1), GaussianRandomField (power-law GRF potential realization, numpy-FFT, no powerbox dep) and the LinearNDInterpolatorExt helper. Ported from the potential_correction package of Cao et al. 2025 (https://github.com/caoxiaoyue/lensing_potential_correction); cite via https://github.com/caoxiaoyue/potential_correction_paper. Phase 2 of PyAutoLabs/PyAutoLens#618. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4761de6 commit 9ccd1c1

12 files changed

Lines changed: 745 additions & 82 deletions

File tree

autogalaxy/config/notation.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ label:
6969
zeroth_signal_scale: V
7070
superscript:
7171
ExternalShear: ext
72+
GaussianRandomField: grf
73+
InputDeflections: input
74+
InputPotential: input
7275
Mesh: mesh
7376
Point: point
7477
SMBH: smbh

autogalaxy/profiles/mass/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,9 @@
6666
ChameleonSph,
6767
)
6868
from .sheets import ExternalPotential, ExternalShear, MassSheet
69+
from .input import (
70+
InputDeflections,
71+
InputPotential,
72+
GaussianRandomField,
73+
LinearNDInterpolatorExt,
74+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .interp import LinearNDInterpolatorExt
2+
from .input_deflections import InputDeflections
3+
from .input_potential import InputPotential
4+
from .gaussian_random_field import GaussianRandomField
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from functools import cached_property
2+
3+
import numpy as np
4+
5+
import autoarray as aa
6+
7+
from autogalaxy.profiles.mass.abstract.abstract import MassProfile
8+
from autogalaxy.profiles.mass.input.input_potential import InputPotential
9+
10+
11+
def gaussian_random_field_from(
12+
shape_native, pixel_scale: float, power_amplitude: float, power_slope: float, seed: int
13+
) -> np.ndarray:
14+
"""
15+
A real Gaussian random field realization with isotropic power-law power
16+
spectrum P(k) = power_amplitude * k^(-power_slope).
17+
18+
The field is generated by filtering white Gaussian noise in Fourier space
19+
(multiplying its transform by sqrt(P(k)) and inverse transforming), which
20+
guarantees a real field whose power spectrum follows P(k); the k = 0 mode
21+
is zeroed so the field has zero mean. Note this normalization convention
22+
differs from the ``powerbox`` package used by the original
23+
implementation — the spectrum's shape and seed-reproducibility are what
24+
potential-correction validation relies on.
25+
26+
Parameters
27+
----------
28+
shape_native
29+
The 2D shape of the realization.
30+
pixel_scale
31+
The pixel size, setting the physical frequencies k.
32+
power_amplitude
33+
The amplitude of the power spectrum.
34+
power_slope
35+
The (positive) slope of the power-law spectrum, P(k) ~ k^-slope.
36+
seed
37+
The random seed, making the realization reproducible.
38+
"""
39+
rng = np.random.default_rng(seed)
40+
white_noise = rng.normal(size=shape_native)
41+
42+
ky = np.fft.fftfreq(shape_native[0], d=pixel_scale) * 2.0 * np.pi
43+
kx = np.fft.fftfreq(shape_native[1], d=pixel_scale) * 2.0 * np.pi
44+
k_grid = np.sqrt(ky[:, None] ** 2 + kx[None, :] ** 2)
45+
46+
power = np.zeros_like(k_grid)
47+
nonzero = k_grid > 0
48+
power[nonzero] = power_amplitude * k_grid[nonzero] ** (-power_slope)
49+
50+
field_ft = np.fft.fft2(white_noise) * np.sqrt(power)
51+
return np.fft.ifft2(field_ft).real
52+
53+
54+
class GaussianRandomField(MassProfile):
55+
def __init__(
56+
self,
57+
mask: aa.Mask2D,
58+
power_amplitude: float = 1.0,
59+
power_slope: float = 1.0,
60+
seed: int = 1,
61+
):
62+
"""
63+
A mass profile whose lensing potential is a Gaussian random field
64+
realization with power spectrum P(k) = power_amplitude * k^(-power_slope),
65+
used to simulate extended perturbations of a smooth lens-mass model
66+
(e.g. for validating potential-correction reconstructions).
67+
68+
The realization is evaluated on the unmasked pixels of the input mask
69+
and wrapped in an ``InputPotential``, from which the deflection angles
70+
and convergence are derived via the mask's sparse derivative
71+
operators.
72+
73+
Ported from the ``potential_correction`` package of Cao et al. 2025
74+
(https://github.com/caoxiaoyue/lensing_potential_correction). If you
75+
use this profile in your research, please cite Cao et al. 2025;
76+
citation materials are provided at
77+
https://github.com/caoxiaoyue/potential_correction_paper. The
78+
realization here uses a plain-numpy Fourier filter rather than the
79+
original's ``powerbox`` dependency (see
80+
``gaussian_random_field_from``).
81+
82+
Parameters
83+
----------
84+
mask
85+
The cleaned 2D mask (an ``aa.Mask2D`` carrying the pixel scale)
86+
on whose unmasked pixels the potential is defined (see
87+
``aa.util.derivative.cleaned_mask_from``).
88+
power_amplitude
89+
The amplitude of the potential's power spectrum.
90+
power_slope
91+
The (positive) slope of the power-law spectrum, P(k) ~ k^-slope.
92+
seed
93+
The random seed, making the realization reproducible.
94+
"""
95+
self.mask = mask
96+
self.power_amplitude = power_amplitude
97+
self.power_slope = power_slope
98+
self.seed = seed
99+
super().__init__()
100+
101+
@cached_property
102+
def lensing_potential_native(self) -> np.ndarray:
103+
return gaussian_random_field_from(
104+
shape_native=self.mask.shape_native,
105+
pixel_scale=self.mask.pixel_scale,
106+
power_amplitude=self.power_amplitude,
107+
power_slope=self.power_slope,
108+
seed=self.seed,
109+
)
110+
111+
@cached_property
112+
def input_potential(self) -> InputPotential:
113+
grid = aa.Grid2D.from_mask(mask=self.mask)
114+
return InputPotential(
115+
lensing_potential=self.lensing_potential_native[~np.asarray(self.mask)],
116+
image_plane_grid=np.asarray(grid),
117+
mask=self.mask,
118+
)
119+
120+
def convergence_2d_from(self, grid: aa.type.Grid2DLike, xp=np, **kwargs):
121+
return self.input_potential.convergence_2d_from(grid=grid, xp=xp, **kwargs)
122+
123+
def potential_2d_from(self, grid: aa.type.Grid2DLike, xp=np, **kwargs):
124+
return self.input_potential.potential_2d_from(grid=grid, xp=xp, **kwargs)
125+
126+
def deflections_yx_2d_from(self, grid: aa.type.Grid2DLike, xp=np, **kwargs):
127+
return self.input_potential.deflections_yx_2d_from(grid=grid, xp=xp, **kwargs)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from typing import Optional
2+
3+
import numpy as np
4+
from scipy.sparse import spmatrix
5+
from scipy.spatial import Delaunay
6+
7+
import autoarray as aa
8+
from autoarray.operators import derivative_util
9+
10+
from autogalaxy.profiles.mass.abstract.abstract import MassProfile
11+
from autogalaxy.profiles.mass.input.interp import LinearNDInterpolatorExt
12+
13+
14+
class InputDeflections(MassProfile):
15+
def __init__(
16+
self,
17+
deflections_y: np.ndarray,
18+
deflections_x: np.ndarray,
19+
image_plane_grid: aa.type.Grid2DLike,
20+
mask: aa.type.Mask2D,
21+
Hy: Optional[spmatrix] = None,
22+
Hx: Optional[spmatrix] = None,
23+
):
24+
"""
25+
A pixelized mass model defined by known deflection angles on the
26+
unmasked pixels of an image-plane grid (e.g. from a previous lens
27+
model, a particle simulation, or a potential-correction
28+
reconstruction).
29+
30+
Deflections at arbitrary positions are evaluated by linear
31+
interpolation over a Delaunay triangulation of the unmasked pixels
32+
(nearest-neighbour fallback outside the convex hull). The convergence
33+
is derived from the input deflections via the sparse first-derivative
34+
operators of the mask, kappa = 0.5 * (dalpha_y/dy + dalpha_x/dx). The
35+
lensing potential is not derivable from deflections alone and returns
36+
zeros.
37+
38+
Ported from the ``potential_correction`` package of Cao et al. 2025
39+
(https://github.com/caoxiaoyue/lensing_potential_correction). If you
40+
use this profile in your research, please cite Cao et al. 2025;
41+
citation materials are provided at
42+
https://github.com/caoxiaoyue/potential_correction_paper.
43+
44+
Parameters
45+
----------
46+
deflections_y
47+
The 1D (slim) array of the y components of the deflection angles
48+
on the unmasked pixels.
49+
deflections_x
50+
The 1D (slim) array of the x components of the deflection angles
51+
on the unmasked pixels.
52+
image_plane_grid
53+
The [n_unmasked, 2] (y, x) grid of the unmasked pixels the
54+
deflection angles are defined on.
55+
mask
56+
The cleaned 2D mask defining the unmasked pixels (see
57+
``aa.util.derivative.cleaned_mask_from``); its ``pixel_scale``
58+
sets the finite-difference step of the derived convergence.
59+
Hy
60+
The sparse first-derivative operator along y of the mask; built
61+
from the mask if not input.
62+
Hx
63+
The sparse first-derivative operator along x of the mask; built
64+
from the mask if not input.
65+
"""
66+
super().__init__()
67+
68+
self.deflections_y = np.asarray(deflections_y)
69+
self.deflections_x = np.asarray(deflections_x)
70+
self.image_plane_grid = np.asarray(image_plane_grid)
71+
self.mask = mask
72+
self.Hy = Hy
73+
self.Hx = Hx
74+
75+
self._build_interpolators()
76+
77+
def _build_interpolators(self):
78+
if self.Hy is None or self.Hx is None:
79+
self.Hy, self.Hx = derivative_util.derivative_1st_operators_from(
80+
np.asarray(self.mask), pixel_scale=self.mask.pixel_scale
81+
)
82+
83+
self.convergence_slim = (
84+
self.Hy @ self.deflections_y + self.Hx @ self.deflections_x
85+
) * 0.5
86+
87+
self.tri = Delaunay(np.fliplr(self.image_plane_grid))
88+
self.interp_defl_y = LinearNDInterpolatorExt(self.tri, self.deflections_y)
89+
self.interp_defl_x = LinearNDInterpolatorExt(self.tri, self.deflections_x)
90+
self.interp_kappa = LinearNDInterpolatorExt(self.tri, self.convergence_slim)
91+
92+
@aa.decorators.to_array
93+
def convergence_2d_from(self, grid: aa.type.Grid2DLike, xp=np, **kwargs):
94+
grid = np.asarray(grid)
95+
return self.interp_kappa(grid[:, 1], grid[:, 0])
96+
97+
@aa.decorators.to_array
98+
def potential_2d_from(self, grid: aa.type.Grid2DLike, xp=np, **kwargs):
99+
return np.zeros(shape=np.asarray(grid).shape[0])
100+
101+
@aa.decorators.to_vector_yx
102+
def deflections_yx_2d_from(self, grid: aa.type.Grid2DLike, xp=np, **kwargs):
103+
"""
104+
Calculate the deflection angles at a given set of arc-second gridded
105+
coordinates, by interpolating the input deflection angles.
106+
107+
Parameters
108+
----------
109+
grid
110+
The grid of (y,x) arc-second coordinates the deflection angles
111+
are computed on.
112+
"""
113+
grid = np.asarray(grid)
114+
deflections_y = self.interp_defl_y(grid[:, 1], grid[:, 0])
115+
deflections_x = self.interp_defl_x(grid[:, 1], grid[:, 0])
116+
return np.stack((deflections_y, deflections_x), axis=-1)

0 commit comments

Comments
 (0)