cmbstack 0.1.1__tar.gz → 0.2.0__tar.gz

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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cmbstack
3
- Version: 0.1.1
4
- Summary: Stack patches of the CMB temperature sky around local maxima
3
+ Version: 0.2.0
4
+ Summary: Stack patches of HEALPix maps around peaks or catalogue positions
5
5
  Author-email: Isaac Alexis López Paredes <isaacalexis.lopezparedes@students.uniroma2.eu>, Anushka Sanjay Tilekar <anushka.tilekar.23@alumni.ucl.ac.uk>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://cmbstack-docs.readthedocs.io/en/latest/index.html
@@ -23,7 +23,7 @@ Requires-Dist: matplotlib
23
23
 
24
24
  # cmbstack
25
25
 
26
- Stack patches of the Cosmic Microwave Background (CMB) temperature sky. Go [here](https://cmbstack-docs.readthedocs.io/en/latest/index.html) to see the full documentation and a worked example.
26
+ Stack patches of HEALPix maps, such as the CMB temperature, CMB lensing or galaxy density, around their peaks or around the positions of a catalogue. Go [here](https://cmbstack-docs.readthedocs.io/en/latest/index.html) to see the full documentation, and [here](https://github.com/IsaacLP/cmbstack/tree/main/docs/tutorials) for worked examples.
27
27
 
28
28
  ## Installation
29
29
 
@@ -62,3 +62,15 @@ stacked_result = pipeline.run()
62
62
  pipeline = StackingPipeline.from_map(sky_map)
63
63
  stacked_result = pipeline.run()
64
64
  ```
65
+
66
+ A masked map (`healpy.ma`) keeps its mask: masked pixels are ignored, and `pipeline.counts` gives the number of patches contributing to each pixel.
67
+
68
+ **On the positions of a catalogue, with a rotation for each patch:**
69
+ ```python
70
+ # positions: (N, 2) array of (lon, lat) in degrees; angles: (N,) rotations in degrees
71
+ pipeline = StackingPipeline.from_map(sky_map)
72
+ stacked_result = pipeline.run(positions=positions, psi=angles, size_deg=2.4, size_deg_y=12.0,
73
+ profile=False, keep_patches=False)
74
+ ```
75
+
76
+ `keep_patches=False` stacks the patches one at a time instead of keeping them all in memory, which is needed for large catalogues.
@@ -6,7 +6,7 @@
6
6
 
7
7
  # cmbstack
8
8
 
9
- Stack patches of the Cosmic Microwave Background (CMB) temperature sky. Go [here](https://cmbstack-docs.readthedocs.io/en/latest/index.html) to see the full documentation and a worked example.
9
+ Stack patches of HEALPix maps, such as the CMB temperature, CMB lensing or galaxy density, around their peaks or around the positions of a catalogue. Go [here](https://cmbstack-docs.readthedocs.io/en/latest/index.html) to see the full documentation, and [here](https://github.com/IsaacLP/cmbstack/tree/main/docs/tutorials) for worked examples.
10
10
 
11
11
  ## Installation
12
12
 
@@ -44,4 +44,16 @@ stacked_result = pipeline.run()
44
44
  ```python
45
45
  pipeline = StackingPipeline.from_map(sky_map)
46
46
  stacked_result = pipeline.run()
47
- ```
47
+ ```
48
+
49
+ A masked map (`healpy.ma`) keeps its mask: masked pixels are ignored, and `pipeline.counts` gives the number of patches contributing to each pixel.
50
+
51
+ **On the positions of a catalogue, with a rotation for each patch:**
52
+ ```python
53
+ # positions: (N, 2) array of (lon, lat) in degrees; angles: (N,) rotations in degrees
54
+ pipeline = StackingPipeline.from_map(sky_map)
55
+ stacked_result = pipeline.run(positions=positions, psi=angles, size_deg=2.4, size_deg_y=12.0,
56
+ profile=False, keep_patches=False)
57
+ ```
58
+
59
+ `keep_patches=False` stacks the patches one at a time instead of keeping them all in memory, which is needed for large catalogues.
@@ -0,0 +1,5 @@
1
+ __version__ = "0.2.0"
2
+
3
+ from cmbstack.pipeline import StackingPipeline
4
+
5
+ __all__ = ["StackingPipeline", "maps", "stacking"]
@@ -123,16 +123,22 @@ def normalize_map(m):
123
123
  Parameters
124
124
  ----------
125
125
  m : array_like
126
- Input HEALPix map. May contain UNSEEN/NaN pixels, which are ignored in
127
- the mean and standard deviation.
126
+ Input HEALPix map. May contain UNSEEN/NaN pixels or be a masked array
127
+ (e.g. from ``hp.ma``); these pixels are ignored in the mean and
128
+ standard deviation and set to UNSEEN in the output.
128
129
 
129
130
  Returns
130
131
  -------
131
132
  m_norm : numpy.ndarray
132
133
  The normalized map, with mean ~0 and std ~1.
133
134
  """
134
- m_clean = hp.remove_monopole(m)
135
- m_norm = m_clean / m_clean.std()
135
+ m = np.ma.filled(np.ma.asarray(m, dtype=float), hp.UNSEEN)
136
+ # mask_bad compares to UNSEEN with a tolerance, so UNSEEN stored as float32
137
+ # (e.g. Planck maps from hp.read_map) is still recognised; it also flags NaN/inf.
138
+ good = ~hp.mask_bad(m)
139
+ m_norm = np.full_like(m, hp.UNSEEN)
140
+ m_good = m[good] - m[good].mean()
141
+ m_norm[good] = m_good / m_good.std()
136
142
  return m_norm
137
143
 
138
144
 
@@ -35,7 +35,9 @@ class StackingPipeline:
35
35
  normalized : numpy.ndarray or None
36
36
  Set after run(); the normalized map.
37
37
  positions : numpy.ndarray or None
38
- Set after run(); the peak positions in (theta, phi) radians.
38
+ Set after run(); the peak or catalogue positions in (lon, lat) degrees.
39
+ psi : numpy.ndarray or None
40
+ Set after run(); the per-patch rotation angles in degrees, if any.
39
41
  patches : numpy.ndarray or None
40
42
  Set after run(); the extracted patches.
41
43
  stacked : numpy.ndarray or None
@@ -51,6 +53,7 @@ class StackingPipeline:
51
53
  self.nside = nside
52
54
  self.normalized = None
53
55
  self.positions = None
56
+ self.psi = None
54
57
  self.patches = None
55
58
  self.stacked = None
56
59
  self.radius = None
@@ -69,7 +72,8 @@ class StackingPipeline:
69
72
  def from_map(cls, sky_map):
70
73
  """Build a pipeline from a HEALPix map array already in memory. nside is inferred from the map length, so the caller doesn't have to supply it.
71
74
  """
72
- sky_map = np.asarray(sky_map)
75
+ # Masked pixels become UNSEEN; np.asarray alone would drop the mask.
76
+ sky_map = np.ma.filled(sky_map, hp.UNSEEN)
73
77
  nside = hp.npix2nside(sky_map.size)
74
78
  return cls(sky_map, nside)
75
79
 
@@ -80,19 +84,46 @@ class StackingPipeline:
80
84
  return cls.from_map(sky_map)
81
85
 
82
86
 
83
- def run(self, size_deg=10.0, reso_arcmin=3.0, profile=True, threshold=3.0, n_peaks=None):
87
+ def run(self, size_deg=10.0, reso_arcmin=3.0, profile=True, threshold=3.0, n_peaks=None,
88
+ positions=None, psi=None, min_count=1, mask_distance_deg=None, verbose=False,
89
+ size_deg_y=None, keep_patches=True):
84
90
  """Run the full stacking loop.
85
91
 
86
92
  Parameters
87
93
  ----------
88
94
  size_deg, reso_arcmin : float
89
- Patch geometry.
95
+ Patch geometry: side length in x (degrees) and pixel size (arcmin).
96
+ size_deg_y : float or None
97
+ Patch side length in y in degrees. None (default) gives a square
98
+ patch of side ``size_deg``.
90
99
  profile : bool
91
100
  Whether to also compute the radial profile.
92
101
  threshold : float
93
102
  Peak-finding threshold in units of the map std.
94
103
  n_peaks : int or None
95
104
  Maximum number of peaks to use; None means use all.
105
+ positions : array_like, shape (N, 2), optional
106
+ External catalogue positions as ``(lon, lat)`` in degrees.
107
+ If given, peak finding is skipped and ``threshold``/``n_peaks``
108
+ are ignored.
109
+ psi : array_like, shape (N,), optional
110
+ Per-patch rotation in degrees for oriented stacking.
111
+ min_count : int or float
112
+ Minimum number of contributing patches for a stacked pixel to be
113
+ kept (see :func:`stacking.stack_patches`). The per-pixel counts
114
+ are stored in ``self.counts``.
115
+ mask_distance_deg : float or None
116
+ Discard detected peaks with a masked pixel within this distance in
117
+ degrees (see :func:`stacking.find_peaks`). Peaks touching the mask
118
+ are always discarded. Ignored when ``positions`` is given. The
119
+ number of discarded peaks is stored in ``self.n_rejected``.
120
+ verbose : bool
121
+ If True, print how many peaks were kept and rejected by the mask cut.
122
+ keep_patches : bool
123
+ If True (default), keep every patch in ``self.patches``. If False,
124
+ stream the patches straight into the stack and set ``self.patches``
125
+ to None: memory then stays at about one patch whatever the number
126
+ of positions, which is needed for large catalogues.
96
127
 
97
128
  Returns
98
129
  -------
@@ -103,11 +134,27 @@ class StackingPipeline:
103
134
 
104
135
  self.normalized = maps.normalize_map(self.map)
105
136
 
106
- self.positions = stacking.find_peaks(self.normalized, self.nside, threshold=threshold, n_peaks=n_peaks)
107
-
108
- self.patches = stacking.extract_patches(self.normalized,self.positions,size_deg=size_deg,reso_arcmin=reso_arcmin)
109
-
110
- self.stacked = stacking.stack_patches(self.patches)
137
+ if positions is None:
138
+ self.positions, self.n_rejected = stacking.find_peaks(
139
+ self.normalized, self.nside, threshold=threshold, n_peaks=n_peaks,
140
+ mask_distance_deg=mask_distance_deg, return_n_rejected=True)
141
+ if verbose:
142
+ print(f"find_peaks: kept {len(self.positions)} peaks, "
143
+ f"rejected {self.n_rejected} near the mask")
144
+ else:
145
+ self.positions = np.atleast_2d(positions)
146
+ self.n_rejected = 0
147
+ if len(self.positions) == 0:
148
+ raise ValueError("No positions to stack on: no peaks found above the threshold.")
149
+ self.psi = psi
150
+
151
+ patches = stacking.iter_patches(self.normalized,self.positions,psi=self.psi,size_deg=size_deg,
152
+ size_deg_y=size_deg_y,reso_arcmin=reso_arcmin)
153
+ if keep_patches:
154
+ patches = list(patches)
155
+ self.patches = patches if keep_patches else None
156
+
157
+ self.stacked, self.counts = stacking.stack_patches(patches, min_count=min_count, return_counts=True)
111
158
 
112
159
  if profile:
113
160
  self.radius, self.profile = stacking.radial_profile(self.stacked,reso_arcmin=reso_arcmin)
@@ -0,0 +1,340 @@
1
+ """Stacking of patches around positions on a HEALPix map.
2
+
3
+ The pipeline is field-agnostic: it operates on any scalar HEALPix map (CMB
4
+ temperature, lensing convergence, a y-map, galaxy density, ...). Positions to
5
+ stack on can either be auto-detected peaks (local maxima) or supplied as an
6
+ external catalogue, so the same code serves peak stacking and
7
+ stacking-on-catalogue (clusters, voids, filaments, ...).
8
+
9
+ Typical use
10
+ -----------
11
+ >>> peaks = find_peaks(m, nside, threshold=3.0) # positions in (lon, lat), degrees
12
+ >>> patches = extract_patches(m, peaks) # fixed-grid 2D cutouts
13
+ >>> stacked = stack_patches(patches) # mean 2D image
14
+ >>> r, profile = radial_profile(stacked, reso_arcmin=3.0)
15
+
16
+ Oriented stacking on a catalogue, streaming the patches so that only one is in
17
+ memory at a time:
18
+
19
+ >>> patches = iter_patches(m, positions, psi=angles, size_deg=2.4, size_deg_y=12.0)
20
+ >>> stacked, counts = stack_patches(patches, return_counts=True)
21
+ """
22
+
23
+ import numpy as np
24
+ import healpy as hp
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Position finding
29
+ # ---------------------------------------------------------------------------
30
+ def find_peaks(sky_map, nside, threshold=None, n_peaks=None, mask_distance_deg=None,
31
+ return_n_rejected=False):
32
+ """Find local maxima of a HEALPix map and return their sky positions.
33
+
34
+ A pixel is a local maximum if its value is strictly greater than all of its
35
+ immediate HEALPix neighbours. Peaks can be filtered by a significance
36
+ threshold and/or capped at the ``n_peaks`` highest.
37
+
38
+ Peaks next to masked (UNSEEN) pixels are discarded: there a pixel only has
39
+ to beat its unmasked neighbours, so the true maximum may lie under the mask.
40
+
41
+ Parameters
42
+ ----------
43
+ sky_map : numpy.ndarray
44
+ Input HEALPix map (RING ordering). For a normalised map, values are in
45
+ units of sigma, so ``threshold`` is a significance nu.
46
+ nside : int
47
+ HEALPix resolution parameter of ``sky_map``.
48
+ threshold : float, optional
49
+ If given, keep only peaks with value greater than this (e.g. 3.0 for
50
+ 3-sigma peaks on a normalised map). Default None (no threshold).
51
+ n_peaks : int, optional
52
+ If given, keep only the ``n_peaks`` highest peaks (applied after the
53
+ threshold and the mask cut). Default None (keep all).
54
+ mask_distance_deg : float, optional
55
+ If given, also discard peaks with any masked pixel within this angular
56
+ distance in degrees. Default None (only the immediate neighbours are
57
+ checked).
58
+ return_n_rejected : bool, optional
59
+ If True, also return the number of peaks discarded for being next to
60
+ (or within ``mask_distance_deg`` of) the mask. Default False.
61
+
62
+ Returns
63
+ -------
64
+ positions : numpy.ndarray, shape (N, 2)
65
+ Sky positions of the selected peaks as ``(lon, lat)`` in degrees, the
66
+ same format accepted by :func:`extract_patches`.
67
+ n_rejected : int
68
+ Number of peaks discarded by the mask cut (before ``threshold`` and
69
+ ``n_peaks``). Only returned if ``return_n_rejected`` is True.
70
+ """
71
+ # hp.hotspots returns (max_map, minima_pix, maxima_pix); we want the maxima.
72
+ _, _, maxima_pix = hp.hotspots(sky_map)
73
+ maxima_pix = np.asarray(maxima_pix)
74
+
75
+ # Drop peaks touching the mask (or within mask_distance_deg of it).
76
+ bad = hp.mask_bad(sky_map)
77
+ n_rejected = 0
78
+ if bad.any() and maxima_pix.size:
79
+ neighbours = hp.get_all_neighbours(nside, maxima_pix)
80
+ near_mask = np.any((neighbours >= 0) & bad[neighbours], axis=0)
81
+ if mask_distance_deg is not None:
82
+ radius = np.radians(mask_distance_deg)
83
+ vecs = hp.pix2vec(nside, maxima_pix)
84
+ near_mask |= [
85
+ bad[hp.query_disc(nside, v, radius, inclusive=True)].any()
86
+ for v in np.transpose(vecs)
87
+ ]
88
+ n_rejected = int(np.count_nonzero(near_mask))
89
+ maxima_pix = maxima_pix[~near_mask]
90
+
91
+ values = sky_map[maxima_pix]
92
+
93
+ if threshold is not None: # Add check for minimmun threshold/n_peaks and ratio between threshold and n_peaks
94
+ keep = values > threshold
95
+ maxima_pix = maxima_pix[keep]
96
+ values = values[keep]
97
+
98
+ # Sort highest-first so n_peaks keeps the most significant.
99
+ order = np.argsort(values)[::-1]
100
+ maxima_pix = maxima_pix[order]
101
+
102
+ if n_peaks is not None:
103
+ maxima_pix = maxima_pix[:n_peaks]
104
+
105
+ lon, lat = hp.pix2ang(nside, maxima_pix,lonlat=True)
106
+ positions = np.column_stack([lon, lat])
107
+ if return_n_rejected:
108
+ return positions, n_rejected
109
+ return positions
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Patch extraction
114
+ # ---------------------------------------------------------------------------
115
+ def size_mpc_to_deg(size_mpc, z, cosmo):
116
+ """Convert a physical size in Mpc to an angular size in degrees.
117
+
118
+ Uses the angular diameter distance at redshift ``z`` from the given
119
+ cosmology.
120
+
121
+ Parameters
122
+ ----------
123
+ size_mpc : float
124
+ Physical size in Mpc.
125
+ z : float
126
+ Redshift.
127
+ cosmo : astropy.cosmology.Cosmology
128
+ Cosmology object to compute the angular diameter distance.
129
+
130
+ Returns
131
+ -------
132
+ size_deg : float
133
+ Angular size in degrees.
134
+ """
135
+ d_a = cosmo.angular_diameter_distance(z).value # Mpc
136
+ size_rad = size_mpc / d_a # radians
137
+ size_deg = np.degrees(size_rad)
138
+ return size_deg
139
+
140
+
141
+ def extract_patches(sky_map, positions, psi=None, size_deg=10.0, size_deg_y=None, reso_arcmin=3.0):
142
+ """Extract fixed-grid gnomonic patches centred on each position.
143
+
144
+ Each patch is a square or a rectangular 2D array produced by a gnomonic (tangent-plane)
145
+ projection centred on the position, so every patch shares the same grid and
146
+ the centre pixel always corresponds to the position itself.
147
+
148
+ Parameters
149
+ ----------
150
+ sky_map : numpy.ndarray
151
+ Input HEALPix map (RING ordering). Any scalar field.
152
+ positions : array_like, shape (N, 2)
153
+ Sky positions as ``(lon, lat)`` in degrees (e.g. the output of
154
+ :func:`find_peaks`, or an external catalogue converted to this format).
155
+ psi : array_like, shape (N,), optional
156
+ Rotation angle in degrees for each patch, passed to the gnomonic projector's
157
+ ``rot``. If None, defaults to 0.0.
158
+ size_deg : float, optional
159
+ Full side length of the patch along x in degrees (along the direction
160
+ given by ``psi`` for a rotated patch). Default 10.0.
161
+ size_deg_y : float, optional
162
+ Full side length of the patch along y in degrees. If None, defaults
163
+ to ``size_deg`` (square patch). Default None.
164
+ reso_arcmin : float, optional
165
+ Pixel size of the projected patch in arcminutes. Default 3.0.
166
+
167
+ Returns
168
+ -------
169
+ patches : list of numpy.ndarray
170
+ One 2D array per position, all of shape ``(ysize, xsize)`` with
171
+ ``xsize = size_deg * 60 / reso_arcmin`` and
172
+ ``ysize = size_deg_y * 60 / reso_arcmin``. Masked pixels are NaN.
173
+
174
+ See Also
175
+ --------
176
+ iter_patches : The same patches one at a time, without keeping them all in
177
+ memory.
178
+ """
179
+ return list(iter_patches(sky_map, positions, psi=psi, size_deg=size_deg,
180
+ size_deg_y=size_deg_y, reso_arcmin=reso_arcmin))
181
+
182
+
183
+ def iter_patches(sky_map, positions, psi=None, size_deg=10.0, size_deg_y=None, reso_arcmin=3.0):
184
+ """Yield the patches of :func:`extract_patches` one at a time.
185
+
186
+ Takes the same arguments as :func:`extract_patches`. Only one patch is in
187
+ memory at a time, so passing this generator to :func:`stack_patches` stacks
188
+ any number of positions in constant memory.
189
+
190
+ Yields
191
+ ------
192
+ patch : numpy.ndarray
193
+ 2D array of shape ``(ysize, xsize)`` for the next position, with masked
194
+ pixels set to NaN.
195
+ """
196
+ positions = np.atleast_2d(positions)
197
+ if size_deg_y is None:
198
+ size_deg_y = size_deg
199
+ xsize = int(round(size_deg * 60.0 / reso_arcmin))
200
+ ysize = int(round(size_deg_y * 60.0 / reso_arcmin))
201
+
202
+ # Masked arrays become plain arrays with UNSEEN at the masked pixels.
203
+ sky_map = hp.pixelfunc.ma_to_array(sky_map)
204
+ nside = hp.get_nside(sky_map)
205
+
206
+ def vec2pix(x, y, z):
207
+ return hp.vec2pix(nside, x, y, z)
208
+
209
+ if psi is None:
210
+ psi = np.zeros(len(positions))
211
+ for lon, lat, angle in zip(positions[:, 0], positions[:, 1], psi):
212
+ # Use the projector directly: same result as gnomview, but without
213
+ # building a matplotlib figure per patch. rot expects (lon, lat) in degrees.
214
+ proj = hp.projector.GnomonicProj(
215
+ rot=(lon, lat, angle), xsize=xsize, ysize=ysize, reso=reso_arcmin
216
+ )
217
+ patch = proj.projmap(sky_map, vec2pix).astype(float)
218
+ # Masked (UNSEEN) pixels become NaN so stack_patches ignores them.
219
+ patch[hp.mask_bad(patch)] = np.nan
220
+ yield patch
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Stacking
225
+ # ---------------------------------------------------------------------------
226
+ def stack_patches(patches, min_count=1, return_counts=False):
227
+ """Average patches pixel-by-pixel into a single stacked image.
228
+
229
+ Because every patch shares the same fixed grid (see :func:`extract_patches`),
230
+ the mean is a genuine stacked image: incoherent noise averages towards zero
231
+ while the coherent central profile survives. NaN pixels (masked or UNSEEN
232
+ regions of the map) are ignored.
233
+
234
+ The function counts the number of times each pixel contributes (its number
235
+ of valid, finite values across patches) and divides the sum by that count,
236
+ rather than by the total number of patches. Pixels with fewer than
237
+ ``min_count`` contributions are set to NaN.
238
+
239
+ Parameters
240
+ ----------
241
+ patches : iterable of numpy.ndarray
242
+ Patches of identical shape: a list such as the output of
243
+ :func:`extract_patches`, or a generator such as :func:`iter_patches`.
244
+ The patches are accumulated one at a time, so a generator never needs
245
+ all of them in memory.
246
+ min_count : int or float, optional
247
+ Minimum number of contributions for a pixel to be kept. A float in
248
+ (0, 1) is a fraction of the number of patches. Default 1 (keep every
249
+ pixel with at least one contribution).
250
+ return_counts : bool, optional
251
+ If True, also return the per-pixel count map. Default False.
252
+
253
+ Returns
254
+ -------
255
+ stacked : numpy.ndarray
256
+ The mean 2D patch. Display with ``plt.imshow(stacked)``.
257
+ counts : numpy.ndarray of int
258
+ Number of patches contributing to each pixel. Only returned if
259
+ ``return_counts`` is True.
260
+ """
261
+ # Running sum and per-pixel count, so only one patch is needed at a time.
262
+ total = counts = None
263
+ n_patches = 0
264
+ for patch in patches:
265
+ patch = np.asarray(patch, dtype=float)
266
+ if total is None:
267
+ total = np.zeros(patch.shape)
268
+ counts = np.zeros(patch.shape, dtype=int)
269
+ elif patch.shape != total.shape:
270
+ raise ValueError(f"Patch {n_patches} has shape {patch.shape}, expected {total.shape}.")
271
+ valid = np.isfinite(patch)
272
+ total += np.where(valid, patch, 0.0)
273
+ counts += valid
274
+ n_patches += 1
275
+ if n_patches == 0:
276
+ raise ValueError("No patches to stack.")
277
+
278
+ if isinstance(min_count, float) and 0.0 < min_count < 1.0:
279
+ min_count = int(np.ceil(min_count * n_patches))
280
+ min_count = max(int(min_count), 1)
281
+
282
+ mean_stacked = np.full(counts.shape, np.nan)
283
+ np.divide(total, counts, out=mean_stacked, where=counts >= min_count)
284
+
285
+ if return_counts:
286
+ return mean_stacked, counts
287
+ return mean_stacked
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Profile characterisation
292
+ # ---------------------------------------------------------------------------
293
+ def radial_profile(stacked, reso_arcmin=3.0, n_bins=None):
294
+ """Azimuthally average a stacked patch into a 1D radial profile.
295
+
296
+ Collapses the 2D stacked image to value-versus-radius by averaging in
297
+ concentric annuli about the centre. This 1D profile is the characterisation
298
+ of the mean peak: a central maximum, and (for CMB temperature) a faint
299
+ acoustic ring further out.
300
+
301
+ Parameters
302
+ ----------
303
+ stacked : numpy.ndarray
304
+ Square 2D stacked patch from :func:`stack_patches`.
305
+ reso_arcmin : float, optional
306
+ Pixel size in arcminutes, so the returned radius is in physical angular
307
+ units. Default 3.0.
308
+ n_bins : int, optional
309
+ Number of radial bins. Default is half the patch side length.
310
+
311
+ Returns
312
+ -------
313
+ radius_arcmin : numpy.ndarray
314
+ Bin-centre radii in arcminutes.
315
+ profile : numpy.ndarray
316
+ Mean value in each annulus.
317
+ """
318
+ ny, nx = stacked.shape
319
+ cy, cx = (ny - 1) / 2.0, (nx - 1) / 2.0
320
+
321
+ y, x = np.indices(stacked.shape)
322
+ r_pix = np.sqrt((x - cx) ** 2 + (y - cy) ** 2)
323
+
324
+ if n_bins is None:
325
+ n_bins = nx // 2
326
+
327
+ r_max = nx // 2
328
+ bin_edges = np.linspace(0, r_max, n_bins + 1)
329
+ bin_centres = 0.5 * (bin_edges[:-1] + bin_edges[1:])
330
+
331
+ profile = np.full(n_bins, np.nan)
332
+ flat_r = r_pix.ravel()
333
+ flat_v = stacked.ravel()
334
+ for i in range(n_bins):
335
+ in_bin = (flat_r >= bin_edges[i]) & (flat_r < bin_edges[i + 1])
336
+ if np.any(in_bin):
337
+ profile[i] = np.nanmean(flat_v[in_bin])
338
+
339
+ radius_arcmin = bin_centres * reso_arcmin
340
+ return radius_arcmin, profile
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cmbstack
3
- Version: 0.1.1
4
- Summary: Stack patches of the CMB temperature sky around local maxima
3
+ Version: 0.2.0
4
+ Summary: Stack patches of HEALPix maps around peaks or catalogue positions
5
5
  Author-email: Isaac Alexis López Paredes <isaacalexis.lopezparedes@students.uniroma2.eu>, Anushka Sanjay Tilekar <anushka.tilekar.23@alumni.ucl.ac.uk>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://cmbstack-docs.readthedocs.io/en/latest/index.html
@@ -23,7 +23,7 @@ Requires-Dist: matplotlib
23
23
 
24
24
  # cmbstack
25
25
 
26
- Stack patches of the Cosmic Microwave Background (CMB) temperature sky. Go [here](https://cmbstack-docs.readthedocs.io/en/latest/index.html) to see the full documentation and a worked example.
26
+ Stack patches of HEALPix maps, such as the CMB temperature, CMB lensing or galaxy density, around their peaks or around the positions of a catalogue. Go [here](https://cmbstack-docs.readthedocs.io/en/latest/index.html) to see the full documentation, and [here](https://github.com/IsaacLP/cmbstack/tree/main/docs/tutorials) for worked examples.
27
27
 
28
28
  ## Installation
29
29
 
@@ -62,3 +62,15 @@ stacked_result = pipeline.run()
62
62
  pipeline = StackingPipeline.from_map(sky_map)
63
63
  stacked_result = pipeline.run()
64
64
  ```
65
+
66
+ A masked map (`healpy.ma`) keeps its mask: masked pixels are ignored, and `pipeline.counts` gives the number of patches contributing to each pixel.
67
+
68
+ **On the positions of a catalogue, with a rotation for each patch:**
69
+ ```python
70
+ # positions: (N, 2) array of (lon, lat) in degrees; angles: (N,) rotations in degrees
71
+ pipeline = StackingPipeline.from_map(sky_map)
72
+ stacked_result = pipeline.run(positions=positions, psi=angles, size_deg=2.4, size_deg_y=12.0,
73
+ profile=False, keep_patches=False)
74
+ ```
75
+
76
+ `keep_patches=False` stacks the patches one at a time instead of keeping them all in memory, which is needed for large catalogues.
@@ -10,5 +10,6 @@ cmbstack.egg-info/dependency_links.txt
10
10
  cmbstack.egg-info/requires.txt
11
11
  cmbstack.egg-info/top_level.txt
12
12
  tests/test_find_peaks.py
13
+ tests/test_mask.py
13
14
  tests/test_round.py
14
15
  tests/test_runtime.py
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "cmbstack"
7
7
  dynamic = ["version"]
8
- description = "Stack patches of the CMB temperature sky around local maxima"
8
+ description = "Stack patches of HEALPix maps around peaks or catalogue positions"
9
9
  readme = "README.md"
10
10
  authors = [
11
11
  {name = "Isaac Alexis López Paredes", email = "isaacalexis.lopezparedes@students.uniroma2.eu"},
@@ -0,0 +1,92 @@
1
+ import numpy as np
2
+ import healpy as hp
3
+
4
+ from cmbstack.stacking import find_peaks
5
+ from cmbstack.maps import normalize_map
6
+
7
+
8
+ def test_single_peak():
9
+ '''Test if find_peaks returns the corrected coordinates for a known peak'''
10
+ nside = 64
11
+ n_pix = hp.nside2npix(nside)
12
+ m = np.zeros(n_pix)
13
+
14
+ spike_pix = n_pix // 2
15
+ m[spike_pix] = 10.0
16
+
17
+ positions = find_peaks(m, nside=nside, threshold=5.0)
18
+
19
+ assert len(positions) == 1
20
+
21
+ lon_expected, lat_expected = hp.pix2ang(nside, spike_pix, lonlat=True)
22
+ lon_found, lat_found = positions[0]
23
+
24
+ assert np.isclose(lon_found, lon_expected)
25
+ assert np.isclose(lat_found, lat_expected)
26
+
27
+
28
+ def test_high_threshold():
29
+ '''Test if find_peaks returns empty array for very high threshold'''
30
+ cl = np.zeros(384)
31
+ cl[2:] = 1.0/np.arange(2,384)**2
32
+ np.random.seed(0)
33
+ m = hp.synfast(cl, nside=128)
34
+ m_norm = normalize_map(m)
35
+ threshold = 1e3
36
+
37
+ positions = find_peaks(m_norm,nside=128,threshold=threshold)
38
+
39
+ assert positions.size == 0
40
+
41
+
42
+ def test_high_n_peaks():
43
+ '''Test if find_peaks returns all the peaks when n_peaks is greater than the total number of found peaks'''
44
+ cl = np.zeros(384)
45
+ cl[2:] = 1.0/np.arange(2,384)**2
46
+ np.random.seed(0)
47
+ m = hp.synfast(cl, nside=128)
48
+
49
+ positions = find_peaks(m,nside=128)
50
+
51
+ n_peaks = len(positions)
52
+
53
+ test_n_peaks = n_peaks + 1
54
+
55
+ new_positions = find_peaks(m,nside=128,n_peaks=test_n_peaks)
56
+
57
+ assert len(new_positions) == len(positions)
58
+
59
+
60
+ def test_peaks_near_mask_dropped():
61
+ '''Test if find_peaks discards peaks touching the mask, or within mask_distance_deg of it'''
62
+ nside = 64
63
+ m = np.zeros(hp.nside2npix(nside))
64
+
65
+ edge_pix = hp.ang2pix(nside, 0.0, 0.0, lonlat=True)
66
+ far_pix = hp.ang2pix(nside, 180.0, 0.0, lonlat=True)
67
+ mid_pix = hp.ang2pix(nside, 0.0, 10.0, lonlat=True)
68
+ m[[edge_pix, far_pix, mid_pix]] = 10.0
69
+
70
+ # Mask a neighbour of edge_pix, plus a pixel 5 deg away from mid_pix.
71
+ m[hp.get_all_neighbours(nside, edge_pix)[0]] = hp.UNSEEN
72
+ m[hp.ang2pix(nside, 0.0, 15.0, lonlat=True)] = hp.UNSEEN
73
+
74
+ positions, n_rejected = find_peaks(m, nside=nside, threshold=5.0, return_n_rejected=True)
75
+ found = hp.ang2pix(nside, positions[:, 0], positions[:, 1], lonlat=True)
76
+ assert set(found) == {far_pix, mid_pix}
77
+ assert n_rejected == 1
78
+
79
+ positions, n_rejected = find_peaks(m, nside=nside, threshold=5.0, mask_distance_deg=6.0,
80
+ return_n_rejected=True)
81
+ found = hp.ang2pix(nside, positions[:, 0], positions[:, 1], lonlat=True)
82
+ assert set(found) == {far_pix}
83
+ assert n_rejected == 2
84
+
85
+
86
+ if __name__ == '__main__':
87
+
88
+ test_high_threshold()
89
+
90
+ test_high_n_peaks()
91
+
92
+ test_single_peak()
@@ -0,0 +1,145 @@
1
+ import warnings
2
+
3
+ import numpy as np
4
+ import healpy as hp
5
+ import pytest
6
+
7
+ from cmbstack import StackingPipeline
8
+ from cmbstack.maps import normalize_map
9
+ from cmbstack.stacking import stack_patches, extract_patches, iter_patches
10
+
11
+
12
+ def _masked_map(nside=32, dtype=np.float64):
13
+ np.random.seed(0)
14
+ m = np.random.normal(5.0, 2.0, hp.nside2npix(nside)).astype(dtype)
15
+ m_masked = hp.ma(m)
16
+ m_masked.mask = np.zeros(m.size, dtype=bool)
17
+ m_masked.mask[: m.size // 4] = True
18
+ return m, m_masked
19
+
20
+
21
+ @pytest.mark.parametrize("dtype", [np.float64, np.float32])
22
+ def test_normalize_map_keeps_mask(dtype):
23
+ '''Test if normalize_map sets masked pixels to UNSEEN and ignores them in the statistics'''
24
+ _, m_masked = _masked_map(dtype=dtype)
25
+ mask = m_masked.mask
26
+
27
+ m_norm = normalize_map(m_masked)
28
+
29
+ assert np.all(m_norm[mask] == hp.UNSEEN)
30
+ assert np.isclose(m_norm[~mask].mean(), 0.0, atol=1e-6)
31
+ assert np.isclose(m_norm[~mask].std(), 1.0)
32
+
33
+
34
+ @pytest.mark.parametrize("dtype", [np.float64, np.float32])
35
+ def test_from_map_keeps_mask(dtype):
36
+ '''Test if StackingPipeline.from_map preserves the mask of a masked array through run()'''
37
+ _, m_masked = _masked_map(dtype=dtype)
38
+ mask = m_masked.mask
39
+
40
+ pipeline = StackingPipeline.from_map(m_masked)
41
+ assert np.all(hp.mask_bad(pipeline.map) == mask)
42
+
43
+ pipeline.run(threshold=2.0, profile=False)
44
+ assert np.all(pipeline.normalized[mask] == hp.UNSEEN)
45
+
46
+ # Peaks are found, and none of them inside the masked region.
47
+ assert len(pipeline.positions) > 0
48
+ pix = hp.ang2pix(pipeline.nside, pipeline.positions[:, 0], pipeline.positions[:, 1], lonlat=True)
49
+ assert not np.any(mask[pix])
50
+
51
+
52
+ def test_run_rectangular_patches():
53
+ '''Test if run() passes size_deg_y through to the patches and the stack'''
54
+ _, m_masked = _masked_map()
55
+ pipeline = StackingPipeline.from_map(m_masked).run(size_deg=10.0, size_deg_y=6.0,
56
+ reso_arcmin=30.0, threshold=2.0,
57
+ profile=False)
58
+
59
+ # extract_patches returns (ysize, xsize) arrays.
60
+ assert pipeline.patches[0].shape == (12, 20)
61
+ assert pipeline.stacked.shape == (12, 20)
62
+
63
+
64
+ def test_stack_patches_counts():
65
+ '''Test if stack_patches divides by the per-pixel count and applies min_count'''
66
+ nan = np.nan
67
+ patches = [
68
+ np.array([[1.0, 2.0], [nan, nan]]),
69
+ np.array([[3.0, nan], [5.0, nan]]),
70
+ np.array([[5.0, 4.0], [nan, nan]]),
71
+ ]
72
+
73
+ with warnings.catch_warnings():
74
+ warnings.simplefilter("error") # no "Mean of empty slice" for the all-NaN pixel
75
+ stacked, counts = stack_patches(patches, return_counts=True)
76
+
77
+ assert np.array_equal(counts, [[3, 2], [1, 0]])
78
+ assert np.allclose(stacked, [[3.0, 3.0], [5.0, nan]], equal_nan=True)
79
+
80
+ # Pixels seen by fewer than 2 patches are dropped, also as a fraction.
81
+ expected = [[3.0, 3.0], [nan, nan]]
82
+ assert np.allclose(stack_patches(patches, min_count=2), expected, equal_nan=True)
83
+ assert np.allclose(stack_patches(patches, min_count=0.5), expected, equal_nan=True)
84
+
85
+
86
+ def _masked_patch_args():
87
+ '''Masked map, random positions and angles for the streaming tests'''
88
+ _, m_masked = _masked_map()
89
+ rng = np.random.default_rng(1)
90
+ positions = np.column_stack([rng.uniform(0, 360, 50), rng.uniform(-60, 60, 50)])
91
+ psi = rng.uniform(0, 180, 50)
92
+ return m_masked, positions, dict(psi=psi, size_deg=10.0, size_deg_y=6.0, reso_arcmin=30.0)
93
+
94
+
95
+ def test_iter_patches_matches_extract_patches():
96
+ '''Test if iter_patches yields the same patches as extract_patches, in the same order'''
97
+ m_masked, positions, kwargs = _masked_patch_args()
98
+
99
+ listed = extract_patches(m_masked, positions, **kwargs)
100
+ streamed = list(iter_patches(m_masked, positions, **kwargs))
101
+
102
+ assert len(streamed) == len(listed) == len(positions)
103
+ for a, b in zip(listed, streamed):
104
+ assert np.array_equal(a, b, equal_nan=True)
105
+
106
+
107
+ def test_stack_patches_generator_matches_list():
108
+ '''Test if stacking a generator gives the same stack and counts as stacking a list'''
109
+ m_masked, positions, kwargs = _masked_patch_args()
110
+ patches = extract_patches(m_masked, positions, **kwargs)
111
+ assert any(np.isnan(p).any() for p in patches) # the mask reaches some patches
112
+
113
+ for min_count in [1, 0.9]:
114
+ s_list, c_list = stack_patches(patches, min_count=min_count, return_counts=True)
115
+ s_gen, c_gen = stack_patches(iter_patches(m_masked, positions, **kwargs),
116
+ min_count=min_count, return_counts=True)
117
+ assert np.array_equal(c_list, c_gen)
118
+ assert np.allclose(s_list, s_gen, equal_nan=True)
119
+
120
+
121
+ def test_stack_patches_errors():
122
+ '''Test if stack_patches rejects an empty input and patches of different shapes'''
123
+ with pytest.raises(ValueError):
124
+ stack_patches(iter([]))
125
+ with pytest.raises(ValueError):
126
+ stack_patches([np.zeros((2, 2)), np.zeros((2, 3))])
127
+
128
+
129
+ def test_run_keep_patches_false():
130
+ '''Test if run(keep_patches=False) gives the same stack and counts without storing the patches'''
131
+ _, m_masked = _masked_map()
132
+ kwargs = dict(size_deg=10.0, reso_arcmin=30.0, threshold=2.0)
133
+
134
+ kept = StackingPipeline.from_map(m_masked).run(**kwargs)
135
+ streamed = StackingPipeline.from_map(m_masked).run(keep_patches=False, **kwargs)
136
+
137
+ assert streamed.patches is None
138
+ assert len(kept.patches) == len(streamed.positions)
139
+ assert np.array_equal(kept.counts, streamed.counts)
140
+ assert np.allclose(kept.stacked, streamed.stacked, equal_nan=True)
141
+ assert np.allclose(kept.profile, streamed.profile, equal_nan=True)
142
+
143
+
144
+ if __name__ == "__main__":
145
+ pytest.main([__file__])
@@ -1,5 +0,0 @@
1
- __version__ = "0.1.1"
2
-
3
- from cmbstack.pipeline import StackingPipeline
4
-
5
- __all__ = ["StackingPipeline", "maps", "stacking"]
@@ -1,198 +0,0 @@
1
- """Stacking of patches around positions on a HEALPix map.
2
-
3
- The pipeline is field-agnostic: it operates on any scalar HEALPix map (CMB
4
- temperature, lensing convergence, a y-map, galaxy density, ...). Positions to
5
- stack on can either be auto-detected peaks (local maxima) or supplied as an
6
- external catalogue, so the same code serves peak stacking and
7
- stacking-on-catalogue (clusters, voids, filaments, ...).
8
-
9
- Typical use
10
- -----------
11
- >>> peaks = find_peaks(m, nside, threshold=3.0) # positions in (theta, phi)
12
- >>> patches = extract_patches(m, peaks) # fixed-grid 2D cutouts
13
- >>> stacked = stack_patches(patches) # mean 2D image
14
- >>> r, profile = radial_profile(stacked, reso_arcmin=3.0)
15
- """
16
-
17
- import numpy as np
18
- import healpy as hp
19
-
20
-
21
- # ---------------------------------------------------------------------------
22
- # Position finding
23
- # ---------------------------------------------------------------------------
24
- def find_peaks(sky_map, nside, threshold=None, n_peaks=None):
25
- """Find local maxima of a HEALPix map and return their sky positions.
26
-
27
- A pixel is a local maximum if its value is strictly greater than all of its
28
- immediate HEALPix neighbours. Peaks can be filtered by a significance
29
- threshold and/or capped at the ``n_peaks`` highest.
30
-
31
- Parameters
32
- ----------
33
- sky_map : numpy.ndarray
34
- Input HEALPix map (RING ordering). For a normalised map, values are in
35
- units of sigma, so ``threshold`` is a significance nu.
36
- nside : int
37
- HEALPix resolution parameter of ``sky_map``.
38
- threshold : float, optional
39
- If given, keep only peaks with value greater than this (e.g. 3.0 for
40
- 3-sigma peaks on a normalised map). Default None (no threshold).
41
- n_peaks : int, optional
42
- If given, keep only the ``n_peaks`` highest peaks (applied after the
43
- threshold). Default None (keep all).
44
-
45
- Returns
46
- -------
47
- positions : numpy.ndarray, shape (N, 2)
48
- Sky positions of the selected peaks as ``(theta, phi)`` in radians, the
49
- same format accepted by :func:`extract_patches`.
50
- """
51
- # hp.hotspots returns (max_map, minima_pix, maxima_pix); we want the maxima.
52
- _, _, maxima_pix = hp.hotspots(sky_map)
53
- maxima_pix = np.asarray(maxima_pix)
54
-
55
- values = sky_map[maxima_pix]
56
-
57
- if threshold is not None: # Add check for minimmun threshold/n_peaks and ratio between threshold and n_peaks
58
- keep = values > threshold
59
- maxima_pix = maxima_pix[keep]
60
- values = values[keep]
61
-
62
- # Sort highest-first so n_peaks keeps the most significant.
63
- order = np.argsort(values)[::-1]
64
- maxima_pix = maxima_pix[order]
65
-
66
- if n_peaks is not None:
67
- maxima_pix = maxima_pix[:n_peaks]
68
-
69
- theta, phi = hp.pix2ang(nside, maxima_pix)
70
- return np.column_stack([theta, phi])
71
-
72
-
73
- # ---------------------------------------------------------------------------
74
- # Patch extraction
75
- # ---------------------------------------------------------------------------
76
- def extract_patches(sky_map, positions, size_deg=10.0, reso_arcmin=3.0):
77
- """Extract fixed-grid gnomonic patches centred on each position.
78
-
79
- Each patch is a square 2D array produced by a gnomonic (tangent-plane)
80
- projection centred on the position, so every patch shares the same grid and
81
- the centre pixel always corresponds to the position itself.
82
-
83
- Parameters
84
- ----------
85
- sky_map : numpy.ndarray
86
- Input HEALPix map (RING ordering). Any scalar field.
87
- positions : array_like, shape (N, 2)
88
- Sky positions as ``(theta, phi)`` in radians (e.g. the output of
89
- :func:`find_peaks`, or an external catalogue converted to this format).
90
- size_deg : float, optional
91
- Full side length of the square patch in degrees. Default 10.0.
92
- reso_arcmin : float, optional
93
- Pixel size of the projected patch in arcminutes. Default 3.0.
94
-
95
- Returns
96
- -------
97
- patches : list of numpy.ndarray
98
- One square 2D array per position, all of identical shape
99
- ``(xsize, xsize)`` with ``xsize = size_deg * 60 / reso_arcmin``.
100
- """
101
- positions = np.atleast_2d(positions)
102
- xsize = int(round(size_deg * 60.0 / reso_arcmin))
103
-
104
- patches = []
105
- for theta, phi in positions:
106
- # gnomview's rot expects (lon, lat) in degrees.
107
- lon = np.degrees(phi)
108
- lat = 90.0 - np.degrees(theta)
109
- patch = hp.gnomview(
110
- sky_map,
111
- rot=(lon, lat),
112
- xsize=xsize,
113
- reso=reso_arcmin,
114
- return_projected_map=True,
115
- no_plot=True,
116
- )
117
- patches.append(np.asarray(patch))
118
-
119
- return patches
120
-
121
-
122
- # ---------------------------------------------------------------------------
123
- # Stacking
124
- # ---------------------------------------------------------------------------
125
- def stack_patches(patches):
126
- """Average patches pixel-by-pixel into a single stacked image.
127
-
128
- Because every patch shares the same fixed grid (see :func:`extract_patches`),
129
- the mean is a genuine stacked image: incoherent noise averages towards zero
130
- while the coherent central profile survives. NaN/UNSEEN pixels (patch edges
131
- that fall off the map near the poles) are ignored.
132
-
133
- Parameters
134
- ----------
135
- patches : sequence of numpy.ndarray
136
- Patches of identical shape, e.g. the output of :func:`extract_patches`.
137
-
138
- Returns
139
- -------
140
- stacked : numpy.ndarray
141
- The mean 2D patch. Display with ``plt.imshow(stacked)``.
142
- """
143
- stack = np.array(patches, dtype=float)
144
- mean_stacked = np.nanmean(stack, axis=0)
145
- return mean_stacked
146
-
147
-
148
- # ---------------------------------------------------------------------------
149
- # Profile characterisation
150
- # ---------------------------------------------------------------------------
151
- def radial_profile(stacked, reso_arcmin=3.0, n_bins=None):
152
- """Azimuthally average a stacked patch into a 1D radial profile.
153
-
154
- Collapses the 2D stacked image to value-versus-radius by averaging in
155
- concentric annuli about the centre. This 1D profile is the characterisation
156
- of the mean peak: a central maximum, and (for CMB temperature) a faint
157
- acoustic ring further out.
158
-
159
- Parameters
160
- ----------
161
- stacked : numpy.ndarray
162
- Square 2D stacked patch from :func:`stack_patches`.
163
- reso_arcmin : float, optional
164
- Pixel size in arcminutes, so the returned radius is in physical angular
165
- units. Default 3.0.
166
- n_bins : int, optional
167
- Number of radial bins. Default is half the patch side length.
168
-
169
- Returns
170
- -------
171
- radius_arcmin : numpy.ndarray
172
- Bin-centre radii in arcminutes.
173
- profile : numpy.ndarray
174
- Mean value in each annulus.
175
- """
176
- ny, nx = stacked.shape
177
- cy, cx = (ny - 1) / 2.0, (nx - 1) / 2.0
178
-
179
- y, x = np.indices(stacked.shape)
180
- r_pix = np.sqrt((x - cx) ** 2 + (y - cy) ** 2)
181
-
182
- if n_bins is None:
183
- n_bins = nx // 2
184
-
185
- r_max = nx // 2
186
- bin_edges = np.linspace(0, r_max, n_bins + 1)
187
- bin_centres = 0.5 * (bin_edges[:-1] + bin_edges[1:])
188
-
189
- profile = np.full(n_bins, np.nan)
190
- flat_r = r_pix.ravel()
191
- flat_v = stacked.ravel()
192
- for i in range(n_bins):
193
- in_bin = (flat_r >= bin_edges[i]) & (flat_r < bin_edges[i + 1])
194
- if np.any(in_bin):
195
- profile[i] = np.nanmean(flat_v[in_bin])
196
-
197
- radius_arcmin = bin_centres * reso_arcmin
198
- return radius_arcmin, profile
@@ -1,66 +0,0 @@
1
- import numpy as np
2
- import healpy as hp
3
-
4
- from cmbstack.stacking import find_peaks
5
- from cmbstack.maps import normalize_map
6
-
7
-
8
- def test_single_peak():
9
- '''Test if find_peaks returns the corrected coordinates for a known peak'''
10
- nside = 64
11
- n_pix = hp.nside2npix(nside)
12
- m = np.zeros(n_pix)
13
-
14
- spike_pix = n_pix // 2
15
- m[spike_pix] = 10.0
16
-
17
- positions = find_peaks(m, nside=nside, threshold=5.0)
18
-
19
- assert len(positions) == 1
20
-
21
- theta_expected, phi_expected = hp.pix2ang(nside, spike_pix)
22
- theta_found, phi_found = positions[0]
23
-
24
- assert np.isclose(theta_found, theta_expected)
25
- assert np.isclose(phi_found, phi_expected)
26
-
27
-
28
- def test_high_threshold():
29
- '''Test if find_peaks returns empty array for very high threshold'''
30
- cl = np.zeros(384)
31
- cl[2:] = 1.0/np.arange(2,384)**2
32
- np.random.seed(0)
33
- m = hp.synfast(cl, nside=128)
34
- m_norm = normalize_map(m)
35
- threshold = 1e3
36
-
37
- positions = find_peaks(m_norm,nside=128,threshold=threshold)
38
-
39
- assert positions.size == 0
40
-
41
-
42
- def test_high_n_peaks():
43
- '''Test if find_peaks returns all the peaks when n_peaks is greater than the total number of found peaks'''
44
- cl = np.zeros(384)
45
- cl[2:] = 1.0/np.arange(2,384)**2
46
- np.random.seed(0)
47
- m = hp.synfast(cl, nside=128)
48
-
49
- positions = find_peaks(m,nside=128)
50
-
51
- n_peaks = len(positions)
52
-
53
- test_n_peaks = n_peaks + 1
54
-
55
- new_positions = find_peaks(m,nside=128,n_peaks=test_n_peaks)
56
-
57
- assert len(new_positions) == len(positions)
58
-
59
-
60
- if __name__ == '__main__':
61
-
62
- test_high_threshold()
63
-
64
- test_high_n_peaks()
65
-
66
- test_single_peak()
File without changes
File without changes
File without changes