HEALPix Pixelization#

This guide covers the HEALPix pixelization scheme and how to work with it using jax-healpy.

Introduction to HEALPix#

HEALPix (Hierarchical Equal Area isoLatitude Pixelization) is a pixelization scheme for the sphere that provides:

  • Equal area pixels: All pixels have the same area on the sphere

  • Hierarchical structure: Pixels can be subdivided recursively

  • Isolatitude rings: Pixels are arranged in rings of constant latitude

  • Efficient spherical harmonic transforms: Optimized for fast SHT computation

Resolution Parameters#

HEALPix uses several parameters to define the resolution:

NSIDE Parameter#

The fundamental resolution parameter is nside, which must be a power of 2:

import jax_healpy as hp

# Valid nside values: 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, ...
nside = 64

# Calculate total number of pixels
npix = hp.nside2npix(nside)
print(f"NSIDE {nside} has {npix} pixels")  # 49152 pixels

Angular Resolution#

# Calculate angular resolution
resol_arcmin = hp.nside2resol(nside, arcmin=True)
resol_rad = hp.nside2resol(nside, arcmin=False)

print(f"Resolution at nside={nside}:")
print(f"  {resol_arcmin:.2f} arcminutes")
print(f"  {resol_rad:.6f} radians")

# Pixel area
pixarea_sr = hp.nside2pixarea(nside)  # steradians
pixarea_arcmin2 = hp.nside2pixarea(nside, degrees=True) * 3600  # square arcminutes

print(f"Pixel area: {pixarea_sr:.2e} steradians")
print(f"Pixel area: {pixarea_arcmin2:.2f} square arcminutes")

Coordinate Systems#

HEALPix uses spherical coordinates with specific conventions:

Angular Coordinates#

import jax.numpy as jnp

# Spherical coordinates: (theta, phi)
# theta: colatitude [0, π] (0 = North pole, π = South pole)
# phi: azimuth [0, 2π] (0 = +X axis in equatorial plane)

# Examples of common directions
north_pole = (0.0, 0.0)                    # theta=0, phi arbitrary
south_pole = (jnp.pi, 0.0)                 # theta=π, phi arbitrary
equator_x = (jnp.pi/2, 0.0)               # +X axis
equator_y = (jnp.pi/2, jnp.pi/2)          # +Y axis
equator_minus_x = (jnp.pi/2, jnp.pi)      # -X axis

# Convert specific coordinates
ra_deg, dec_deg = 83.6331, 22.0145  # Crab Nebula in degrees

# Convert RA/Dec to HEALPix theta/phi
theta = jnp.radians(90.0 - dec_deg)  # Colatitude = 90° - declination
phi = jnp.radians(ra_deg)             # Azimuth = right ascension

print(f"Crab Nebula: theta={theta:.4f}, phi={phi:.4f} radians")

Unit Vectors#

HEALPix also works with 3-dimensional unit vectors:

# Convert angles to unit vectors
theta, phi = jnp.pi/3, jnp.pi/4  # 60° colatitude, 45° azimuth
unit_vec = hp.ang2vec(theta, phi)

print(f"Unit vector: {unit_vec}")
print(f"Length: {jnp.linalg.norm(unit_vec):.6f}")  # Should be 1.0

# Convert back to angles
theta_back, phi_back = hp.vec2ang(unit_vec[0], unit_vec[1], unit_vec[2])
print(f"Recovered angles: theta={theta_back:.4f}, phi={phi_back:.4f}")

Pixel Indexing Schemes#

HEALPix supports two pixel ordering schemes:

RING Ordering (Default)#

Pixels are numbered in rings of constant latitude:

nside = 4
pixels_ring = jnp.arange(12)  # First 12 pixels

# Convert to coordinates
theta_ring, phi_ring = hp.pix2ang(nside, pixels_ring, nest=False)

print("RING ordering:")
for i, (t, p) in enumerate(zip(theta_ring, phi_ring)):
    print(f"Pixel {i:2d}: theta={t:.3f}, phi={p:.3f}")

NESTED Ordering#

Pixels follow a hierarchical tree structure:

# Same pixels in NESTED ordering
theta_nest, phi_nest = hp.pix2ang(nside, pixels_ring, nest=True)

print("\nNESTED ordering:")
for i, (t, p) in enumerate(zip(theta_nest, phi_nest)):
    print(f"Pixel {i:2d}: theta={t:.3f}, phi={p:.3f}")

Converting Between Schemes#

# Convert RING to NESTED
ring_pixels = jnp.arange(100)
nest_pixels = hp.ring2nest(nside, ring_pixels)

# Convert NESTED to RING
ring_pixels_back = hp.nest2ring(nside, nest_pixels)

# Verify round-trip conversion
print(f"Round-trip successful: {jnp.allclose(ring_pixels, ring_pixels_back)}")

# Reorder entire maps
ring_map = jnp.random.normal(0, 1, hp.nside2npix(nside))
nest_map = hp.reorder(ring_map, r2n=True)   # RING to NESTED
ring_map_back = hp.reorder(nest_map, n2r=True)  # NESTED to RING

print(f"Map reordering successful: {jnp.allclose(ring_map, ring_map_back)}")

Coordinate Conversions#

Basic Conversions#

nside = 32
npix = hp.nside2npix(nside)

# Generate all pixel indices
all_pixels = jnp.arange(npix)

# Convert pixels to coordinates
theta_all, phi_all = hp.pix2ang(nside, all_pixels)
vectors_all = hp.pix2vec(nside, all_pixels)

# Convert coordinates back to pixels
pixels_from_ang = hp.ang2pix(nside, theta_all, phi_all)
pixels_from_vec = hp.vec2pix(nside, vectors_all[0], vectors_all[1], vectors_all[2])

# Verify conversions
print(f"Angle conversion accuracy: {jnp.allclose(all_pixels, pixels_from_ang)}")
print(f"Vector conversion accuracy: {jnp.allclose(all_pixels, pixels_from_vec)}")

Face Coordinates#

HEALPix internally uses coordinates within 12 base faces:

# Convert pixels to face coordinates
pixels = jnp.arange(24)  # First 24 pixels
x, y, face = hp.pix2xyf(nside, pixels)

print("Face coordinates:")
for i, (xi, yi, fi) in enumerate(zip(x, y, face)):
    print(f"Pixel {i:2d}: x={xi:.3f}, y={yi:.3f}, face={fi}")

# Convert back to pixels
pixels_back = hp.xyf2pix(nside, x, y, face)
print(f"\nFace coordinate round-trip: {jnp.allclose(pixels, pixels_back)}")

Working with Real Data#

Astronomical Coordinates#

# Convert from equatorial coordinates (RA/Dec)
def radec_to_healpix(ra_deg, dec_deg, nside, nest=False):
    """Convert RA/Dec coordinates to HEALPix pixel indices."""
    theta = jnp.radians(90.0 - dec_deg)  # Convert Dec to colatitude
    phi = jnp.radians(ra_deg)            # RA is already azimuth
    return hp.ang2pix(nside, theta, phi, nest=nest)

# Example: Messier catalog objects
messier_coords = [
    ("M1 (Crab Nebula)", 83.6331, 22.0145),
    ("M31 (Andromeda)", 10.6847, 41.2689),
    ("M42 (Orion Nebula)", 83.8221, -5.3911)
]

nside = 128
for name, ra, dec in messier_coords:
    pixel = radec_to_healpix(ra, dec, nside)
    print(f"{name}: RA={ra:.2f}°, Dec={dec:.2f}° → Pixel {pixel}")

Map Resolution Considerations#

# Choose appropriate nside for your application
def recommend_nside(angular_scale_arcmin):
    """Recommend nside for a given angular scale."""
    # Rule of thumb: pixel size should be ~1/3 of smallest feature
    desired_pixel_size = angular_scale_arcmin / 3.0

    # Find appropriate nside
    for nside in [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024]:
        pixel_size = hp.nside2resol(nside, arcmin=True)
        if pixel_size <= desired_pixel_size:
            return nside
    return 1024  # Maximum commonly used resolution

# Examples
scales = [60, 30, 10, 5, 1]  # arcminutes
for scale in scales:
    recommended = recommend_nside(scale)
    actual_resolution = hp.nside2resol(recommended, arcmin=True)
    print(f"Feature scale {scale}': recommended nside={recommended} "
          f"(pixel size {actual_resolution:.2f}')")

Performance Tips#

Vectorized Operations#

# Process many coordinates at once
n_coords = 100000
theta_batch = jnp.random.uniform(0, jnp.pi, n_coords)
phi_batch = jnp.random.uniform(0, 2*jnp.pi, n_coords)

# Vectorized conversion (efficient)
pixels_batch = hp.ang2pix(nside, theta_batch, phi_batch)

# This is much faster than:
# pixels_slow = [hp.ang2pix(nside, t, p) for t, p in zip(theta_batch, phi_batch)]

JIT Compilation#

import jax

# Compile coordinate conversion for speed
@jax.jit
def fast_coordinate_pipeline(nside, n_random):
    # Generate random coordinates
    theta = jax.random.uniform(jax.random.PRNGKey(42), (n_random,),
                               minval=0, maxval=jnp.pi)
    phi = jax.random.uniform(jax.random.PRNGKey(43), (n_random,),
                            minval=0, maxval=2*jnp.pi)

    # Convert to pixels and back
    pixels = hp.ang2pix(nside, theta, phi)
    theta_back, phi_back = hp.pix2ang(nside, pixels)

    return theta_back, phi_back

# First call compiles, subsequent calls are fast
result = fast_coordinate_pipeline(64, 10000)

Validation and Error Checking#

# Validate nside values
test_nsides = [1, 3, 64, 127, 128]
for nside in test_nsides:
    is_valid = hp.isnsideok(nside)
    print(f"nside={nside}: {'valid' if is_valid else 'invalid'}")

# Validate pixel indices
nside = 64
npix = hp.nside2npix(nside)
test_pixels = [0, npix//2, npix-1, npix, -1]

for pixel in test_pixels:
    is_valid = hp.isnpixok(nside, pixel)
    print(f"nside={nside}, pixel={pixel}: {'valid' if is_valid else 'invalid'}")

# Check map properties
test_map = jnp.random.normal(0, 1, npix)
map_type = hp.maptype(test_map)
print(f"Map type: {map_type}")  # Should be 'random' or similar