convolve-uv 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ from .convolve_uv import convolve_uv
2
+
3
+ __all__ = [
4
+ "convolve_uv",
5
+ ]
@@ -0,0 +1,358 @@
1
+ import copy
2
+
3
+ import astropy.units as u
4
+ import numpy as np
5
+ from astropy.convolution import convolve_fft, interpolate_replace_nans
6
+ from astropy.utils.console import ProgressBar
7
+ from astropy.wcs.utils import proj_plane_pixel_scales
8
+ from radio_beam import Beam
9
+ from radio_beam.utils import BeamError
10
+ from spectral_cube import Projection, SpectralCube, VaryingResolutionSpectralCube
11
+
12
+ FWHM_TO_SIGMA = 1.0 / np.sqrt(8.0 * np.log(2.0))
13
+
14
+
15
+ def beam_covariance_en(
16
+ beam: Beam,
17
+ ) -> np.ndarray:
18
+ """Gaussian covariance in (east, north), in square degrees.
19
+
20
+ Args:
21
+ beam (Beam): The beam to compute the covariance for.
22
+
23
+ Returns:
24
+ np.ndarray: The 2x2 covariance matrix of the beam in (east, north) coordinates.
25
+ """
26
+ smaj = beam.major.to_value(u.deg) * FWHM_TO_SIGMA
27
+ smin = beam.minor.to_value(u.deg) * FWHM_TO_SIGMA
28
+ angle = beam.pa.to_value(u.rad)
29
+ major_hat = np.array([np.sin(angle), np.cos(angle)])
30
+ minor_hat = np.array([np.cos(angle), -np.sin(angle)])
31
+
32
+ cov = smaj**2 * np.outer(major_hat, major_hat) + smin**2 * np.outer(minor_hat, minor_hat)
33
+
34
+ return cov
35
+
36
+
37
+ def kernel_covariance_pixels(
38
+ cube_slice: Projection,
39
+ target_beam: Beam,
40
+ ) -> np.ndarray:
41
+ """Return target-minus-input covariance in pixel (x, y) coordinates.
42
+
43
+ Args:
44
+ cube_slice (Projection): 2D projection of a full 3D SpectralCube.
45
+ target_beam (Beam): The desired circular beam to convolve to.
46
+
47
+ Returns:
48
+ np.ndarray: The 2x2 covariance matrix of the kernel in pixel coordinates.
49
+ """
50
+
51
+ target = beam_covariance_en(target_beam)
52
+ source = beam_covariance_en(cube_slice.beam)
53
+ kernel_sky = target - source
54
+
55
+ eigenvalues, eigenvectors = np.linalg.eigh(kernel_sky)
56
+ eigenvalues = np.maximum(eigenvalues, 0.0)
57
+ kernel_sky = (eigenvectors * eigenvalues) @ eigenvectors.T
58
+
59
+ # pixel_scale_matrix maps (dx, dy) pixels to local projected (east, north)
60
+ # degrees. This includes rotation and unequal pixel scales.
61
+ jacobian = np.asarray(cube_slice.wcs.celestial.pixel_scale_matrix, dtype=float)
62
+ if jacobian.shape != (2, 2) or abs(np.linalg.det(jacobian)) < 1e-20:
63
+ raise ValueError("The celestial WCS has a singular pixel-scale matrix")
64
+ sky_to_pix = np.linalg.inv(jacobian)
65
+
66
+ cov = sky_to_pix @ kernel_sky @ sky_to_pix.T
67
+
68
+ return cov
69
+
70
+
71
+ def transfer_function(
72
+ shape_yx: tuple[int, int],
73
+ covariance_xy: np.ndarray,
74
+ ) -> np.ndarray:
75
+ """Analytic Fourier transform of a unit-integral Gaussian.
76
+
77
+ Args:
78
+ shape_yx (tuple[int, int]): The shape of the 2D array in (y, x) order.
79
+ covariance_xy (np.ndarray): The 2x2 covariance matrix of the Gaussian in pixel coordinates.
80
+
81
+ Returns:
82
+ np.ndarray: The transfer function in Fourier space.
83
+ """
84
+ ny, nx = shape_yx
85
+ fx = np.fft.rfftfreq(nx)
86
+ fy = np.fft.fftfreq(ny)
87
+
88
+ # k is cycles/pixel and covariance is in pixel^2.
89
+ exponent = (
90
+ -2.0
91
+ * np.pi**2
92
+ * (
93
+ covariance_xy[0, 0] * fx[None, :] ** 2
94
+ + 2.0 * covariance_xy[0, 1] * fy[:, None] * fx[None, :]
95
+ + covariance_xy[1, 1] * fy[:, None] ** 2
96
+ )
97
+ )
98
+ t_func = np.exp(exponent)
99
+
100
+ return t_func
101
+
102
+
103
+ def fft_filter(
104
+ data: np.ndarray,
105
+ transfer: np.ndarray,
106
+ ) -> np.ndarray:
107
+ """Simple wrapper around the FFT-based filtering of a 2D array with a transfer function.
108
+
109
+ Args:
110
+ data (np.ndarray): The 2D array to be filtered.
111
+ transfer (np.ndarray): The transfer function in Fourier space.
112
+
113
+ Returns:
114
+ np.ndarray: The filtered 2D array.
115
+ """
116
+
117
+ data_fft_filtered = np.fft.irfft2(
118
+ np.fft.rfft2(data) * transfer,
119
+ s=data.shape,
120
+ )
121
+
122
+ return data_fft_filtered
123
+
124
+
125
+ def do_convolution(
126
+ image_slice: Projection,
127
+ target_beam: Beam,
128
+ boundary: str = "fill",
129
+ fill_value: float | int = 0.0,
130
+ pad_sigma: float = 8.0,
131
+ nan_treatment: str = "interpolate",
132
+ preserve_nan: bool = False,
133
+ ) -> np.ndarray:
134
+ """Perform the actual convolution
135
+
136
+ Args:
137
+ image_slice (Projection | SpectralCube): Either a full SpectralCube instance,
138
+ or a projection of a full 3D SpectralCube. If a full SpectralCube, then the cube should only
139
+ have two dimensions
140
+ target_beam (Beam): The desired circular beam to convolve to.
141
+ boundary (str, optional): ``"wrap"`` gives the exact periodic DFT solution. ``"fill"`` pads by
142
+ ``fill_value`` for ``pad_sigma`` kernel sigmas before transforming to reduce wrapping.
143
+ fill_value (float|int, optional): The value to use outside the array when using boundary=``fill``.
144
+ Defaults to 0.0
145
+ pad_sigma (float, optional): Number of kernel sigmas to pad when using "pad" boundary. Defaults to 8.0.
146
+ nan_treatment (str, optional): The method used to handle NaNs in the input slice:
147
+ * 'interpolate': ``NaN`` values are replaced with interpolated
148
+ values using the kernel as an interpolation function. Note that
149
+ if the kernel has a sum equal to zero, NaN interpolation is not
150
+ possible and will raise an exception.
151
+ * 'fill': ``NaN`` values are replaced by ``fill_value`` prior to
152
+ convolution.
153
+ Defaults to "interpolate".
154
+ preserve_nan (bool, optional): After performing convolution, should pixels that were originally NaN again
155
+ become NaN? Defaults to False.
156
+
157
+ Returns:
158
+ np.ndarray: The convolved image_slice
159
+ """
160
+
161
+ # Check beams are as we expect
162
+ try:
163
+ beam = image_slice.beam
164
+ except AttributeError:
165
+ raise AttributeError("image_slice must have a valid beam")
166
+
167
+ if not isinstance(beam, Beam):
168
+ raise TypeError("Input beam must be a Beam object")
169
+
170
+ # If the beams are identical, we just return the data
171
+ if beam == target_beam:
172
+ return image_slice
173
+
174
+ # Check the beams can be deconvolved
175
+ try:
176
+ kernel = target_beam.deconvolve(image_slice.beam)
177
+ except BeamError:
178
+ raise ValueError("The target beam is smaller than the input beam, so cannot be deconvolved")
179
+
180
+ # Pull out the pixel scale, convert the kernel to an array
181
+ pix_scale = proj_plane_pixel_scales(image_slice.wcs.celestial)[0] * u.deg
182
+ kernel = kernel.as_kernel(pixscale=pix_scale).array
183
+
184
+ covariance = kernel_covariance_pixels(image_slice, target_beam)
185
+ data = image_slice.unitless_filled_data[:]
186
+
187
+ # Keep track of NaNs, in case we need to put them back in later
188
+ nan_mask = np.isnan(data)
189
+
190
+ # If we're filling NaNs, then do that here
191
+ if nan_treatment == "fill":
192
+ data = np.where(np.isfinite(data), data, fill_value)
193
+ elif nan_treatment == "interpolate":
194
+ data = interpolate_replace_nans(
195
+ data,
196
+ kernel,
197
+ convolve=convolve_fft,
198
+ )
199
+ else:
200
+ raise ValueError("nan_treatment must be 'interpolate' or 'fill'")
201
+
202
+ # Keep track of where pixels are valid
203
+ valid = np.isfinite(data)
204
+
205
+ pad_y = pad_x = 0
206
+ if boundary == "fill":
207
+ # Marginal standard deviations give a conservative axis-wise pad.
208
+ pad_x = int(np.ceil(pad_sigma * np.sqrt(covariance[0, 0])))
209
+ pad_y = int(np.ceil(pad_sigma * np.sqrt(covariance[1, 1])))
210
+ pad_width = [(0, 0)] * (data.ndim - 2) + [(pad_y, pad_y), (pad_x, pad_x)]
211
+ data = np.pad(data, pad_width, mode="constant", constant_values=fill_value)
212
+ valid = np.pad(valid, pad_width, mode="constant", constant_values=False)
213
+
214
+ transfer = transfer_function(data.shape, covariance)
215
+ numerator = fft_filter(np.where(valid, data, 0.0), transfer)
216
+
217
+ # Account for NaNs. This is essentially to get around numerical issues
218
+ denominator = fft_filter(valid.astype(float), transfer)
219
+ scale = max(float(transfer.mean()), 1e-14)
220
+ cube_slice_conv = np.divide(
221
+ numerator,
222
+ denominator,
223
+ out=np.full_like(numerator, np.nan),
224
+ where=denominator > 1e-12 * scale,
225
+ )
226
+
227
+ # If we've padded, then back out this pad
228
+ if boundary == "fill":
229
+ ys = slice(pad_y, -pad_y or None)
230
+ xs = slice(pad_x, -pad_x or None)
231
+ cube_slice_conv = cube_slice_conv[ys, xs]
232
+
233
+ # If we're preserving NaNs, then put them back in
234
+ if preserve_nan:
235
+ cube_slice_conv[nan_mask] = np.nan
236
+
237
+ # If we're Jy/beam-like, account for that here
238
+ if image_slice.unit.is_equivalent(u.Jy / u.beam):
239
+ beam_ratio_factor = (target_beam.sr / image_slice.beam.sr).value
240
+ else:
241
+ beam_ratio_factor = 1.0
242
+ cube_slice_conv *= beam_ratio_factor
243
+
244
+ # If the dtype has changed, then revert here
245
+ if cube_slice_conv.dtype != image_slice.dtype:
246
+ cube_slice_conv = cube_slice_conv.astype(image_slice.dtype)
247
+
248
+ return cube_slice_conv
249
+
250
+
251
+ def convolve_uv(
252
+ image: Projection | SpectralCube | VaryingResolutionSpectralCube,
253
+ target_beam: Beam,
254
+ boundary: str = "fill",
255
+ fill_value: float | int = 0.0,
256
+ pad_sigma: float = 8.0,
257
+ nan_treatment: str = "interpolate",
258
+ preserve_nan: bool = False,
259
+ ) -> Projection | SpectralCube:
260
+ """Convolve a 2D projection to a round Gaussian beam exactly in uv space.
261
+
262
+ The spatial Gaussian transfer function is evaluated analytically on the DFT
263
+ grid. No image-plane convolution kernel is sampled, so sub-pixel kernels are
264
+ handled without the discretisation problem in ``astropy.convolution``.
265
+
266
+ Note this will also check if the slice units are Jy/beam like, and account for that here
267
+ so no extra renormalisation is required
268
+
269
+ Args:
270
+ image (Projection | SpectralCube | VaryingResolutionSpectralCube): Either a full SpectralCube instance,
271
+ or a projection of a full 3D SpectralCube.
272
+ target_beam (Beam): The desired circular beam to convolve to.
273
+ boundary (str, optional): ``"wrap"`` gives the exact periodic DFT solution. ``"fill"`` pads by
274
+ ``fill_value`` for ``pad_sigma`` kernel sigmas before transforming to reduce wrapping.
275
+ fill_value (float|int, optional): The value to use outside the array when using boundary=``fill``.
276
+ Defaults to 0.0
277
+ pad_sigma (float, optional): Number of kernel sigmas to pad when using "pad" boundary. Defaults to 8.0.
278
+ nan_treatment (str, optional): The method used to handle NaNs in the input slice:
279
+ * 'interpolate': ``NaN`` values are replaced with interpolated
280
+ values using the kernel as an interpolation function. Note that
281
+ if the kernel has a sum equal to zero, NaN interpolation is not
282
+ possible and will raise an exception.
283
+ * 'fill': ``NaN`` values are replaced by ``fill_value`` prior to
284
+ convolution.
285
+ Defaults to "interpolate".
286
+ preserve_nan (bool, optional): After performing convolution, should pixels that were originally NaN again
287
+ become NaN? Defaults to False.
288
+
289
+ Returns:
290
+ Projection | SpectralCube: The convolved Projection or SpectralCube
291
+ """
292
+
293
+ # We need to keep everything in memory
294
+ image.allow_huge_operations = True
295
+
296
+ if boundary not in ["fill", "wrap"]:
297
+ raise ValueError("boundary must be 'fill' or 'wrap'")
298
+
299
+ if nan_treatment not in ["interpolate", "fill"]:
300
+ raise ValueError("nan_treatment must be 'interpolate' or 'fill'")
301
+
302
+ # If we're a cube, then we need to loop over each plane
303
+ if not isinstance(image, Projection):
304
+ n_chan = image.shape[0]
305
+
306
+ data_conv = np.zeros(image.shape, dtype=image.unmasked_data[0, 0, 0].dtype)
307
+
308
+ # To avoid adding in unnecessary slice info to the header,
309
+ # take a copy of the cube
310
+ image_copy = copy.deepcopy(image)
311
+
312
+ with ProgressBar(n_chan) as bar:
313
+ for chan in range(n_chan):
314
+ data_conv[chan] = do_convolution(
315
+ image_copy[chan],
316
+ target_beam=target_beam,
317
+ boundary=boundary,
318
+ fill_value=fill_value,
319
+ pad_sigma=pad_sigma,
320
+ nan_treatment=nan_treatment,
321
+ preserve_nan=preserve_nan,
322
+ )
323
+ bar.update()
324
+
325
+ # If we're a VaryingResolutionSpectralCube, then we need to return a SpectralCube with the new beam
326
+ if isinstance(image, VaryingResolutionSpectralCube):
327
+ image_conv = SpectralCube(
328
+ data=data_conv,
329
+ wcs=image.wcs,
330
+ mask=image.mask,
331
+ meta=image.meta,
332
+ fill_value=image.fill_value,
333
+ header=image.header,
334
+ beam=target_beam,
335
+ )
336
+
337
+ else:
338
+ image_conv = image._new_cube_with(data=data_conv, beam=target_beam)
339
+
340
+ else:
341
+ slice_conv = do_convolution(
342
+ image,
343
+ target_beam=target_beam,
344
+ boundary=boundary,
345
+ fill_value=fill_value,
346
+ pad_sigma=pad_sigma,
347
+ nan_treatment=nan_treatment,
348
+ preserve_nan=preserve_nan,
349
+ )
350
+
351
+ image_conv = image._new_projection_with(data=slice_conv, beam=target_beam)
352
+
353
+ # Since we've convolved to a beam, if there's still references to multibeam tables,
354
+ # remove that
355
+ if "CASAMBM" in image_conv.header:
356
+ del image_conv._header["CASAMBM"]
357
+
358
+ return image_conv