From 51bf89389f9d26424620054d0c88051e6d04d55d Mon Sep 17 00:00:00 2001 From: Joshua Kim <40779225+jaejoonk@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:13:57 -0700 Subject: [PATCH 1/6] Write per-scale NILC weight maps and add cmb_weights nmap label - coadd_maps_pixels now also returns the per-pixel ILC weights, and coadd(..., return_weights=True) returns them alongside the map. - needlet_coadd writes the weights estimated for 'coadd' to {out_root}wavelet_weights_scale_{k}_{tag}.fits (removed with delete_intermediate like the covariance maps). - New 'cmb_weights' nmap label applies the weights to ones maps and returns the per-scale weight sums (no wave2map) in outmaps['cmb_weights_coadd']. Co-Authored-By: Claude Opus 5.5 --- coberus/core.py | 37 ++++++++++++++++++++++++++-------- coberus/pipeline.py | 49 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/coberus/core.py b/coberus/core.py index 2360a59..45e5497 100644 --- a/coberus/core.py +++ b/coberus/core.py @@ -181,9 +181,12 @@ def coadd_maps_pixels( masks: np.ndarray, responses: np.ndarray, deproj_responses: np.ndarray, -) -> np.ndarray: +) -> tuple[np.ndarray, np.ndarray]: """ Co-adds the maps in the pixel domain. Assumes all masks and maps are the same size. + + Returns the coadded map (n_y, n_x) and the ILC weights (n_map, n_y, n_x) + applied to each map. Weights are zero for maps masked out of a pixel. """ n_y, n_x = maps.shape[-2:] @@ -191,6 +194,7 @@ def coadd_maps_pixels( n_deproj = len(deproj_responses) n_map = len(maps) + weights_out = np.zeros((n_map, n_y, n_x), dtype=np.float32) if n_deproj > 0: response_mat = ( @@ -230,6 +234,8 @@ def coadd_maps_pixels( numer = np.dot(a, cinvd) output[j, i] = numer / denom + # a^T C^-1 / denom, using the symmetry of C + weights_out[mask, j, i] = cinva / denom # Constrained ILC (see Eq. 29 & 30 of 2307.01043) else: @@ -250,8 +256,9 @@ def coadd_maps_pixels( a_eff = (np.dot(det_q_sub_vec, a_mix.T) / det_q).astype(np.float32) weights = np.linalg.solve(cov, a_eff) output[j, i] = np.dot(weights, masked_maps) + weights_out[mask, j, i] = weights - return output + return output, weights_out def write_to_main_array(data: np.ndarray, chunk: Chunk, main_array: da.array): @@ -274,12 +281,13 @@ def coadded_map_wrapper( ) -> np.ndarray: """ Wrapper for the coadd_maps_pixels function that allows you to return - the chunk to. + the chunk to. Returns (coadded map, weights, chunk). """ - return coadd_maps_pixels( + output, weights = coadd_maps_pixels( maps, covariance_maps, masks, responses, deproj_responses - ), chunk + ) + return output, weights, chunk def create_tasks_for_chunk( @@ -308,23 +316,36 @@ def create_tasks_for_chunk( return coadded_map -def coadd(client: Client, coadder: Coadder) -> da.Array: +def coadd(client: Client, coadder: Coadder, return_weights: bool = False): """ The main function for co-adding maps. Takes your Coadder object, and a Dask client, and returns a Dask array filled with your coadded map. This function uses Dask futures to coadd your maps. + + If return_weights is True, also returns a Dask array of shape + (n_map, n_y, n_x) holding the ILC weight applied to each map in each + pixel (zero where a map is masked out). """ chunks = coadder.chunk_task_list() - main_array = da.zeros(coadder.chunk_meta()["meta"].shape, dtype=np.float32) + shape = coadder.chunk_meta()["meta"].shape + main_array = da.zeros(shape, dtype=np.float32) + if return_weights: + weights_array = da.zeros((len(coadder.maps),) + shape, dtype=np.float32) results = [ create_tasks_for_chunk(chunk, client, coadder, main_array) for chunk in chunks ] for future in dask.distributed.as_completed(results): - image, chunk = future.result() + image, weights, chunk = future.result() write_to_main_array(image, chunk, main_array) + if return_weights: + weights_array[:, chunk[0][0] : chunk[1][0], chunk[0][1] : chunk[1][1]] = ( + weights + ) + if return_weights: + return main_array, weights_array return main_array diff --git a/coberus/pipeline.py b/coberus/pipeline.py index 9221dad..d0cc486 100644 --- a/coberus/pipeline.py +++ b/coberus/pipeline.py @@ -362,12 +362,18 @@ def needlet_coadd( Whether to delete intermediate outputs nmap_labels : optional,list[str] - List of possible optional maps' labels + List of possible optional maps' labels. The special label + 'cmb_weights' does not read any maps; instead the weights are + applied to wavelet maps that are 1 in all pixels, and outmaps + ['cmb_weights_coadd'] is a list (one per wavelet scale) of the + summed weights on each scale's wavelet geometry, without the + final wave2map. For a CMB solution (responses of 1) with no + deprojection this should be 1 wherever any map contributes. nmap_label_fname_func : optional,func | (nmap_label, fname) -> nmap Optional maps not used for covariance, but coadded with the same weights. Accepts the optional map's label and filename - and returns a path + and returns a path. Not called for the 'cmb_weights' label. apply_mask : optional, boolean If true, zero out regions of the input maps based on their masks. @@ -385,7 +391,12 @@ def needlet_coadd( 'mask' : the final footprint mask (the base_tag mask, projected onto the output geometry when one is provided). For each label in nmap_labels, also contains '{label}_coadd' with the - coadd of those maps using the same weights. + coadd of those maps using the same weights ('cmb_weights_coadd' is + instead a per-scale list; see nmap_labels). + + The per-scale ILC weight maps estimated for 'coadd' are also written + to {out_root}wavelet_weights_scale_{k}_{tag}.fits for each wavelet + scale k and each tag included in that scale. """ if nmap_labels is None: @@ -441,7 +452,7 @@ def needlet_coadd( ) # if using optional additional maps to coadd - do_nmaps = len(nmap_labels) > 0 and (nmap_label_fname_func is not None) + do_nmaps = len(nmap_labels) > 0 def _get_wave(fname_func, itag, imask): gmap = enmap.read_map(fname_func(itag)) @@ -572,6 +583,7 @@ def _get_wave(fname_func, itag, imask): lambda fname: nmap_label_fname_func(label, fname), tag, mask ) for label in nmap_labels + if label != "cmb_weights" } if i == 0: @@ -600,6 +612,16 @@ def _get_wave(fname_func, itag, imask): # optional maps for label in nmap_labels: + if label == "cmb_weights": + # Maps of ones are identical for every tag, so write + # one per scale and share it between tags. + nwfname = f"{out_root}wavelet_{label}_scale_{j}.fits" + if len(nfmaps[label][j]) == 0: + filenames.append(nwfname) + enmap.write_map(nwfname, enmap.ones(wmap.shape, wmap.wcs)) + totgibytes = totgibytes + (wmap.nbytes / 1024 / 1024.0 / 1024.0) + nfmaps[label][j].append(nwfname) + continue nwfname = f"{out_root}wavelet_{label}_{tags[i]}_scale_{j}.fits" filenames.append(nwfname) nfmaps[label][j].append(nwfname) @@ -742,11 +764,28 @@ def _get_wave(fname_func, itag, imask): print("Number of workers: ", len(client.scheduler_info()["workers"])) # Result is a dask array - result = coadd(client, coadder) + if outmaptype == "coadd": + result, weights = coadd(client, coadder, return_weights=True) + # Write the ILC weight map of each tag at this scale + weights = weights.compute() + for i, tag in enumerate(included_tags[j]): + fwname = f"{out_root}wavelet_weights_scale_{j}_{tag}.fits" + enmap.write_map( + fwname, enmap.enmap(weights[i], owave.maps[j].wcs) + ) + filenames.append(fwname) + else: + result = coadd(client, coadder) # This is now a numpy array arr = result.compute() owave.maps[j] = enmap.enmap(arr.copy(), owave.maps[j].wcs) + if outmaptype == "cmb_weights_coadd": + # Synthesizing constant maps is not meaningful, so keep the + # per-scale weight sums on the wavelet geometries. + outmaps[outmaptype] = [m.copy() for m in owave.maps] + continue + coadd_map = wt_out.wave2map(owave) coadd_map[out_base_mask == 0] = 0 outmaps[outmaptype] = coadd_map.copy() From 7216d0039b75f313970c4fe06958b07e83f13175 Mon Sep 17 00:00:00 2001 From: Joshua Kim <40779225+jaejoonk@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:16:10 -0700 Subject: [PATCH 2/6] Add check script for cmb_weights and per-scale weight maps Runs a block-smoothed CMB NILC on the synthetic test_pipeline dataset with nmap_labels=["cmb_weights"] and prints, per wavelet scale, the range of cmb_weights_coadd and the sum over tags of the written weight maps (both should be 1 wherever any map contributes). Co-Authored-By: Claude Opus 5.5 --- tests/check_cmb_weights.py | 66 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/check_cmb_weights.py diff --git a/tests/check_cmb_weights.py b/tests/check_cmb_weights.py new file mode 100644 index 0000000..b55fbec --- /dev/null +++ b/tests/check_cmb_weights.py @@ -0,0 +1,66 @@ +""" +Check the 'cmb_weights' nmap label and the per-scale weight maps written by +coberus.pipeline.needlet_coadd, using the synthetic dataset from +tests/test_pipeline.py. +""" + +import argparse +import glob + +import numpy as np +from pixell import enmap +from test_pipeline import BASE_TAG, TAGS, make_dataset + +from coberus.pipeline import gauss_beam, needlet_coadd + + +def main(): + """Run a block-smoothed CMB NILC with 'cmb_weights' and check outputs.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out-root", default="/tmp/cob_chk_") + parser.add_argument("--debug", action="store_true", help="Low-res quick run.") + args = parser.parse_args() + res, lmax = (8.0, 250) if args.debug else (4.0, 500) + lpeaks = [lp for lp in [50, 100, 200, 350] if lp < lmax] + [lmax] + + tags = [t[0] for t in TAGS] + info = make_dataset(f"{args.out_root}dataset", res, lmax, 1234) + root = f"{args.out_root}run_" + out = needlet_coadd( + map_fname_func=lambda tag: info[tag]["map"], + mask_fname_func=lambda tag: info[tag]["mask"], + tags=tags, + base_tag=BASE_TAG, + lpeaks=lpeaks, + lmins=[None] * len(tags), + lmaxs=[None] * len(tags), + response_func=lambda tag: 1.0, + beam_func=lambda tag, ells: gauss_beam(np.asarray(ells), info[tag]["fwhm"]), + out_beam_fwhm=8.0, + out_root=root, + cov_smooth_type="block", + cov_smooth_factor=8, + n_workers=2, + nmap_labels=["cmb_weights"], + ) + print("keys:", sorted(out)) + for k, w in enumerate(out["cmb_weights_coadd"]): + nz = w != 0 + print( + f"cmb_weights_coadd scale {k}: shape {w.shape}, " + f"nonzero range {w[nz].min():.5f}..{w[nz].max():.5f}, " + f"zero fraction {1 - nz.mean():.3f}" + ) + for k in range(len(lpeaks)): + files = sorted(glob.glob(f"{root}wavelet_weights_scale_{k}_*.fits")) + ws = np.array([enmap.read_map(f) for f in files]) + tot = ws.sum(axis=0) + nz = tot != 0 + print( + f"scale {k}: {len(files)} weight maps, shape {ws.shape[1:]}, " + f"sum over tags where nonzero: {tot[nz].min():.5f}..{tot[nz].max():.5f}" + ) + + +if __name__ == "__main__": + main() From 3dc8edbeb75f8e14c2995dd6be0b18ccb398ca2b Mon Sep 17 00:00:00 2001 From: Joshua Kim <40779225+jaejoonk@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:30:06 -0700 Subject: [PATCH 3/6] Replace cmb_weights nmap label with check_cmb_weights flag needlet_coadd no longer accepts 'cmb_weights' in nmap_labels. Per-scale ILC weight maps are still written to disk; with check_cmb_weights=True, each scale's weights are checked to sum to 1 in every covered pixel (CMB solution), raising ValueError otherwise. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + coberus/pipeline.py | 77 +++++++++++++++++++++++++------------- tests/check_cmb_weights.py | 13 ++----- 3 files changed, 54 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 881d4b9..7da7ec4 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ coadd_map = result['coadd'] - **`deproj_response_funcs`** — list of response functions for constrained ILC deprojection (e.g. tSZ removal). - **`cov_smooth_type`** — covariance smoothing method: `'block'` (default), `'gaussian'` ([2307.01043](https://arxiv.org/abs/2307.01043)), or `'tophat'` ([2307.01258](https://arxiv.org/abs/2307.01258)). - **`nmap_labels` / `nmap_label_fname_func`** — coadd additional maps (e.g. simulations) using weights derived from the data maps; results returned as `result['