Skip to content

Commit 645cc8f

Browse files
authored
Merge pull request #582 from PyAutoLabs/feature/weak-viz-profiles
feat: tangential/cross shear profiles + Kaiser-Squires map (weak series step 6)
2 parents 82f48a6 + b508ffa commit 645cc8f

5 files changed

Lines changed: 620 additions & 0 deletions

File tree

autolens/plot/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@
8585
plot_chi_squared_map,
8686
subplot_fit_weak,
8787
)
88+
from autolens.weak.plot.shear_profile_plots import (
89+
plot_shear_profile,
90+
)
91+
from autolens.weak.plot.convergence_plots import (
92+
plot_convergence_map,
93+
)
8894

8995
from autolens.lens.plot.subhalo_plots import (
9096
subplot_detection_imaging,
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""
2+
Kaiser-Squires convergence-map reconstruction from a shear catalogue.
3+
4+
Kaiser & Squires (1993) showed the convergence :math:`\\kappa` and shear
5+
:math:`\\gamma` are related algebraically in Fourier space:
6+
7+
.. math::
8+
\\hat{\\kappa}(\\mathbf{k}) = D^*(\\mathbf{k}) \\, \\hat{\\gamma}(\\mathbf{k}),
9+
\\qquad
10+
D(\\mathbf{k}) = \\frac{k_x^2 - k_y^2 + 2 i k_x k_y}{k_x^2 + k_y^2}
11+
12+
so a mass map can be reconstructed directly from the measured shear field with two
13+
FFTs — no mass model assumed. This is the classic "dark matter map" technique used
14+
for merging clusters (e.g. the Bullet cluster) and survey mass maps.
15+
16+
Because the catalogue's galaxy positions are irregular, the shears are first binned
17+
onto a regular grid (plain per-cell mean, empty cells zero) over the catalogue's
18+
extent, then optionally smoothed with a Gaussian kernel — standard practice, since
19+
the inversion is only defined on a grid and raw per-cell shears are shape-noise
20+
dominated. The imaginary part of the reconstruction (the B-mode map) carries no
21+
lensing signal and is returned alongside the E-mode map as a systematics check.
22+
23+
The reconstruction inherits Kaiser-Squires' well-known caveats: the
24+
:math:`\\mathbf{k} = 0` mode is unconstrained (the mass-sheet degeneracy — maps are
25+
zero-mean by construction) and FFT periodicity causes edge artefacts on small
26+
fields. For quantitative masses, fit a mass model (``scripts/weak/modeling.py``);
27+
the map is a visualization and model-independent cross-check.
28+
"""
29+
from typing import Optional, Tuple
30+
31+
import numpy as np
32+
33+
import autoarray as aa
34+
35+
from autoarray.plot.utils import save_figure, subplots
36+
37+
from autolens.weak.plot.weak_dataset_plots import _positions_yx
38+
from autolens.weak.plot.shear_profile_plots import _gamma_1_2_from
39+
40+
41+
def convergence_via_kaiser_squires_from(
42+
shear_yx,
43+
shape_native: Tuple[int, int] = (50, 50),
44+
smoothing_sigma_pixels: float = 1.0,
45+
extent: Optional[Tuple[float, float, float, float]] = None,
46+
) -> Tuple[aa.Array2D, aa.Array2D]:
47+
"""
48+
Reconstruct E-mode (convergence) and B-mode maps from a shear field.
49+
50+
Parameters
51+
----------
52+
shear_yx
53+
The shear field (e.g. ``dataset.shear_yx``).
54+
shape_native
55+
The ``(rows, cols)`` shape of the regular grid the shears are binned onto.
56+
smoothing_sigma_pixels
57+
The sigma (in pixels) of the Gaussian kernel applied to the binned shear
58+
maps before inversion; ``0.0`` disables smoothing.
59+
extent
60+
The ``(x_min, x_max, y_min, y_max)`` field extent; defaults to the
61+
catalogue's bounding box (with a small buffer, matching
62+
``WeakDataset.extent_from``).
63+
64+
Returns
65+
-------
66+
An ``(e_mode, b_mode)`` pair of ``aa.Array2D`` maps: the convergence
67+
reconstruction and its B-mode systematics check.
68+
"""
69+
positions = _positions_yx(shear_yx)
70+
gamma_1, gamma_2 = _gamma_1_2_from(shear_yx)
71+
72+
if extent is None:
73+
buffer = 0.1
74+
x_min, x_max = positions[:, 1].min() - buffer, positions[:, 1].max() + buffer
75+
y_min, y_max = positions[:, 0].min() - buffer, positions[:, 0].max() + buffer
76+
else:
77+
x_min, x_max, y_min, y_max = extent
78+
79+
rows, cols = shape_native
80+
81+
# Bin the irregular shears onto the grid: plain mean per cell, zero where empty.
82+
y_edges = np.linspace(y_min, y_max, rows + 1)
83+
x_edges = np.linspace(x_min, x_max, cols + 1)
84+
85+
counts, _, _ = np.histogram2d(
86+
positions[:, 0], positions[:, 1], bins=[y_edges, x_edges]
87+
)
88+
sum_1, _, _ = np.histogram2d(
89+
positions[:, 0], positions[:, 1], bins=[y_edges, x_edges], weights=gamma_1
90+
)
91+
sum_2, _, _ = np.histogram2d(
92+
positions[:, 0], positions[:, 1], bins=[y_edges, x_edges], weights=gamma_2
93+
)
94+
95+
with np.errstate(invalid="ignore"):
96+
grid_1 = np.where(counts > 0, sum_1 / np.maximum(counts, 1), 0.0)
97+
grid_2 = np.where(counts > 0, sum_2 / np.maximum(counts, 1), 0.0)
98+
99+
if smoothing_sigma_pixels > 0.0:
100+
kernel = _gaussian_kernel_2d(sigma=smoothing_sigma_pixels)
101+
grid_1 = _convolve_2d_same(grid_1, kernel)
102+
grid_2 = _convolve_2d_same(grid_2, kernel)
103+
104+
# Kaiser-Squires inversion: kappa_hat = D*(k) gamma_hat, D = (kx^2 - ky^2 + 2i kx ky) / k^2.
105+
ky = np.fft.fftfreq(rows)[:, None]
106+
kx = np.fft.fftfreq(cols)[None, :]
107+
k_squared = kx**2.0 + ky**2.0
108+
k_squared[0, 0] = 1.0 # k = 0 mode is unconstrained (mass-sheet degeneracy); set below.
109+
110+
d_conj = ((kx**2.0 - ky**2.0) - 2.0j * kx * ky) / k_squared
111+
112+
gamma_hat = np.fft.fft2(grid_1) + 1.0j * np.fft.fft2(grid_2)
113+
kappa_hat = d_conj * gamma_hat
114+
kappa_hat[0, 0] = 0.0
115+
116+
kappa = np.fft.ifft2(kappa_hat)
117+
118+
pixel_scales = ((y_max - y_min) / rows, (x_max - x_min) / cols)
119+
120+
# Row 0 of the binned grids is y_min; autoarray native arrays put y_max at row 0.
121+
e_mode = aa.Array2D.no_mask(
122+
values=np.flipud(kappa.real), pixel_scales=pixel_scales
123+
)
124+
b_mode = aa.Array2D.no_mask(
125+
values=np.flipud(kappa.imag), pixel_scales=pixel_scales
126+
)
127+
128+
return e_mode, b_mode
129+
130+
131+
def _gaussian_kernel_2d(sigma: float) -> np.ndarray:
132+
"""A normalised 2D Gaussian kernel truncated at 3 sigma."""
133+
half_width = max(int(np.ceil(3.0 * sigma)), 1)
134+
x = np.arange(-half_width, half_width + 1)
135+
kernel_1d = np.exp(-0.5 * (x / sigma) ** 2.0)
136+
kernel = np.outer(kernel_1d, kernel_1d)
137+
return kernel / kernel.sum()
138+
139+
140+
def _convolve_2d_same(image: np.ndarray, kernel: np.ndarray) -> np.ndarray:
141+
"""Same-size 2D convolution via zero-padded FFT (avoids a scipy dependency)."""
142+
kh, kw = kernel.shape
143+
ih, iw = image.shape
144+
padded = np.zeros((ih + kh - 1, iw + kw - 1))
145+
padded[:ih, :iw] = image
146+
kernel_padded = np.zeros_like(padded)
147+
kernel_padded[:kh, :kw] = kernel
148+
result = np.fft.ifft2(np.fft.fft2(padded) * np.fft.fft2(kernel_padded)).real
149+
h0, w0 = (kh - 1) // 2, (kw - 1) // 2
150+
return result[h0 : h0 + ih, w0 : w0 + iw]
151+
152+
153+
def plot_convergence_map(
154+
shear_yx,
155+
shape_native: Tuple[int, int] = (50, 50),
156+
smoothing_sigma_pixels: float = 1.0,
157+
show_positions: bool = True,
158+
ax=None,
159+
title: str = "Kaiser-Squires Convergence",
160+
output_path: Optional[str] = None,
161+
output_filename: str = "convergence_map",
162+
output_format: Optional[str] = None,
163+
):
164+
"""
165+
Plot the Kaiser-Squires E-mode convergence reconstruction of a shear field.
166+
167+
Parameters
168+
----------
169+
shear_yx
170+
The shear field (e.g. ``dataset.shear_yx``).
171+
shape_native
172+
The ``(rows, cols)`` reconstruction grid shape.
173+
smoothing_sigma_pixels
174+
Gaussian smoothing applied to the binned shears before inversion.
175+
show_positions
176+
Whether to overlay the catalogue's galaxy positions on the map.
177+
ax
178+
An existing matplotlib axes to draw on; a new figure is created if ``None``.
179+
"""
180+
e_mode, _ = convergence_via_kaiser_squires_from(
181+
shear_yx=shear_yx,
182+
shape_native=shape_native,
183+
smoothing_sigma_pixels=smoothing_sigma_pixels,
184+
)
185+
186+
positions = _positions_yx(shear_yx)
187+
extent = [
188+
positions[:, 1].min() - 0.1,
189+
positions[:, 1].max() + 0.1,
190+
positions[:, 0].min() - 0.1,
191+
positions[:, 0].max() + 0.1,
192+
]
193+
194+
fig = None
195+
if ax is None:
196+
fig, ax = subplots(1, 1)
197+
198+
image = ax.imshow(
199+
e_mode.native,
200+
origin="upper",
201+
extent=extent,
202+
cmap="magma",
203+
)
204+
if show_positions:
205+
ax.scatter(
206+
positions[:, 1], positions[:, 0], s=2, c="cyan", alpha=0.5, linewidths=0
207+
)
208+
ax.set_xlabel("x (arcsec)")
209+
ax.set_ylabel("y (arcsec)")
210+
ax.set_title(title)
211+
if fig is not None:
212+
fig.colorbar(image, ax=ax, label=r"$\kappa$ (E-mode)")
213+
save_figure(
214+
fig,
215+
path=output_path,
216+
filename=output_filename,
217+
format=output_format,
218+
)

0 commit comments

Comments
 (0)