Skip to content

Commit df8d076

Browse files
Add FootprintPlotter class and hsp_map_logical_or to plots module
- Move FootprintPlotter class from sp_validation to cs_util.plots - Add hsp_map_logical_or function for combining HealSparse maps - Add dependencies: healpy, healsparse, skyproj - Bump version from 0.1.7 to 0.1.8 This allows shapepipe to use cs_util.plots.FootprintPlotter instead of requiring the separate sp_validation package. Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 091e9d1 commit df8d076

2 files changed

Lines changed: 325 additions & 2 deletions

File tree

cs_util/plots.py

Lines changed: 321 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""PLOTS.
1+
"""PLOTS.
22
33
:Name: plots.py
44
@@ -8,10 +8,17 @@
88
99
"""
1010

11+
from collections import Counter
12+
13+
import healpy as hp
14+
import healsparse as hsp
1115
import matplotlib
1216
import matplotlib.pylab as plt
1317
import matplotlib.ticker as ticker
1418
import numpy as np
19+
import skyproj
20+
from astropy import units as u
21+
from astropy.coordinates import SkyCoord
1522

1623

1724
def figure(figsize=(30, 30)):
@@ -426,3 +433,316 @@ def log_ticks(x):
426433
ticks.append(val)
427434

428435
return np.array(ticks)
436+
437+
438+
class FootprintPlotter:
439+
"""Class to create footprint plots.
440+
441+
Parameters
442+
-----------
443+
nside_coverage: int, optional
444+
basic resolution of map; default is 32
445+
nside_map:
446+
fine resolution for plotting; default is 2048
447+
448+
"""
449+
450+
# Dictionary storing region parameters
451+
_regions = {
452+
"NGC": {"ra_0": 180, "extend": [120, 270, 20, 70], "vmax": 60},
453+
"SGC": {"ra_0": 15, "extend": [-20, 45, 20, 45], "vmax": 60},
454+
"fullsky": {"ra_0": 150, "extend": [0, 360, -90, 90], "vmax": 60},
455+
}
456+
457+
def __init__(self, nside_coverage=32, nside_map=2048):
458+
459+
self._nside_coverage = nside_coverage
460+
self._nside_map = nside_map
461+
462+
def create_hsp_map(self, ra, dec):
463+
"""Create Hsp Map.
464+
465+
Create healsparse map.
466+
467+
Parameters
468+
----------
469+
ra : numpy.ndarray
470+
right ascension values
471+
dec : numpy.ndarray
472+
declination values
473+
474+
Returns
475+
-------
476+
hsp.HealSparseMap
477+
map
478+
479+
"""
480+
# Create empty map
481+
hsp_map = hsp.HealSparseMap.make_empty(
482+
self._nside_coverage, self._nside_map, dtype=np.float32, sentinel=np.nan
483+
)
484+
485+
# Get pixel list corresponding to coordinates
486+
hpix = hp.ang2pix(self._nside_map, ra, dec, nest=True, lonlat=True)
487+
488+
# Get count of objects per pixel
489+
pixel_counts = Counter(hpix)
490+
491+
# List of unique pixels
492+
unique_hpix = np.array(list(pixel_counts.keys()))
493+
494+
# Number of objects
495+
values = np.array(list(pixel_counts.values()), dtype=np.float32)
496+
497+
# Create maps with numbers per pixel
498+
hsp_map[unique_hpix] = values
499+
500+
return hsp_map
501+
502+
def plot_area(
503+
self,
504+
hsp_map,
505+
ra_0=0,
506+
extend=[120, 270, 29, 70],
507+
vmax=60,
508+
projection=None,
509+
outpath=None,
510+
title=None,
511+
colorbar=True,
512+
colorbar_label="Coverage depth",
513+
):
514+
"""Plot Area.
515+
516+
Plot catalogue in an area on the sky.
517+
518+
Parameters
519+
----------
520+
hsp_map : hsp_HealSparseMap
521+
input map
522+
ra_0 : float, optional
523+
anchor point in R.A.; default is 0
524+
extend : list, optional
525+
sky region, extend=[ra_low, ra_high, dec_low, dec_high];
526+
default is [120, 270, 29, 70]
527+
vmax : float, optional
528+
maximum pixel value to plot with color; default is 60
529+
projection : skyproj.McBrydeSkyproj
530+
if ``None`` (default), a new plot is created
531+
outpath : str, optional
532+
output path, default is ``None``
533+
title : str, optional
534+
print title if not ``None`` (default)
535+
colorbar : bool, optional
536+
add colorbar; default is ``True``
537+
colorbar_label : str, optional
538+
colorbar label; default is "Coverage depth"
539+
540+
Returns
541+
--------
542+
skyproj.McBrydeSkyproj
543+
projection instance
544+
plt.axes.Axes
545+
axes instance
546+
547+
Raises
548+
------
549+
ValueError
550+
if no object found in region
551+
552+
"""
553+
if not projection:
554+
555+
# Create new figure and axes
556+
fig, ax = plt.subplots(figsize=(10, 10))
557+
558+
# Create new projection
559+
projection = skyproj.McBrydeSkyproj(
560+
ax=ax, lon_0=ra_0, extent=extend, autorescale=True, vmax=vmax
561+
)
562+
else:
563+
ax = None
564+
565+
im = None
566+
try:
567+
im, lon_raster, lat_raster, values_raster = projection.draw_hspmap(
568+
hsp_map, lon_range=extend[0:2], lat_range=extend[2:]
569+
)
570+
except ValueError:
571+
msg = "No object found in region to draw"
572+
print(f"{msg}, continuing...")
573+
# raise ValueError(msg)
574+
575+
projection.draw_milky_way(width=25, linewidth=1.5, color="black", linestyle="-")
576+
577+
# Add colorbar if requested and image was drawn
578+
if colorbar and im is not None:
579+
plt.colorbar(im, ax=ax if ax else projection.ax, label=colorbar_label)
580+
581+
if title:
582+
plt.title(title, pad=5)
583+
584+
if outpath:
585+
plt.savefig(outpath)
586+
587+
return projection, ax
588+
589+
def plot_region(self, hsp_map, region, projection=None, outpath=None, title=None, colorbar=True, colorbar_label="Coverage depth"):
590+
"""Plot Region.
591+
592+
Plot catalogue in a predefined region on the sky.
593+
594+
Parameters
595+
----------
596+
hsp_map : hsp_HealSparseMap
597+
input map
598+
region : dict
599+
region dictionary with keys 'ra_0', 'extend', 'vmax'
600+
projection : skyproj.McBrydeSkyproj, optional
601+
if ``None`` (default), a new plot is created
602+
outpath : str, optional
603+
output path, default is ``None``
604+
title : str, optional
605+
print title if not ``None`` (default)
606+
colorbar : bool, optional
607+
add colorbar; default is ``True``
608+
colorbar_label : str, optional
609+
colorbar label; default is "Coverage depth"
610+
611+
Returns
612+
--------
613+
skyproj.McBrydeSkyproj
614+
projection instance
615+
plt.axes.Axes
616+
axes instance
617+
618+
"""
619+
return self.plot_area(
620+
hsp_map,
621+
region["ra_0"],
622+
region["extend"],
623+
region["vmax"],
624+
projection=projection,
625+
outpath=outpath,
626+
title=title,
627+
colorbar=colorbar,
628+
colorbar_label=colorbar_label,
629+
)
630+
631+
def plot_all_regions(self, hsp_map, outbase=None):
632+
633+
for region in self._regions:
634+
if outbase:
635+
outpath = f"{outbase}_{region}.png"
636+
else:
637+
outpath = None
638+
self.plot_region(hsp_map, self._regions[region], outpath=outpath)
639+
640+
@classmethod
641+
def hp_pixel_centers(cls, nside, nest=False):
642+
643+
# Get number of pixels for given nside
644+
npix = hp.nside2npix(nside)
645+
646+
# Get pixel indices
647+
pix_indices = np.arange(npix)
648+
649+
# Get coordinates of pixel centers
650+
ra, dec = hp.pix2ang(nside, pix_indices, nest=nest, lonlat=True)
651+
652+
return ra, dec, npix
653+
654+
@classmethod
655+
def plot_footprint_as_hp(cls, hsp_map, nside, outpath=None, title=None):
656+
657+
ra, dec, npix = cls.hp_pixel_centers(nside)
658+
659+
# Create an empty HEALPix map
660+
m = np.full(npix, np.nan)
661+
662+
fig, ax = plt.subplots(figsize=(10, 10))
663+
664+
# Plot the HEALPix grid
665+
hp.mollview(m, title=title, coord="C", notext=True, rot=(180, 0, 0))
666+
667+
# Define the Galactic Plane: l = [0, 360], b = 0°
668+
for l0, ls in zip((-5, 0, 5), (":", "-", ":")):
669+
l_values = np.linspace(0, 360, 500) # 500 points along the plane
670+
b_values = np.zeros_like(l_values) # Galactic latitude is 0 (the plane)
671+
672+
# Convert (l, b) to (λ, β) - Ecliptic coordinates
673+
coords = SkyCoord(
674+
l=l_values * u.degree, b=b_values * u.degree, frame="galactic"
675+
)
676+
ecl_coords = coords.transform_to(
677+
"barycentrictrueecliptic"
678+
) # Ecliptic frame
679+
680+
# Extract Ecliptic longitude (λ) and latitude (β)
681+
lambda_ecl = ecl_coords.lon.deg # Ecliptic longitude
682+
beta_ecl = ecl_coords.lat.deg # Ecliptic latitude
683+
684+
# Convert to HEALPix projection coordinates (colatitude, longitude)
685+
theta = np.radians(90 - beta_ecl) # HEALPix uses colatitude
686+
phi = np.radians(lambda_ecl) # HEALPix uses longitude
687+
688+
# Create a healpy Mollweide projection in Ecliptic coordinates
689+
hp.projplot(
690+
theta, phi, linestyle=ls, color="black", linewidth=1
691+
) # Plot the outline
692+
693+
# Apply mask
694+
mask_values = hsp_map.get_values_pos(ra, dec, valid_mask=True, lonlat=True)
695+
696+
ok = np.where(mask_values == False)[0]
697+
# nok = np.where(mask_values == False)[0]
698+
699+
hp.projscatter(ra[ok], dec[ok], lonlat=True, color="green", s=1, marker=".")
700+
# hp.projscatter(ra[nok], dec[nok], lonlat=True, color="red", s=1, marker=".")
701+
702+
plt.tight_layout()
703+
704+
if outpath:
705+
plt.savefig(outpath)
706+
707+
plt.show()
708+
709+
710+
def hsp_map_logical_or(maps, verbose=False):
711+
"""
712+
Hsp Map Logical Or.
713+
714+
Logical AND of HealSparseMaps.
715+
716+
"""
717+
if verbose:
718+
print("Combine all maps...")
719+
720+
# Ensure consistency in coverage and data type
721+
nside_coverage = maps[0].nside_coverage
722+
nside_sparse = maps[0].nside_sparse
723+
dtype = maps[0].dtype
724+
725+
for m in maps:
726+
# MKDEBUG TODO: Change nside if possible
727+
if m.nside_coverage != nside_coverage:
728+
raise ValueError(
729+
f"Coverage nside={m.nside_coverage} does not match {nside_coverage}"
730+
)
731+
if m.dtype != dtype:
732+
raise ValueError(f"Data type {m.dtype} does not match {dtype}")
733+
734+
# Create an empty HealSparse map
735+
map_comb = hsp.HealSparseMap.make_empty(nside_coverage, nside_sparse, dtype=dtype)
736+
for idx, m in enumerate(maps):
737+
map_comb |= m
738+
739+
if verbose:
740+
valid_pixels = map_comb.valid_pixels
741+
n_tot = np.sum(valid_pixels)
742+
n_true = np.count_nonzero(valid_pixels)
743+
n_false = n_tot - n_true
744+
print(
745+
f"after map {idx}: frac_true={n_true / n_tot:g}, frac_false={n_false / n_tot:g}"
746+
)
747+
748+
return map_comb

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "cs_util"
3-
version = "0.1.7"
3+
version = "0.1.8"
44
description = "Utility library for CosmoStat"
55
authors = [
66
{ name = "Martin Kilbinger", email = "martin.kilbinger@cea.fr" },
@@ -20,6 +20,9 @@ dependencies = [
2020
"keyring>=25.2.0",
2121
"pyccl>=3.0.2",
2222
"camb>=1.5.9",
23+
"healpy>=1.16.0",
24+
"healsparse>=1.8.0",
25+
"skyproj>=1.0.0",
2326
]
2427

2528
[project.optional-dependencies]

0 commit comments

Comments
 (0)