|
| 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