Stellar Velocity Mapping — Liger IFS

[ ]:
from liger_iris_sim.sources import make_point_source_ifs_cube
from liger_iris_sim.expose import compute_throughput, expose_ifs
from liger_iris_sim.sky import get_maunakea_sky_background
from liger_iris_sim.utils import LIGER_PROPS, compute_filter_photon_flux, generate_wave_grid_for_filter, get_psfs

from liger_iris_drp_resources.filters import load_filters_summary

import numpy as np
import matplotlib.pyplot as plt

Instrument and exposure parameters

[ ]:
np.random.seed(1)

instrument_name = 'Liger'
instrument_mode = 'ifs'
ifs_mode = 'lenslet'  # 'lenslet' or 'slicer'
filt = 'J'
size = (128, 128)       # IFS spatial dimensions (y, x) in spaxels
read_noise = 9          # e- RMS
dark_current = 0.025    # e- / sec / pixel
scale = 0.014           # arcsec / spaxel (lenslet)
itime = 30              # integration time, sec
n_frames = 1            # number of coadded frames
resolution = 4000       # spectral resolution R = lambda / delta_lambda
collarea = LIGER_PROPS['collarea']  # m^2

Filter, throughput, and wavelength grid

[ ]:
filter_info = load_filters_summary(filter_name=filt)

tput = compute_throughput(
    instrument_name=instrument_name,
    instrument_mode=instrument_mode,
    wave=filter_info['wavecenter'],
    ifs_mode=ifs_mode,
    filt=filt,
)
print(f"Total throughput for {ifs_mode} mode at {filter_info['wavecenter']:.3f} µm: {tput:.3f}")

wave = generate_wave_grid_for_filter(filter_info, resolution=resolution)
print(f"Wavelength grid: {wave[0]:.4f}{wave[-1]:.4f} µm, {len(wave)} channels")

Define emission line sources for simplicity instead of stellar spectrum

Each star gets a Gaussian spectral template centred at the filter wavelength. A small velocity field is imposed by shifting the template peak radially from the field centre.

[ ]:
n_stars = 100
mag_range = (16, 17)

mag = np.random.uniform(mag_range[0], mag_range[1], size=n_stars)
xpix = np.random.uniform(5, size[1] - 5, size=n_stars)
ypix = np.random.uniform(5, size[0] - 5, size=n_stars)
photon_flux = compute_filter_photon_flux(mag, zp=filter_info['zpphot'])  # photons / sec / m^2

# Gaussian spectrum template (normalised to unit integral)
def gauss(x, A, mu, sigma):
    return A * np.exp(-(x - mu)**2 / (2 * sigma**2))

spec = gauss(wave, A=1, mu=filter_info['wavecenter'], sigma=filter_info['wavecenter'] / resolution)
template_flux = spec / spec.sum()
template = (wave, template_flux)

# Radial velocity field: z increases with distance from field centre
r = np.sqrt((xpix - xpix.mean())**2 + (ypix - ypix.mean())**2)
zs = 0.002 * (r / r.max())

print(f"Injected redshifts: {zs.min():.5f}{zs.max():.5f}")

Load PSFs

[ ]:
psfs, psf_infos, psf_indices = get_psfs(
    instrument_name=instrument_name,
    instrument_mode=instrument_mode,
    wave=filter_info['wavecenter'],
    xdet=xpix, ydet=ypix,
    output_plate_scale=scale,
)

Sky background

[ ]:
sky_data = get_maunakea_sky_background(
    wave, resolution=resolution,
    T_tel=275, T_atm=258, T_aos=243,
    Em_tel=0.09, Em_atm=0.2, Em_aos=0.01,
    plate_scale=scale,
)

sky_em_rate = sky_data['sky_em']
sky_trans = sky_data['sky_trans']

Build source cube and simulate exposure

[ ]:
input_cube_rate = np.zeros((len(wave), *size), dtype=np.float32)

make_point_source_ifs_cube(
    xdet=xpix, ydet=ypix, wave=wave,
    template=template,
    flux_int=photon_flux,
    psf=psfs,
    psf_indices=psf_indices,
    cube_out=input_cube_rate,
    z=zs,
)

sim = expose_ifs(
    input_cube_rate,
    itime=itime, n_frames=n_frames, collarea=collarea,
    sky_emission_rate=sky_em_rate,
    sky_transmission=sky_trans,
    tput=tput, read_noise=read_noise, dark_current=dark_current,
)

print(f"Output cube shape: {sim['observed_tot'].shape}")
print(f"Peak SNR (per voxel): {np.nanmax(sim['snr']):.1f}")

SNR map

[ ]:
snr_map = np.nansum(sim['snr']**2, axis=0)**0.5

plt.figure()
im = plt.imshow(snr_map, origin='lower', cmap='viridis', vmin=0)
plt.colorbar(im, label='SNR')
plt.title('SNR integrated over bandpass')
plt.show()

Velocity map recovery

For each star the peak-flux spaxel is used. The flux-weighted centroid of the spectrum within ±6 channels of the peak gives the observed wavelength; converting to redshift recovers the injected velocity field.

[ ]:
zs_rec = np.zeros(n_stars, dtype=np.float32)

for i in range(n_stars):
    ii, jj = int(round(ypix[i])), int(round(xpix[i]))
    spec_i = input_cube_rate[:, ii, jj]
    if spec_i.sum() > 0:
        k = np.argmax(spec_i)
        ki = max(k - 6, 0)
        kf = min(k + 6, len(wave) - 1)
        ws = wave[ki:kf + 1]
        wt = spec_i[ki:kf + 1]
        wc = np.sum(ws * wt) / np.sum(wt)
        zs_rec[i] = (wc - filter_info['wavecenter']) / filter_info['wavecenter']

# Convert to km/s
c_kms = 2.998e5
v_true = zs * c_kms
v_rec  = zs_rec * c_kms

print(f"Residuals (km/s): {(v_rec - v_true)}")
[ ]:
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))

# --- velocity map on the sky ---
sc = axes[0].scatter(xpix, ypix, c=v_rec, cmap='RdBu_r', s=80,
                     vmin=v_true.min(), vmax=v_true.max())
plt.colorbar(sc, ax=axes[0], label='Recovered velocity (km/s)')
axes[0].set_xlim(0, size[1])
axes[0].set_ylim(0, size[0])
axes[0].set_xlabel('x (spaxels)')
axes[0].set_ylabel('y (spaxels)')
axes[0].set_title('Velocity map')

# --- injected vs recovered ---
axes[1].plot(v_true, v_rec, 'o')
vmin, vmax = v_true.min(), v_true.max()
axes[1].plot([vmin, vmax], [vmin, vmax], 'k--', lw=1)
axes[1].set_xlabel('Injected velocity (km/s)')
axes[1].set_ylabel('Recovered velocity (km/s)')
axes[1].set_title('Injected vs recovered')

plt.tight_layout()
plt.show()