cmbstack 0.0.1__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.
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: cmbstack
3
+ Version: 0.0.1
4
+ Summary: Stack patches of the CMB temperature sky around local maxima
5
+ Author-email: Isaac Alexis López Paredes <first@example.com>, Anushka Sanjay Tilekar <anushka.tilekar.23@alumni.ucl.ac.uk>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/IsaacLP/cmbstack
8
+ Project-URL: Repository, https://github.com/IsaacLP/cmbstack.git
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: numpy
14
+ Requires-Dist: healpy
15
+ Requires-Dist: matplotlib
16
+
17
+ [![A rectangular badge, half black half purple containing the text made at Code Astro](https://img.shields.io/badge/Made%20at-Code/Astro-blueviolet.svg)](https://semaphorep.github.io/codeastro/)
18
+
19
+ # CMB Peak Stacking Pipeline
20
+
21
+ ## Overview
22
+
23
+ `cmbstack` is a Python package for stacking patches of the Cosmic Microwave Background (CMB) temperature sky. It accepts input as a theoretical power spectrum, a [HEALPix](https://healpy.readthedocs.io/en/latest/index.html) FITS file, or a map array already in memory. From there it detects local maxima, extracts gnomonic (flat-sky) patches around each peak, and averages them. This stacking procedure enhances the coherent peak profile while suppressing uncorrelated noise.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install cmbstack
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ **From a power spectrum file:**
34
+ ```python
35
+ from cmbstack.main import StackingPipeline
36
+
37
+ pipeline = StackingPipeline.from_cl("path/to/spectrum.dat", nside=1024, seed=42)
38
+ pipeline.run()
39
+ ```
40
+
41
+ **From an existing FITS map:**
42
+ ```python
43
+ pipeline = StackingPipeline.from_fits("path/to/map.fits", field=0)
44
+ pipeline.run()
45
+ ```
46
+
47
+ **From a map array already in memory:**
48
+ ```python
49
+ pipeline = StackingPipeline.from_map(sky_map)
50
+ pipeline.run()
51
+ ```
52
+
53
+ See `examples/from_theoretical_cl.ipynb` for a full worked example.
54
+
55
+ ## Package Structure
56
+
57
+ ```
58
+ cmbstack/
59
+ ├── maps.py — power-spectrum loading, map simulation, normalisation
60
+ ├── stacking.py — peak finding, patch extraction, stacking, radial profile
61
+ └── main.py — StackingPipeline high-level class
62
+ ```
63
+
64
+ ## Workflow
65
+
66
+ > **Note:** Steps 1–2 apply when starting from a power spectrum. Use `StackingPipeline.from_fits` or `from_map` to skip them when working with a real or pre-simulated map.
67
+
68
+ ### 1. Load the Power Spectrum — `maps.load_cl`
69
+
70
+ The input file contains the power spectrum as $D_\ell^{TT}$:
71
+
72
+ $$D_\ell \equiv \frac{\ell(\ell+1)}{2\pi} C_\ell$$
73
+
74
+ `load_cl` reads columns $(\ell, D_\ell)$ and converts to $C_\ell$:
75
+
76
+ $$C_\ell = \frac{2\pi}{\ell(\ell+1)} D_\ell, \qquad C_0 = C_1 = 0$$
77
+
78
+ ---
79
+
80
+ ### 2. Simulate a Sky Map — `maps.simulate_map`
81
+
82
+ A Gaussian random realization is drawn by sampling spherical harmonic coefficients $a_{\ell m}$ with variance $C_\ell$:
83
+
84
+ $$T(\hat{n}) = \sum_{\ell,m} a_{\ell m} \, Y_{\ell m}(\hat{n}), \qquad \langle |a_{\ell m}|^2 \rangle = C_\ell$$
85
+
86
+ This calls `healpy.synfast` internally. An optional `seed` makes runs reproducible.
87
+
88
+ ---
89
+
90
+ ### 3. Normalise the Map — `maps.normalize_map`
91
+
92
+ Before peak detection, the map is standardised so that thresholds have a clear statistical meaning:
93
+
94
+ $$T_{\text{norm}}(\hat{n}) = \frac{T(\hat{n}) - \langle T \rangle}{\sigma}$$
95
+
96
+ After this step the map has mean $\approx 0$ and standard deviation $= 1$, so peaks are measured in units of $\sigma$.
97
+
98
+ ---
99
+
100
+ ### 4. Detect Peaks — `stacking.find_peaks`
101
+
102
+ Local maxima are identified with `healpy.hotspots`: a pixel is a maximum if its value exceeds every immediate HEALPix neighbour. Peaks are filtered by a significance threshold $\nu$ (default $\nu = 3\sigma$) and optionally capped at the $N$ highest:
103
+
104
+ $$\text{Peaks} = \{\hat{n}_p \in \text{Maxima} \mid T_{\text{norm}}(\hat{n}_p) > \nu\}$$
105
+
106
+ Returns sky positions as $(θ, φ)$ pairs in radians.
107
+
108
+ ---
109
+
110
+ ### 5. Extract Patches — `stacking.extract_patches`
111
+
112
+ For each peak a square patch is cut using a gnomonic (tangent-plane) projection centred on $\hat{n}_p$. Every patch shares the same fixed pixel grid (side length `size_deg`, pixel scale `reso_arcmin`), so the centre pixel always corresponds to the peak itself and patches can be co-added directly.
113
+
114
+ ---
115
+
116
+ ### 6. Stack — `stacking.stack_patches`
117
+
118
+ Patches are averaged pixel-by-pixel:
119
+
120
+ $$S = \frac{1}{N} \sum_{i=1}^{N} P_i$$
121
+
122
+ Incoherent noise averages towards zero; the coherent central profile survives.
123
+
124
+ ---
125
+
126
+ ### 7. Radial Profile — `stacking.radial_profile`
127
+
128
+ The 2D stacked image is collapsed to a 1D profile by azimuthal averaging in concentric annuli about the centre. Returns bin-centre radii in arcminutes and the mean temperature in each annulus.
129
+
130
+ ---
131
+
132
+ ## Pipeline Constructors
133
+
134
+ `StackingPipeline` provides three entry points depending on where your data comes from:
135
+
136
+ | Constructor | Input | Notes |
137
+ |---|---|---|
138
+ | `from_cl(path, nside, seed)` | Power-spectrum file | Simulates a Gaussian random map via `healpy.synfast` |
139
+ | `from_fits(path, field=0)` | HEALPix FITS file | Loads the map with `maps.load_map`; `nside` is inferred automatically |
140
+ | `from_map(sky_map)` | NumPy array | Accepts any in-memory HEALPix map; `nside` is inferred automatically |
141
+
142
+ All three store the map in `pipeline.map` and share the same `run()` interface.
143
+
144
+ ---
145
+
146
+ ## Map I/O Utilities
147
+
148
+ `maps.load_map` and `maps.save_map` wrap the healpy FITS readers for convenience:
149
+
150
+ ```python
151
+ from cmbstack import maps
152
+
153
+ m = maps.load_map("map.fits", field=0) # wraps hp.read_map
154
+ maps.save_map("out.fits", m) # wraps hp.write_map (overwrite=True by default)
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Pipeline Object
160
+
161
+ `StackingPipeline` stores every intermediate product as an attribute:
162
+
163
+ | Attribute | Content |
164
+ |---|---|
165
+ | `pipeline.map` | Raw simulated map |
166
+ | `pipeline.normalized` | Normalised map (units of $\sigma$) |
167
+ | `pipeline.positions` | Peak positions $(θ, φ)$ in radians |
168
+ | `pipeline.patches` | List of 2D gnomonic patches |
169
+ | `pipeline.stacked` | Mean stacked 2D image |
170
+ | `pipeline.radius` | Radial bin centres (arcmin) |
171
+ | `pipeline.profile` | Mean temperature per radial bin |
@@ -0,0 +1,155 @@
1
+ [![A rectangular badge, half black half purple containing the text made at Code Astro](https://img.shields.io/badge/Made%20at-Code/Astro-blueviolet.svg)](https://semaphorep.github.io/codeastro/)
2
+
3
+ # CMB Peak Stacking Pipeline
4
+
5
+ ## Overview
6
+
7
+ `cmbstack` is a Python package for stacking patches of the Cosmic Microwave Background (CMB) temperature sky. It accepts input as a theoretical power spectrum, a [HEALPix](https://healpy.readthedocs.io/en/latest/index.html) FITS file, or a map array already in memory. From there it detects local maxima, extracts gnomonic (flat-sky) patches around each peak, and averages them. This stacking procedure enhances the coherent peak profile while suppressing uncorrelated noise.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install cmbstack
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ **From a power spectrum file:**
18
+ ```python
19
+ from cmbstack.main import StackingPipeline
20
+
21
+ pipeline = StackingPipeline.from_cl("path/to/spectrum.dat", nside=1024, seed=42)
22
+ pipeline.run()
23
+ ```
24
+
25
+ **From an existing FITS map:**
26
+ ```python
27
+ pipeline = StackingPipeline.from_fits("path/to/map.fits", field=0)
28
+ pipeline.run()
29
+ ```
30
+
31
+ **From a map array already in memory:**
32
+ ```python
33
+ pipeline = StackingPipeline.from_map(sky_map)
34
+ pipeline.run()
35
+ ```
36
+
37
+ See `examples/from_theoretical_cl.ipynb` for a full worked example.
38
+
39
+ ## Package Structure
40
+
41
+ ```
42
+ cmbstack/
43
+ ├── maps.py — power-spectrum loading, map simulation, normalisation
44
+ ├── stacking.py — peak finding, patch extraction, stacking, radial profile
45
+ └── main.py — StackingPipeline high-level class
46
+ ```
47
+
48
+ ## Workflow
49
+
50
+ > **Note:** Steps 1–2 apply when starting from a power spectrum. Use `StackingPipeline.from_fits` or `from_map` to skip them when working with a real or pre-simulated map.
51
+
52
+ ### 1. Load the Power Spectrum — `maps.load_cl`
53
+
54
+ The input file contains the power spectrum as $D_\ell^{TT}$:
55
+
56
+ $$D_\ell \equiv \frac{\ell(\ell+1)}{2\pi} C_\ell$$
57
+
58
+ `load_cl` reads columns $(\ell, D_\ell)$ and converts to $C_\ell$:
59
+
60
+ $$C_\ell = \frac{2\pi}{\ell(\ell+1)} D_\ell, \qquad C_0 = C_1 = 0$$
61
+
62
+ ---
63
+
64
+ ### 2. Simulate a Sky Map — `maps.simulate_map`
65
+
66
+ A Gaussian random realization is drawn by sampling spherical harmonic coefficients $a_{\ell m}$ with variance $C_\ell$:
67
+
68
+ $$T(\hat{n}) = \sum_{\ell,m} a_{\ell m} \, Y_{\ell m}(\hat{n}), \qquad \langle |a_{\ell m}|^2 \rangle = C_\ell$$
69
+
70
+ This calls `healpy.synfast` internally. An optional `seed` makes runs reproducible.
71
+
72
+ ---
73
+
74
+ ### 3. Normalise the Map — `maps.normalize_map`
75
+
76
+ Before peak detection, the map is standardised so that thresholds have a clear statistical meaning:
77
+
78
+ $$T_{\text{norm}}(\hat{n}) = \frac{T(\hat{n}) - \langle T \rangle}{\sigma}$$
79
+
80
+ After this step the map has mean $\approx 0$ and standard deviation $= 1$, so peaks are measured in units of $\sigma$.
81
+
82
+ ---
83
+
84
+ ### 4. Detect Peaks — `stacking.find_peaks`
85
+
86
+ Local maxima are identified with `healpy.hotspots`: a pixel is a maximum if its value exceeds every immediate HEALPix neighbour. Peaks are filtered by a significance threshold $\nu$ (default $\nu = 3\sigma$) and optionally capped at the $N$ highest:
87
+
88
+ $$\text{Peaks} = \{\hat{n}_p \in \text{Maxima} \mid T_{\text{norm}}(\hat{n}_p) > \nu\}$$
89
+
90
+ Returns sky positions as $(θ, φ)$ pairs in radians.
91
+
92
+ ---
93
+
94
+ ### 5. Extract Patches — `stacking.extract_patches`
95
+
96
+ For each peak a square patch is cut using a gnomonic (tangent-plane) projection centred on $\hat{n}_p$. Every patch shares the same fixed pixel grid (side length `size_deg`, pixel scale `reso_arcmin`), so the centre pixel always corresponds to the peak itself and patches can be co-added directly.
97
+
98
+ ---
99
+
100
+ ### 6. Stack — `stacking.stack_patches`
101
+
102
+ Patches are averaged pixel-by-pixel:
103
+
104
+ $$S = \frac{1}{N} \sum_{i=1}^{N} P_i$$
105
+
106
+ Incoherent noise averages towards zero; the coherent central profile survives.
107
+
108
+ ---
109
+
110
+ ### 7. Radial Profile — `stacking.radial_profile`
111
+
112
+ The 2D stacked image is collapsed to a 1D profile by azimuthal averaging in concentric annuli about the centre. Returns bin-centre radii in arcminutes and the mean temperature in each annulus.
113
+
114
+ ---
115
+
116
+ ## Pipeline Constructors
117
+
118
+ `StackingPipeline` provides three entry points depending on where your data comes from:
119
+
120
+ | Constructor | Input | Notes |
121
+ |---|---|---|
122
+ | `from_cl(path, nside, seed)` | Power-spectrum file | Simulates a Gaussian random map via `healpy.synfast` |
123
+ | `from_fits(path, field=0)` | HEALPix FITS file | Loads the map with `maps.load_map`; `nside` is inferred automatically |
124
+ | `from_map(sky_map)` | NumPy array | Accepts any in-memory HEALPix map; `nside` is inferred automatically |
125
+
126
+ All three store the map in `pipeline.map` and share the same `run()` interface.
127
+
128
+ ---
129
+
130
+ ## Map I/O Utilities
131
+
132
+ `maps.load_map` and `maps.save_map` wrap the healpy FITS readers for convenience:
133
+
134
+ ```python
135
+ from cmbstack import maps
136
+
137
+ m = maps.load_map("map.fits", field=0) # wraps hp.read_map
138
+ maps.save_map("out.fits", m) # wraps hp.write_map (overwrite=True by default)
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Pipeline Object
144
+
145
+ `StackingPipeline` stores every intermediate product as an attribute:
146
+
147
+ | Attribute | Content |
148
+ |---|---|
149
+ | `pipeline.map` | Raw simulated map |
150
+ | `pipeline.normalized` | Normalised map (units of $\sigma$) |
151
+ | `pipeline.positions` | Peak positions $(θ, φ)$ in radians |
152
+ | `pipeline.patches` | List of 2D gnomonic patches |
153
+ | `pipeline.stacked` | Mean stacked 2D image |
154
+ | `pipeline.radius` | Radial bin centres (arcmin) |
155
+ | `pipeline.profile` | Mean temperature per radial bin |
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,103 @@
1
+ """High-level stacking pipeline for HEALPix CMB maps.
2
+
3
+ :class:`StackingPipeline` runs the full analysis in one place:
4
+ simulate (or load) a map, detect peaks, extract gnomonic patches, stack them,
5
+ and compute a radial profile. The pipeline stores every intermediate product
6
+ as an attribute so individual steps can be inspected after the run.
7
+
8
+ Typical use
9
+ -----------
10
+ >>> pipeline = StackingPipeline.from_cl("path/to/cl.txt", nside=1024, seed=42)
11
+ >>> pipeline.run()
12
+ >>> plt.imshow(pipeline.stacked)
13
+ """
14
+
15
+ from . import maps, stacking
16
+ import numpy as np
17
+ import healpy as hp
18
+
19
+
20
+ class StackingPipeline:
21
+ """End-to-end stacking pipeline.
22
+
23
+ Construct from a power spectrum (:meth:`from_cl`) or from an existing map
24
+ (:meth:`from_map`), then call :meth:`run`.
25
+
26
+ Parameters
27
+ ----------
28
+ sky_map : numpy.ndarray
29
+ The HEALPix map to stack on.
30
+ nside : int
31
+ Resolution parameter of the map.
32
+
33
+ Attributes
34
+ ----------
35
+ normalized : numpy.ndarray or None
36
+ Set after run(); the normalized map.
37
+ """
38
+
39
+ def __init__(self, sky_map, nside):
40
+ self.map = sky_map
41
+ self.nside = nside
42
+ self.normalized = None
43
+ self.positions = None
44
+ self.patches = None
45
+ self.stacked = None
46
+ self.radius = None
47
+ self.profile = None
48
+
49
+
50
+ @classmethod
51
+ def from_cl(cls, cl_path, nside=128, seed=None):
52
+ """Build a pipeline by simulating a map from a power-spectrum file."""
53
+ cl = maps.load_cl(cl_path)
54
+
55
+ sky_map = maps.simulate_map(cl, nside, seed)
56
+ return cls(sky_map, nside)
57
+
58
+ @classmethod
59
+ def from_map(cls, sky_map):
60
+ """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.
61
+ """
62
+ sky_map = np.asarray(sky_map)
63
+ nside = hp.npix2nside(sky_map.size)
64
+ return cls(sky_map, nside)
65
+
66
+ @classmethod
67
+ def from_fits(cls, path, field=0):
68
+ """Build a pipeline from any HEALPix FITS file on disk."""
69
+ sky_map = maps.load_map(path, field=field)
70
+ return cls.from_map(sky_map)
71
+
72
+
73
+ def run(self, size_deg=10.0, reso_arcmin=3.0, profile=True, threshold=3.0, n_peaks=None):
74
+ """Run the full stacking loop.
75
+
76
+ Parameters
77
+ ----------
78
+ size_deg, reso_arcmin : float
79
+ Patch geometry.
80
+ profile : bool
81
+ Whether to also compute the radial profile.
82
+ threshold : float
83
+ Peak-finding threshold in units of the map std.
84
+ n_peaks : int or None
85
+ Maximum number of peaks to use; None means use all.
86
+
87
+ Returns
88
+ -------
89
+ result : cmbstack.stack.StackResult
90
+ """
91
+
92
+ self.normalized = maps.normalize_map(self.map)
93
+
94
+ self.positions = stacking.find_peaks(self.normalized, self.nside, threshold=threshold, n_peaks=n_peaks)
95
+
96
+ self.patches = stacking.extract_patches(self.normalized,self.positions,size_deg=size_deg,reso_arcmin=reso_arcmin)
97
+
98
+ self.stacked = stacking.stack_patches(self.patches)
99
+
100
+ if profile:
101
+ self.radius, self.profile = stacking.radial_profile(self.stacked,reso_arcmin=reso_arcmin)
102
+
103
+ return self
@@ -0,0 +1,136 @@
1
+ """Map simulation and preprocessing utilities for HEALPix CMB maps.
2
+
3
+ Provides the building blocks for turning a raw power spectrum into a
4
+ normalised HEALPix map ready for stacking:
5
+
6
+ 1. :func:`load_cl` — read a D_ell spectrum file and convert to C_ell
7
+ 2. :func:`simulate_map` — draw a Gaussian random realisation with ``healpy.synfast``
8
+ 3. :func:`normalize_map`— subtract the monopole and divide by the std
9
+
10
+ These functions are intentionally field-agnostic: they work on any scalar
11
+ power spectrum (temperature TT, lensing convergence, y-map, ...).
12
+ """
13
+
14
+ import numpy as np
15
+ import healpy as hp
16
+
17
+
18
+ def dl_to_cl(ell, dl,lmax):
19
+ """Convert D_ell = ell(ell+1) C_ell / (2 pi) to C_ell.
20
+
21
+ Parameters
22
+ ----------
23
+ ell : array_like
24
+ Multipole values. May start at 0; the ell=0 and ell=1 entries are set
25
+ to zero in the output to avoid division by zero (they carry no usable
26
+ power for this purpose).
27
+ dl : array_like
28
+ D_ell values in the same units you want C_ell returned in (e.g. uK^2).
29
+
30
+ Returns
31
+ -------
32
+ cl : numpy.ndarray
33
+ The angular power spectrum C_ell, same shape as ``dl``.
34
+
35
+ Notes
36
+ -----
37
+ The inverse normalization is ``C_ell = D_ell * 2*pi / (ell*(ell+1))``.
38
+ """
39
+ # Convert D_ell -> C_ell
40
+ cl_vals = 2.0 * np.pi * dl / (ell * (ell + 1.0))
41
+
42
+ # Build a full array indexed from ell=0, with 0,1 set to zero
43
+ lmax = np.max(ell)
44
+ cl = np.zeros(lmax + 1)
45
+ cl[ell] = cl_vals
46
+
47
+ return cl
48
+
49
+ # Function 1
50
+ def load_cl(path):
51
+ """Load a power-spectrum file and return C_ell for the requested spectrum.
52
+
53
+ The expected file columns are ell, Dl_TT, Dl_TE, Dl_EE, Dl_BB, Dl_dd, with
54
+ D_ell in uK^2. The chosen column is converted from D_ell to C_ell via
55
+ :func:`dl_to_cl` before being returned.
56
+
57
+ Parameters
58
+ ----------
59
+ path : str
60
+ Path to the whitespace-delimited spectrum file.
61
+ column : str, optional
62
+ Which spectrum to return: one of "TT", "TE", "EE", "BB", "dd".
63
+ Default "TT".
64
+
65
+ Returns
66
+ -------
67
+ cl : numpy.ndarray
68
+ C_ell array indexed from ell=0, suitable for passing to
69
+ :func:`simulate_map`.
70
+ """
71
+ ell, dl = np.loadtxt(path,usecols=(0,1),unpack=True)
72
+ ell = ell.astype(int)
73
+ lmax = ell.max()
74
+ cl = dl_to_cl(ell,dl,lmax)
75
+
76
+ return cl
77
+
78
+ # Function 2
79
+ def simulate_map(cl, nside=128, seed=None):
80
+ """Simulate a Gaussian random HEALPix map from a power spectrum.
81
+
82
+ Thin wrapper over ``healpy.synfast`` with an optional seed so results are
83
+ reproducible in tests.
84
+
85
+ Parameters
86
+ ----------
87
+ cl : array_like
88
+ Angular power spectrum C_ell (not D_ell).
89
+ nside : int, optional
90
+ HEALPix resolution parameter. Default 128.
91
+ seed : int or None, optional
92
+ Seed for the random number generator. If None, the draw is random.
93
+
94
+ Returns
95
+ -------
96
+ m : numpy.ndarray
97
+ A HEALPix map (RING ordering) of length ``12 * nside**2``.
98
+ """
99
+ if seed:
100
+ np.random.seed(seed)
101
+
102
+ map = hp.synfast(cl, nside=nside)
103
+
104
+ return map
105
+
106
+
107
+ def normalize_map(m):
108
+ """Subtract the monopole and divide by the standard deviation.
109
+
110
+ After this, peak thresholds can be expressed in units of sigma, which is the
111
+ natural convention for peak statistics.
112
+
113
+ Parameters
114
+ ----------
115
+ m : array_like
116
+ Input HEALPix map. May contain UNSEEN/NaN pixels, which are ignored in
117
+ the mean and standard deviation.
118
+
119
+ Returns
120
+ -------
121
+ m_norm : numpy.ndarray
122
+ The normalized map, with mean ~0 and std ~1.
123
+ """
124
+ m_clean = hp.remove_monopole(m)
125
+ m_norm = m_clean / m_clean.std()
126
+ return m_norm
127
+
128
+
129
+ def load_map(path, field=0):
130
+ """Wraps ``hp.read_map``"""
131
+ return hp.read_map(path, field=field)
132
+
133
+
134
+ def save_map(path, sky_map, overwrite=True):
135
+ """Wraps ``hp.write_map``"""
136
+ hp.write_map(path, sky_map, overwrite=overwrite)
@@ -0,0 +1,198 @@
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
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: cmbstack
3
+ Version: 0.0.1
4
+ Summary: Stack patches of the CMB temperature sky around local maxima
5
+ Author-email: Isaac Alexis López Paredes <first@example.com>, Anushka Sanjay Tilekar <anushka.tilekar.23@alumni.ucl.ac.uk>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/IsaacLP/cmbstack
8
+ Project-URL: Repository, https://github.com/IsaacLP/cmbstack.git
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: numpy
14
+ Requires-Dist: healpy
15
+ Requires-Dist: matplotlib
16
+
17
+ [![A rectangular badge, half black half purple containing the text made at Code Astro](https://img.shields.io/badge/Made%20at-Code/Astro-blueviolet.svg)](https://semaphorep.github.io/codeastro/)
18
+
19
+ # CMB Peak Stacking Pipeline
20
+
21
+ ## Overview
22
+
23
+ `cmbstack` is a Python package for stacking patches of the Cosmic Microwave Background (CMB) temperature sky. It accepts input as a theoretical power spectrum, a [HEALPix](https://healpy.readthedocs.io/en/latest/index.html) FITS file, or a map array already in memory. From there it detects local maxima, extracts gnomonic (flat-sky) patches around each peak, and averages them. This stacking procedure enhances the coherent peak profile while suppressing uncorrelated noise.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install cmbstack
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ **From a power spectrum file:**
34
+ ```python
35
+ from cmbstack.main import StackingPipeline
36
+
37
+ pipeline = StackingPipeline.from_cl("path/to/spectrum.dat", nside=1024, seed=42)
38
+ pipeline.run()
39
+ ```
40
+
41
+ **From an existing FITS map:**
42
+ ```python
43
+ pipeline = StackingPipeline.from_fits("path/to/map.fits", field=0)
44
+ pipeline.run()
45
+ ```
46
+
47
+ **From a map array already in memory:**
48
+ ```python
49
+ pipeline = StackingPipeline.from_map(sky_map)
50
+ pipeline.run()
51
+ ```
52
+
53
+ See `examples/from_theoretical_cl.ipynb` for a full worked example.
54
+
55
+ ## Package Structure
56
+
57
+ ```
58
+ cmbstack/
59
+ ├── maps.py — power-spectrum loading, map simulation, normalisation
60
+ ├── stacking.py — peak finding, patch extraction, stacking, radial profile
61
+ └── main.py — StackingPipeline high-level class
62
+ ```
63
+
64
+ ## Workflow
65
+
66
+ > **Note:** Steps 1–2 apply when starting from a power spectrum. Use `StackingPipeline.from_fits` or `from_map` to skip them when working with a real or pre-simulated map.
67
+
68
+ ### 1. Load the Power Spectrum — `maps.load_cl`
69
+
70
+ The input file contains the power spectrum as $D_\ell^{TT}$:
71
+
72
+ $$D_\ell \equiv \frac{\ell(\ell+1)}{2\pi} C_\ell$$
73
+
74
+ `load_cl` reads columns $(\ell, D_\ell)$ and converts to $C_\ell$:
75
+
76
+ $$C_\ell = \frac{2\pi}{\ell(\ell+1)} D_\ell, \qquad C_0 = C_1 = 0$$
77
+
78
+ ---
79
+
80
+ ### 2. Simulate a Sky Map — `maps.simulate_map`
81
+
82
+ A Gaussian random realization is drawn by sampling spherical harmonic coefficients $a_{\ell m}$ with variance $C_\ell$:
83
+
84
+ $$T(\hat{n}) = \sum_{\ell,m} a_{\ell m} \, Y_{\ell m}(\hat{n}), \qquad \langle |a_{\ell m}|^2 \rangle = C_\ell$$
85
+
86
+ This calls `healpy.synfast` internally. An optional `seed` makes runs reproducible.
87
+
88
+ ---
89
+
90
+ ### 3. Normalise the Map — `maps.normalize_map`
91
+
92
+ Before peak detection, the map is standardised so that thresholds have a clear statistical meaning:
93
+
94
+ $$T_{\text{norm}}(\hat{n}) = \frac{T(\hat{n}) - \langle T \rangle}{\sigma}$$
95
+
96
+ After this step the map has mean $\approx 0$ and standard deviation $= 1$, so peaks are measured in units of $\sigma$.
97
+
98
+ ---
99
+
100
+ ### 4. Detect Peaks — `stacking.find_peaks`
101
+
102
+ Local maxima are identified with `healpy.hotspots`: a pixel is a maximum if its value exceeds every immediate HEALPix neighbour. Peaks are filtered by a significance threshold $\nu$ (default $\nu = 3\sigma$) and optionally capped at the $N$ highest:
103
+
104
+ $$\text{Peaks} = \{\hat{n}_p \in \text{Maxima} \mid T_{\text{norm}}(\hat{n}_p) > \nu\}$$
105
+
106
+ Returns sky positions as $(θ, φ)$ pairs in radians.
107
+
108
+ ---
109
+
110
+ ### 5. Extract Patches — `stacking.extract_patches`
111
+
112
+ For each peak a square patch is cut using a gnomonic (tangent-plane) projection centred on $\hat{n}_p$. Every patch shares the same fixed pixel grid (side length `size_deg`, pixel scale `reso_arcmin`), so the centre pixel always corresponds to the peak itself and patches can be co-added directly.
113
+
114
+ ---
115
+
116
+ ### 6. Stack — `stacking.stack_patches`
117
+
118
+ Patches are averaged pixel-by-pixel:
119
+
120
+ $$S = \frac{1}{N} \sum_{i=1}^{N} P_i$$
121
+
122
+ Incoherent noise averages towards zero; the coherent central profile survives.
123
+
124
+ ---
125
+
126
+ ### 7. Radial Profile — `stacking.radial_profile`
127
+
128
+ The 2D stacked image is collapsed to a 1D profile by azimuthal averaging in concentric annuli about the centre. Returns bin-centre radii in arcminutes and the mean temperature in each annulus.
129
+
130
+ ---
131
+
132
+ ## Pipeline Constructors
133
+
134
+ `StackingPipeline` provides three entry points depending on where your data comes from:
135
+
136
+ | Constructor | Input | Notes |
137
+ |---|---|---|
138
+ | `from_cl(path, nside, seed)` | Power-spectrum file | Simulates a Gaussian random map via `healpy.synfast` |
139
+ | `from_fits(path, field=0)` | HEALPix FITS file | Loads the map with `maps.load_map`; `nside` is inferred automatically |
140
+ | `from_map(sky_map)` | NumPy array | Accepts any in-memory HEALPix map; `nside` is inferred automatically |
141
+
142
+ All three store the map in `pipeline.map` and share the same `run()` interface.
143
+
144
+ ---
145
+
146
+ ## Map I/O Utilities
147
+
148
+ `maps.load_map` and `maps.save_map` wrap the healpy FITS readers for convenience:
149
+
150
+ ```python
151
+ from cmbstack import maps
152
+
153
+ m = maps.load_map("map.fits", field=0) # wraps hp.read_map
154
+ maps.save_map("out.fits", m) # wraps hp.write_map (overwrite=True by default)
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Pipeline Object
160
+
161
+ `StackingPipeline` stores every intermediate product as an attribute:
162
+
163
+ | Attribute | Content |
164
+ |---|---|
165
+ | `pipeline.map` | Raw simulated map |
166
+ | `pipeline.normalized` | Normalised map (units of $\sigma$) |
167
+ | `pipeline.positions` | Peak positions $(θ, φ)$ in radians |
168
+ | `pipeline.patches` | List of 2D gnomonic patches |
169
+ | `pipeline.stacked` | Mean stacked 2D image |
170
+ | `pipeline.radius` | Radial bin centres (arcmin) |
171
+ | `pipeline.profile` | Mean temperature per radial bin |
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ cmbstack/__init__.py
4
+ cmbstack/main.py
5
+ cmbstack/maps.py
6
+ cmbstack/stacking.py
7
+ cmbstack.egg-info/PKG-INFO
8
+ cmbstack.egg-info/SOURCES.txt
9
+ cmbstack.egg-info/dependency_links.txt
10
+ cmbstack.egg-info/requires.txt
11
+ cmbstack.egg-info/top_level.txt
12
+ tests/test_find_peaks.py
13
+ tests/test_round.py
@@ -0,0 +1,3 @@
1
+ numpy
2
+ healpy
3
+ matplotlib
@@ -0,0 +1 @@
1
+ cmbstack
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools >= 61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cmbstack"
7
+ version = "0.0.1"
8
+ description = "Stack patches of the CMB temperature sky around local maxima"
9
+ readme = "README.md"
10
+ authors = [
11
+ {name = "Isaac Alexis López Paredes", email = "first@example.com"},
12
+ {name = "Anushka Sanjay Tilekar", email = "anushka.tilekar.23@alumni.ucl.ac.uk"},
13
+ ]
14
+ license = "MIT"
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ requires-python = ">=3.8"
20
+ dependencies = [
21
+ "numpy",
22
+ "healpy",
23
+ "matplotlib",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/IsaacLP/cmbstack"
28
+ Repository = "https://github.com/IsaacLP/cmbstack.git"
29
+
30
+ [tool.setuptools.packages.find]
31
+ include = ["cmbstack*"]
32
+ exclude = ["data*", "tests*", "docs*", "examples*", "_*", "_build", "_static", "_templates"]
33
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,69 @@
1
+ import sys
2
+ import numpy as np
3
+ import healpy as hp
4
+
5
+ sys.path.append("..")
6
+
7
+ from cmbstack.stacking import find_peaks
8
+ from cmbstack.maps import normalize_map
9
+
10
+
11
+ def test_single_peak():
12
+ '''Test if find_peaks returns the corrected coordinates for a known peak'''
13
+ nside = 64
14
+ n_pix = hp.nside2npix(nside)
15
+ m = np.zeros(n_pix)
16
+
17
+ spike_pix = n_pix // 2
18
+ m[spike_pix] = 10.0
19
+
20
+ positions = find_peaks(m, nside=nside, threshold=5.0)
21
+
22
+ assert len(positions) == 1
23
+
24
+ theta_expected, phi_expected = hp.pix2ang(nside, spike_pix)
25
+ theta_found, phi_found = positions[0]
26
+
27
+ assert np.isclose(theta_found, theta_expected)
28
+ assert np.isclose(phi_found, phi_expected)
29
+
30
+
31
+ def test_high_threshold():
32
+ '''Test if find_peaks returns empty array for very high threshold'''
33
+ cl = np.zeros(384)
34
+ cl[2:] = 1.0/np.arange(2,384)**2
35
+ np.random.seed(0)
36
+ m = hp.synfast(cl, nside=128)
37
+ m_norm = normalize_map(m)
38
+ threshold = 1e3
39
+
40
+ positions = find_peaks(m_norm,nside=128,threshold=threshold)
41
+
42
+ assert positions.size == 0
43
+
44
+
45
+ def test_high_n_peaks():
46
+ '''Test if find_peaks returns all the peaks when n_peaks is greater than the total number of found peaks'''
47
+ cl = np.zeros(384)
48
+ cl[2:] = 1.0/np.arange(2,384)**2
49
+ np.random.seed(0)
50
+ m = hp.synfast(cl, nside=128)
51
+
52
+ positions = find_peaks(m,nside=128)
53
+
54
+ n_peaks = len(positions)
55
+
56
+ test_n_peaks = n_peaks + 1
57
+
58
+ new_positions = find_peaks(m,nside=128,n_peaks=test_n_peaks)
59
+
60
+ assert len(new_positions) == len(positions)
61
+
62
+
63
+ if __name__ == '__main__':
64
+
65
+ test_high_threshold()
66
+
67
+ test_high_n_peaks()
68
+
69
+ test_single_peak()
@@ -0,0 +1,28 @@
1
+ import sys
2
+ import healpy as hp
3
+ import numpy as np
4
+ from pathlib import Path
5
+ import tempfile
6
+
7
+ sys.path.append("..")
8
+
9
+ from cmbstack.main import StackingPipeline
10
+ from cmbstack import maps
11
+
12
+ def test_fits_roundtrip(tmp_path):
13
+ '''Test that the Stacking Pipeline gives the same result from a map saved in memory and loading a .fits file'''
14
+ cl = np.zeros(384)
15
+ cl[2:] = 1.0/np.arange(2,384)**2
16
+ np.random.seed(0)
17
+ m = hp.synfast(cl, nside=128)
18
+ path = tmp_path / "m.fits"
19
+ maps.save_map(str(path), m)
20
+
21
+ direct = StackingPipeline.from_map(m).run(reso_arcmin=10, threshold=1.0, n_peaks=200)
22
+ loaded = StackingPipeline.from_fits(str(path)).run(reso_arcmin=10, threshold=1.0, n_peaks=200)
23
+ assert np.allclose(direct.stacked, loaded.stacked, equal_nan=True)
24
+
25
+ if __name__ == '__main__':
26
+ with tempfile.TemporaryDirectory() as tmp:
27
+ tmp_path = Path(tmp)
28
+ test_fits_roundtrip(tmp_path=tmp_path)