Including sky localization#

Before starting, let’s first familiarize with sky localization maps:

https://samueleronchini.github.io/acme_tutorials/alerts/skymaps.html

from plot_config import setup_matplotlib_style

import matplotlib.pyplot as plt

# Configure matplotlib with high-quality settings
setup_matplotlib_style()

Spatio-temporal joint FAR#

from astropy.table import QTable
from ligo.raven import gracedb_events, search

skymap_grb = QTable.read('./754189311_0_n_PROBMAP.fits')

skymap_gw = QTable.read('./Bilby.multiorder.fits')

tl, th = -10, 20
se_far = 9.503e-10  # Hz
far_ext = 1 / 44 / 60  # 1/44 min

far_joint = search.coinc_far(se_far, tl, th,
                far_ext = far_ext,
                joint_far_method = 'targeted', # 'targeted' or 'untargeted'
                ext_search = 'SubGRBTargeted', # "GRB", "SubGRB", "SubGRBTargeted", "MDC", or "HEN" --> if you don't specify joint_far_method, you must specify ext_search
                # {'GRB', 'SubGRB', 'HEN'} --> uses untargeted
                # {'SubGRBTargeted'} --> uses targeted
                ext_pipeline = 'Swift', # --> if you don't specify far_ext_thresh, you must specify ext_pipeline
                gw_skymap=skymap_gw,
                ext_skymap=skymap_grb)

Sky overlap integral#

sky_overlap = search.skymap_overlap_integral(skymap_gw, ext_skymap=skymap_grb)

Note

If both maps are multi-order, then both maps are internally upsampled to the highest HEALPix depth present among the two. If only the external map if flat, for each pixel of the MOC GW map the corrisponding prob density of the external map is computed in the same position. If both flat, a simple element-wise multiplication is done.

Sky overlap integral for external precise localization#

import ligo.skymap.io.fits

gw_map, _ = ligo.skymap.io.fits.read_sky_map('Bilby.multiorder.fits', nest=True)
sky_overlap_point = search.skymap_overlap_integral(gw_map,
                            ra=90, dec=30) 
# use the gw map in flat format. The functions seems to not handle MOC skymaps when using ext rea and dec 
sky_overlap_point
np.float64(1.4768264524886637e-84)

Combining sky maps#

The function used here for two MOC maps is taken from https://rtd.igwn.org/projects/gwcelery/en/latest/_modules/gwcelery/tasks/external_skymaps.html#combine_skymaps_moc_moc

from hpmoc import PartialUniqSkymap
from hpmoc.utils import reraster, uniq_intersection
from ligo.skymap.distance import parameters_to_marginal_moments
import numpy as np

def combine_skymaps_moc_moc(gw_sky, ext_sky):
    """This function combines a multi-ordered (MOC) GW sky map with a MOC
    external skymap.
    """
    gw_sky_hpmoc = PartialUniqSkymap(gw_sky["PROBDENSITY"], gw_sky["UNIQ"],
                                     name="PROBDENSITY", meta=gw_sky.meta)
    # Determine the column name in ext_sky and rename it as PROBDENSITY.
    ext_sky_hpmoc = PartialUniqSkymap(ext_sky["PROBDENSITY"], ext_sky["UNIQ"],
                                      name="PROBDENSITY", meta=ext_sky.meta)

    comb_sky_hpmoc = gw_sky_hpmoc * ext_sky_hpmoc
    comb_sky_hpmoc /= np.sum(comb_sky_hpmoc.s * comb_sky_hpmoc.area())
    comb_sky = comb_sky_hpmoc.to_table(name='PROBDENSITY')

    if 'DISTMU' in gw_sky.keys() and 'DISTSIGMA' in gw_sky.keys():
        UNIQ = comb_sky['UNIQ']
        UNIQ_ORIG = gw_sky['UNIQ']
        intersection = uniq_intersection(UNIQ_ORIG, UNIQ)
        DIST_MU = reraster(UNIQ_ORIG,
                           gw_sky["DISTMU"],
                           UNIQ,
                           method='copy',
                           intersection=intersection)
        DIST_SIGMA = reraster(UNIQ_ORIG,
                              gw_sky["DISTSIGMA"],
                              UNIQ,
                              method='copy',
                              intersection=intersection)
        DIST_NORM = reraster(UNIQ_ORIG,
                             gw_sky["DISTNORM"],
                             UNIQ,
                             method='copy',
                             intersection=intersection)
        comb_sky.add_columns([DIST_MU, DIST_SIGMA, DIST_NORM],
                             names=['DISTMU', 'DISTSIGMA', 'DISTNORM'])

        distmean, diststd = parameters_to_marginal_moments(
            comb_sky['PROBDENSITY'] * comb_sky_hpmoc.area().value,
            comb_sky['DISTMU'], comb_sky['DISTSIGMA'])
        comb_sky.meta['distmean'], comb_sky.meta['diststd'] = distmean, diststd

    if 'ORDERING' in comb_sky.meta:
        del comb_sky.meta['ORDERING']
    return comb_sky

If your EXT map is in flat format, use: https://rtd.igwn.org/projects/gwcelery/en/latest/_modules/gwcelery/tasks/external_skymaps.html#combine_skymaps_moc_flat

comb_skymap = combine_skymaps_moc_moc(skymap_gw, skymap_grb)
comb_skymap.write('combined_skymap.fits', overwrite=True)

Plotting#

import matplotlib.pyplot as pp
from matplotlib.colors import LogNorm
from matplotlib import cm
import ligo.skymap.plot
import ligo.skymap.io.fits
import numpy as np

gw_map, _ = ligo.skymap.io.fits.read_sky_map('Bilby.multiorder.fits', nest=True)
grb_map, _ = ligo.skymap.io.fits.read_sky_map('754189311_0_n_PROBMAP.fits', nest=True)
comb_map, _ = ligo.skymap.io.fits.read_sky_map('combined_skymap.fits', nest=True)

def plot_panel(ax, skymap, title, cmap):
    import ligo.skymap.postprocess.util
    cls = 100 * ligo.skymap.postprocess.util.find_greedy_credible_levels(skymap)
    vmax = np.percentile(skymap[~np.isnan(skymap)], 99.0)
    vmin = np.percentile(skymap[~np.isnan(skymap)], 10.0)
    vmin = max(vmax/1e3, vmin)
    skymap_plot = skymap.copy()
    skymap_plot[cls > 90] = np.nan
    skymap_plot[skymap_plot == 0] = np.nan
    ax.set_facecolor('none')
    ax.grid()
    ax.imshow_hpx((skymap_plot, 'ICRS'), nested=True,
                  cmap=cmap, norm=LogNorm(vmin=vmin, vmax=vmax), zorder=0)
    ax.contour_hpx((cls, 'ICRS'), nested=True, colors='black',
                   levels=(50, 90), zorder=1,
                   linestyles=['dashed', 'solid'], linewidths=0.5)
    ax.set_title(title)

fig = pp.figure(figsize=(10, 12), dpi=100)

ax1 = pp.subplot(3, 1, 1, projection='astro degrees mollweide')
ax2 = pp.subplot(3, 1, 2, projection='astro degrees mollweide')

ax3 = pp.subplot(3, 1, 3, projection='astro degrees mollweide')
plot_panel(ax1, gw_map, 'GW skymap', cm.Blues)
plot_panel(ax2, grb_map, 'GRB skymap', cm.Greens)
plot_panel(ax3, comb_map, 'Combined skymap', cm.Oranges)

pp.tight_layout()
pp.show()
../_images/bfd30c372aec3d6b895a7a28fba095ae685f16051f822bfdc88a56c6ad7fcc3c.png

Zoom on the peak of the EXT map

skymap, _ = ligo.skymap.io.fits.read_sky_map('combined_skymap.fits', nest = True) # NEST = True ensures that the order is rearranged properly, both for flat and multi-order maps

ax = pp.axes(projection='astro degrees zoom',
              center=f'52 d +70 d', radius='10 deg')

cls = 100 * ligo.skymap.postprocess.util.find_greedy_credible_levels(skymap)

vmax = np.percentile(skymap[~np.isnan(skymap)], 99.0)
vmin = np.percentile(skymap[~np.isnan(skymap)], 10.0)
vmin = max(vmax/1e3, vmin)

skymap[cls > 90] = np.nan
skymap[skymap == 0] = np.nan


ax.imshow_hpx((skymap, 'ICRS'), nested=True, cmap=cm.Oranges, norm=LogNorm(vmin=vmin, vmax=vmax), zorder=0)
ax.contour_hpx((cls, 'ICRS'), nested=True, colors='black', levels=(50, 90), zorder=1, linestyles=['dashed', 'solid'])
<matplotlib.contour.QuadContourSet at 0x313109450>
../_images/97ad7a299a351914ab3bd85a8534683284d648c0acc7a05112346f2922ce5e15.png

How likely is to have randomly the sky overlap we obtain?#

import healpy as hp
import numpy as np
import matplotlib.pyplot as pp

random_overlaps = []
import ligo.skymap.plot
import ligo.skymap.io.fits
import numpy as np

gw_map, _ = ligo.skymap.io.fits.read_sky_map('Bilby.multiorder.fits', nest=True)
skymap_grb, _ = ligo.skymap.io.fits.read_sky_map('754189311_0_n_PROBMAP.fits', nest=True)

# Generate random rotation angles
n_rotations = 100
phis = np.random.uniform(0.0, 360.0, n_rotations)           # φ ~ U(0, 2π)
u = np.random.uniform(-1.0, 1.0, n_rotations)               # cosθ ~ U(-1, 1)
thetas = np.degrees(np.arccos(u))                           # θ = arccos(u)
psis = np.random.uniform(0.0, 360.0, n_rotations)           # ψ ~ U(0, 2π)

rotated_maps = np.array([
    hp.Rotator(rot=[phi, theta, psi], deg=True).rotate_map_pixel(gw_map)
    for phi, theta, psi in zip(phis, thetas, psis)
])
# Calculate overlaps for all rotated maps
skymap_grb, _ = ligo.skymap.io.fits.read_sky_map('754189311_0_n_PROBMAP.fits', nest=True)
random_overlaps = [np.log(search.skymap_overlap_integral(rotated_map, ext_skymap=skymap_grb)) 
                    for rotated_map in rotated_maps]

pp.hist(random_overlaps, bins=20, alpha=0.7, color='blue', edgecolor='black')
pp.axvline(np.log(sky_overlap), color='red', linestyle='dashed', linewidth=2, label='Actual Overlap')
pp.xlabel('Log Skymap Overlap Integral')
pp.ylabel('Frequency')
pp.yscale('log')
pp.title('Distribution of Skymap Overlap Integrals from Random Rotations')
pp.legend()
<matplotlib.legend.Legend at 0x324871010>
../_images/fee0701169c6d9755e669772b3f1d6b618fd34cfe3f7b303705cb16f7edf662e.png
# number of random overlaps greater than or equal to the actual overlap
n_greater_equal = sum(1 for overlap in random_overlaps if overlap >= np.log(sky_overlap))
p_value = n_greater_equal / n_rotations
p_value
0.06

Bonus: tiling the resulting joint map#

def fibonacci_sphere(n_samples):
    """
    Generate uniformly distributed points on a sphere using Fibonacci lattice.
    This provides optimal uniform coverage - better than HEALPix for circle packing.
    
    Based on: "How to generate equidistributed points on the surface of a sphere"
    by Àlex Comas (2015) and Vogel's method.
    
    Parameters:
    -----------
    n_samples : int
        Number of points to generate
    
    Returns:
    --------
    ra, dec : arrays
        Right ascension and declination in degrees
    """
    golden_ratio = (1 + np.sqrt(5)) / 2
    
    # Generate indices
    i = np.arange(n_samples)
    
    # Latitude: uniform distribution in cos(theta) ensures equal-area spacing
    theta = np.arccos(1 - 2 * (i + 0.5) / n_samples)
    
    # Longitude: golden angle spiral
    phi = 2 * np.pi * i / golden_ratio
    
    # Convert to RA, Dec
    dec = 90 - np.rad2deg(theta)
    ra = np.rad2deg(phi) % 360
    
    return ra, dec



def optimize_circle_coverage(skymap, r_circle_deg, target_coverage=0.9):
    """
    Find optimal number of circles to cover target probability on skymap.
    Uses Fibonacci lattice for uniform initial distribution + greedy selection.
    OPTIMIZED: Pre-ranks circles by total probability, then uses a fast sequential
    pass that only evaluates uncovered gain for the next best candidates.
    
    Key features:
    - Hexagonal-like spacing proxy via spherical cap distance
    - No double-counting: tracks covered pixels to compute only new probability added
    - FAST: Avoids full best-search each iteration; walks sorted candidates
    
    Parameters:
    -----------
    r_circle_deg : float
        Circle radius in degrees
    target_coverage : float
        Target probability coverage (e.g., 0.9 for 90%)
    
    Returns:
    --------
    selected_circles : list of tuples
        (ra, dec, probability_added) for each selected circle
    """
    import healpy as hp
    import numpy as np
    
    r_rad = np.deg2rad(r_circle_deg)
    
    # Estimate sphere area that can be covered by one circle (spherical cap area)
    cap_area = 2 * np.pi * (1 - np.cos(r_rad))  # steradians
    sphere_area = 4 * np.pi  # steradians
    
    # Spacing guideline for spherical hex-like packing
    target_spacing_rad = 2 * np.arcsin(np.sqrt(3)/2 * np.sin(r_rad))
    
    # Candidate count heuristic
    points_per_steradian = 1 / (target_spacing_rad ** 2)
    n_candidates = int(sphere_area * points_per_steradian * 1.5)  # 50% extra
    
    print(f"Circle radius: {r_circle_deg}°")
    print(f"Spherical cap area: {np.rad2deg(np.rad2deg(cap_area)):.2f} deg²")
    print(f"Hexagonal packing spacing (spherical): {np.rad2deg(target_spacing_rad):.3f}°")
    print(f"Generating {n_candidates} candidate positions using Fibonacci lattice...")
    
    # Generate candidate centers
    ra_centers, dec_centers = fibonacci_sphere(n_candidates)
    
    # Prepare circle data with probability information
    nside = hp.npix2nside(len(skymap))
    circle_data = []
    
    # Precompute probabilities per candidate (total within disc)
    for ra_c, dec_c in zip(ra_centers, dec_centers):
        theta_c = np.radians(90 - dec_c)
        phi_c = np.radians(ra_c)
        xyz_c = hp.ang2vec(theta_c, phi_c)
        ipix_disc = np.array(hp.query_disc(nside, xyz_c, r_rad, nest=True), dtype=np.int32)
        total_prob = np.nansum(skymap[ipix_disc])
        circle_data.append((ra_c, dec_c, ipix_disc, total_prob))
    
    # Sort circles by total probability (descending) - pre-ranking step
    circle_data.sort(key=lambda x: x[3], reverse=True)
    
    selected_circles = []
    covered_pixels = np.zeros(len(skymap), dtype=bool)  # Boolean mask
    cumulative_prob = 0.0
    
    print(f"Running fast selection for {target_coverage:.0%} coverage...")
    iteration = 0
    
    # FAST PATH: iterate candidates in descending total_prob order
    for idx, (ra_c, dec_c, ipix_disc, total_prob) in enumerate(circle_data):
        if cumulative_prob >= target_coverage:
            break
        iteration += 1
        
        # Compute uncovered gain only for this candidate
        uncovered_mask = ~covered_pixels[ipix_disc]
        new_prob = np.nansum(skymap[ipix_disc[uncovered_mask]])
        
        # If adds something, accept and update covered mask
        if new_prob > 0:
            selected_circles.append((ra_c, dec_c, new_prob))
            covered_pixels[ipix_disc[uncovered_mask]] = True
            cumulative_prob += new_prob
            
            # Progress update every 10 accepts
            if len(selected_circles) % 10 == 0:
                avg_new_prob = cumulative_prob / len(selected_circles)
                #print(f"  Accepted {len(selected_circles)} circles, coverage: {cumulative_prob:.2%}, "
                #     f"avg new prob/circle: {avg_new_prob:.3%}")
    
    print(f"\n✓ Selected {len(selected_circles)} circles covering {cumulative_prob:.2%}")
    if len(selected_circles) > 0:
        print(f"  Average new probability per circle: {cumulative_prob/len(selected_circles):.3%}")
    print(f"  Total unique pixels covered: {np.sum(covered_pixels):,} (no double-counting)")
    
    return selected_circles



# Test with different circle sizes
r_circle = 23/2 / 60  # degrees
skymap, _ = ligo.skymap.io.fits.read_sky_map('combined_skymap.fits', nest = True) # NEST = True ensures that the order is rearranged properly, both for flat and multi-order maps
selected_circles = optimize_circle_coverage(skymap, r_circle, target_coverage=0.90)
Circle radius: 0.19166666666666668°
Spherical cap area: 0.12 deg²
Hexagonal packing spacing (spherical): 0.332°
Generating 561477 candidate positions using Fibonacci lattice...
Running fast selection for 90% coverage...

✓ Selected 2364 circles covering 90.00%
  Average new probability per circle: 0.038%
  Total unique pixels covered: 55,184 (no double-counting)
Running fast selection for 90% coverage...

✓ Selected 2364 circles covering 90.00%
  Average new probability per circle: 0.038%
  Total unique pixels covered: 55,184 (no double-counting)
# Visualize the selected circles on the skymap
import matplotlib.pyplot as plt
import ligo.skymap.postprocess.util
from astropy import units as u

cls = 100 * ligo.skymap.postprocess.util.find_greedy_credible_levels(skymap)

def plot_spherical_cap(ax, center_lon_deg, center_lat_deg, radius_deg,
                       npoints=720, fill=True, facecolor='none', edgecolor='red',
                       alpha=0.3, linewidth=0.8, zorder=5):
    """
    Plot a spherical cap (sphere ∩ cone) on an axes that accepts transform=ax.get_transform('icrs').
    center_lon_deg, center_lat_deg: degrees (RA/longitude, Dec/latitude in ICRS)
    radius_deg: angular radius of the cap in degrees
    npoints: number of points to sample boundary (higher -> smoother)
    
    Handles circles that cross the RA=0/360 boundary by splitting them into multiple segments.
    """
    # center as SkyCoord and radial positions around it
    center = SkyCoord(center_lon_deg * u.deg, center_lat_deg * u.deg, frame='icrs')
    pos_angles = np.linspace(0, 360, npoints) * u.deg
    boundary = center.directional_offset_by(pos_angles, radius_deg * u.deg)

    # Get RA in [0, 360) range
    ra = boundary.ra.deg
    dec = boundary.dec.deg

    # Detect large jumps in RA (>180 deg) indicating boundary crossing
    ra_diff = np.diff(ra)
    jumps = np.abs(ra_diff) > 180
    split_idx = np.where(jumps)[0]
    
    # Split into continuous segments
    segments = []
    start = 0
    for idx in split_idx:
        segments.append((ra[start:idx + 1], dec[start:idx + 1]))
        start = idx + 1
    segments.append((ra[start:], dec[start:]))

    # Plot each continuous segment
    transform = ax.get_transform('icrs')

    for seg_ra, seg_dec in segments:
        if len(seg_ra) < 2:
            continue
        if fill and facecolor != 'none':
            ax.fill(seg_ra, seg_dec, transform=transform,
                    facecolor=facecolor, edgecolor='none', alpha=alpha, zorder=zorder)
        # Always draw edge so the cap boundary is visible
        ax.plot(seg_ra, seg_dec, transform=transform, color=edgecolor,
                linewidth=linewidth, alpha=alpha, zorder=zorder + 1)
        
        # plot rank inside the cap
        


fig = plt.figure(figsize=(14, 8), dpi=300)
fig.patch.set_visible(False)
ax = pp.axes(projection='astro degrees zoom',
              center=f'52 d +70 d', radius='10 deg')
ax.set_facecolor('none')
ax.grid()

# Plot skymap
ax.imshow_hpx((skymap, 'ICRS'), nested=True, cmap='Blues', alpha=0.6)
ax.contour_hpx((cls, 'ICRS'), nested=True, colors='black',
               levels=(50, 90), zorder=1, linestyles=['dashed', 'solid'],
               linewidths=0.7)

# Plot selected circles as spherical caps
from astropy.coordinates import SkyCoord
print(f"Plotting {len(selected_circles)} optimized circles...")
rank = 1
for ra_c, dec_c, prob in selected_circles:
    plot_spherical_cap(ax, center_lon_deg=ra_c, center_lat_deg=dec_c, 
                       radius_deg=r_circle,
                       npoints=360, fill=False, facecolor='none', 
                       edgecolor='red', alpha=1, linewidth=1)

    # ax.text(ra_c, dec_c, str(rank), color='black', fontsize=8,
    #         ha='center', va='center', zorder=10, transform=ax.get_transform('icrs'))
    rank += 1


plt.tight_layout()
plt.show()
Plotting 2364 optimized circles...
../_images/fdeaabeb77c44bed91b24b10ec5cfbbcd2f46471607953ea1222e31d068a2da8.png