cmbstack 0.1.0__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.
- {cmbstack-0.1.0 → cmbstack-0.2.0}/PKG-INFO +16 -3
- {cmbstack-0.1.0 → cmbstack-0.2.0}/README.md +14 -2
- cmbstack-0.2.0/cmbstack/__init__.py +5 -0
- {cmbstack-0.1.0 → cmbstack-0.2.0}/cmbstack/maps.py +30 -14
- {cmbstack-0.1.0 → cmbstack-0.2.0}/cmbstack/pipeline.py +56 -9
- cmbstack-0.2.0/cmbstack/stacking.py +340 -0
- {cmbstack-0.1.0 → cmbstack-0.2.0}/cmbstack.egg-info/PKG-INFO +16 -3
- {cmbstack-0.1.0 → cmbstack-0.2.0}/cmbstack.egg-info/SOURCES.txt +3 -1
- {cmbstack-0.1.0 → cmbstack-0.2.0}/cmbstack.egg-info/requires.txt +1 -0
- {cmbstack-0.1.0 → cmbstack-0.2.0}/pyproject.toml +2 -1
- cmbstack-0.2.0/tests/test_find_peaks.py +92 -0
- cmbstack-0.2.0/tests/test_mask.py +145 -0
- cmbstack-0.2.0/tests/test_runtime.py +3 -0
- cmbstack-0.1.0/cmbstack/__init__.py +0 -5
- cmbstack-0.1.0/cmbstack/stacking.py +0 -198
- cmbstack-0.1.0/tests/test_find_peaks.py +0 -66
- {cmbstack-0.1.0 → cmbstack-0.2.0}/cmbstack.egg-info/dependency_links.txt +0 -0
- {cmbstack-0.1.0 → cmbstack-0.2.0}/cmbstack.egg-info/top_level.txt +0 -0
- {cmbstack-0.1.0 → cmbstack-0.2.0}/setup.cfg +0 -0
- {cmbstack-0.1.0 → cmbstack-0.2.0}/tests/test_round.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cmbstack
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Stack patches of
|
|
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
|
|
@@ -11,6 +11,7 @@ Classifier: Operating System :: OS Independent
|
|
|
11
11
|
Requires-Python: >=3.8
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
Requires-Dist: numpy
|
|
14
|
+
Requires-Dist: pandas
|
|
14
15
|
Requires-Dist: healpy
|
|
15
16
|
Requires-Dist: matplotlib
|
|
16
17
|
|
|
@@ -22,7 +23,7 @@ Requires-Dist: matplotlib
|
|
|
22
23
|
|
|
23
24
|
# cmbstack
|
|
24
25
|
|
|
25
|
-
Stack patches of
|
|
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.
|
|
26
27
|
|
|
27
28
|
## Installation
|
|
28
29
|
|
|
@@ -61,3 +62,15 @@ stacked_result = pipeline.run()
|
|
|
61
62
|
pipeline = StackingPipeline.from_map(sky_map)
|
|
62
63
|
stacked_result = pipeline.run()
|
|
63
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
|
|
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.
|
|
@@ -13,6 +13,7 @@ power spectrum (temperature TT, lensing convergence, y-map, ...).
|
|
|
13
13
|
|
|
14
14
|
import numpy as np
|
|
15
15
|
import healpy as hp
|
|
16
|
+
import pandas as pd
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def dl_to_cl(ell, dl,lmax):
|
|
@@ -46,12 +47,13 @@ def dl_to_cl(ell, dl,lmax):
|
|
|
46
47
|
|
|
47
48
|
return cl
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
def load_cl(path):
|
|
50
|
+
|
|
51
|
+
def load_cl(path, column="TT"):
|
|
51
52
|
"""Load a power-spectrum file and return C_ell for the requested spectrum.
|
|
52
53
|
|
|
53
|
-
The expected file columns are ell, Dl_TT, Dl_TE, Dl_EE, Dl_BB,
|
|
54
|
-
D_ell in uK^2
|
|
54
|
+
The expected file columns are ell, Dl_TT, Dl_TE, Dl_EE, Dl_BB, Dl_pp, with
|
|
55
|
+
D_ell in uK^2 (with names L, TT, TE, EE, BB, PP).
|
|
56
|
+
The chosen column is converted from D_ell to C_ell via
|
|
55
57
|
:func:`dl_to_cl` before being returned.
|
|
56
58
|
|
|
57
59
|
Parameters
|
|
@@ -59,7 +61,7 @@ def load_cl(path):
|
|
|
59
61
|
path : str
|
|
60
62
|
Path to the whitespace-delimited spectrum file.
|
|
61
63
|
column : str, optional
|
|
62
|
-
Which spectrum to return: one of "TT", "TE", "EE", "BB", "
|
|
64
|
+
Which spectrum to return: one of "TT", "TE", "EE", "BB", "PP".
|
|
63
65
|
Default "TT".
|
|
64
66
|
|
|
65
67
|
Returns
|
|
@@ -68,14 +70,22 @@ def load_cl(path):
|
|
|
68
70
|
C_ell array indexed from ell=0, suitable for passing to
|
|
69
71
|
:func:`simulate_map`.
|
|
70
72
|
"""
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
|
|
74
|
+
df = pd.read_csv(
|
|
75
|
+
path,
|
|
76
|
+
sep=r"\s+",
|
|
77
|
+
comment="#",
|
|
78
|
+
header=None,
|
|
79
|
+
names=["L", "TT", "TE", "EE", "BB", "PP"],
|
|
80
|
+
)
|
|
81
|
+
ell = df['L'].values.astype(int)
|
|
82
|
+
dl = df[column].values
|
|
83
|
+
lmax = ell.max()
|
|
84
|
+
cl = dl_to_cl(ell, dl, lmax)
|
|
75
85
|
|
|
76
86
|
return cl
|
|
77
87
|
|
|
78
|
-
|
|
88
|
+
|
|
79
89
|
def simulate_map(cl, nside=128, seed=None):
|
|
80
90
|
"""Simulate a Gaussian random HEALPix map from a power spectrum.
|
|
81
91
|
|
|
@@ -113,16 +123,22 @@ def normalize_map(m):
|
|
|
113
123
|
Parameters
|
|
114
124
|
----------
|
|
115
125
|
m : array_like
|
|
116
|
-
Input HEALPix map. May contain UNSEEN/NaN pixels
|
|
117
|
-
the mean and
|
|
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.
|
|
118
129
|
|
|
119
130
|
Returns
|
|
120
131
|
-------
|
|
121
132
|
m_norm : numpy.ndarray
|
|
122
133
|
The normalized map, with mean ~0 and std ~1.
|
|
123
134
|
"""
|
|
124
|
-
|
|
125
|
-
|
|
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()
|
|
126
142
|
return m_norm
|
|
127
143
|
|
|
128
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 (
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
4
|
-
Summary: Stack patches of
|
|
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
|
|
@@ -11,6 +11,7 @@ Classifier: Operating System :: OS Independent
|
|
|
11
11
|
Requires-Python: >=3.8
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
Requires-Dist: numpy
|
|
14
|
+
Requires-Dist: pandas
|
|
14
15
|
Requires-Dist: healpy
|
|
15
16
|
Requires-Dist: matplotlib
|
|
16
17
|
|
|
@@ -22,7 +23,7 @@ Requires-Dist: matplotlib
|
|
|
22
23
|
|
|
23
24
|
# cmbstack
|
|
24
25
|
|
|
25
|
-
Stack patches of
|
|
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.
|
|
26
27
|
|
|
27
28
|
## Installation
|
|
28
29
|
|
|
@@ -61,3 +62,15 @@ stacked_result = pipeline.run()
|
|
|
61
62
|
pipeline = StackingPipeline.from_map(sky_map)
|
|
62
63
|
stacked_result = pipeline.run()
|
|
63
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.
|
|
@@ -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
|
|
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"},
|
|
@@ -19,6 +19,7 @@ classifiers = [
|
|
|
19
19
|
requires-python = ">=3.8"
|
|
20
20
|
dependencies = [
|
|
21
21
|
"numpy",
|
|
22
|
+
"pandas",
|
|
22
23
|
"healpy",
|
|
23
24
|
"matplotlib",
|
|
24
25
|
]
|
|
@@ -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,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
|
|
File without changes
|