WFSS Spectra Part 3: Emission Line Map#

Use case: optimal extraction of grism spectra; redshift measurement; emission-line maps. Simplified version of JDox Science Use Case # 33.
Data: JWST simulated NIRISS images from MIRAGE, run through the JWST calibration pipeline; galaxy cluster.
Tools: specutils, astropy, pandas, emcee, lmfit, corner, h5py.
Cross-intrument: NIRSpec
Documentation: This notebook is part of a STScI’s larger post-pipeline Data Analysis Tools Ecosystem.

Introduction#

This notebook is 4 of 4 in a set focusing on NIRISS WFSS data: 1. 1D optimal extraction since the JWST pipeline only provides a box extraction. Optimal extraction improves S/N of spectra for faint sources. 2. Combine and normalize 1D spectra. 3. Cross correlate galaxy with template to get redshift. 4. Spatially resolved emission line map.

This notebook obtains an emission line map from the 2D-cutout of NIRISS WFSS spectra. This notebook will require the 2D-cutout of NIRISS WFSS spectrum (e.g., l3_nis_f150w_G150C_s00004_cal.fits; Pipeline spec3 product).

Note: For this example, the notebook uses only a 2D rectified spectra at one dither position, directly obtained from the current versionn of the pipeline (Build 7.5), but one could start with spectra stacked with all dither positions too, to improve S/Ns.

%matplotlib inline
import os

import numpy as np

from astropy.io import fits
import astropy.units as u
from astropy.modeling import models
from astropy.modeling.polynomial import Chebyshev1D

from specutils.spectra import Spectrum1D
from specutils.fitting import fit_lines
from specutils.fitting import continuum

from astropy import __version__ as astropy_version
print('astropy', astropy_version)
astropy 6.0.0
import matplotlib.pyplot as plt
import matplotlib as mpl

mpl.rcParams['savefig.dpi'] = 80
mpl.rcParams['figure.dpi'] = 80

0. Download notebook 01 products#

These can be also obtained by running the notebooks.

if not os.path.exists('./output'):
    import zipfile
    import urllib.request
    boxlink = 'https://data.science.stsci.edu/redirect/JWST/jwst-data_analysis_tools/NIRISS_lensing_cluster/output.zip'
    boxfile = './output.zip'
    urllib.request.urlretrieve(boxlink, boxfile)
    zf = zipfile.ZipFile(boxfile, 'r')
    zf.extractall()
else:
    print('Already exists')
# Download files, if not exists yet.
if not os.path.exists('./pipeline_products'):
    import zipfile
    import urllib.request
    boxlink = 'https://data.science.stsci.edu/redirect/JWST/jwst-data_analysis_tools/NIRISS_lensing_cluster/pipeline_products.zip'
    boxfile = './pipeline_products.zip'
    urllib.request.urlretrieve(boxlink, boxfile)
    zf = zipfile.ZipFile(boxfile, 'r')
    zf.extractall()
# Which target?
DIR_OUT = './output/'
filt = 'f200w'

grism = 'G150C'
# grism = 'G150R'

id = '00004'

1.Get Halpha emission line map;#

# Open two-dimensional file;
# can be downloaded in No.01a.
DIR_DATA = './pipeline_products/'

# Zero-indexed number for dither --- the test data has two dither positions.
ndither = 0

# File
file_2d = f'{DIR_DATA}l3_nis_{filt}_{grism}_s{id}_cal.fits'
hdu_2d = fits.open(file_2d)

# Align grism direction
#   - x-direction = Dispersion (wavelength) direction.
#   - y-direction = Cross-dispersion.
# in this notebook.
    
if grism == 'G150C':
    # If spectrum is horizontal;
    data_2d = hdu_2d[ndither*7+1].data
    dq_2d = hdu_2d[ndither*7+2].data
    err_2d = hdu_2d[ndither*7+3].data
    wave_2d = hdu_2d[ndither*7+4].data
else:
    data_2d = rotate(hdu_2d[ndither*7+1].data, 90)
    dq_2d = rotate(hdu_2d[ndither*7+2].data, 90)
    err_2d = rotate(hdu_2d[ndither*7+3].data, 90)
    wave_2d = rotate(hdu_2d[ndither*7+4].data, 90)
plt.imshow(data_2d[:, :])
<matplotlib.image.AxesImage at 0x7fcd7c473350>
../../_images/e41e1fe17b7e856ce78fc69a7804d40f557e1b350c3f50c65ed01a976b50e07e.png

Subtract continuum at each y-position:#

There are two options, depending on the brightness of the source;

  • 1.Subtract continuum estimated at each position of cross-dispersion column. This works for a bright source.

  • 2.Subtract a single continuum (“master continuum”), assuming the shape is same across cross-dispersion direction as the one from a 1d extraction. This is required for a faint source.

Since we clearly see continuum across the source, here we demonstrate the option 1.

# At which y position do we want to see spectrum?
# as an example;
yy = 10

spec_unit = u.dimensionless_unscaled

mask_line = ((wave_2d[yy, :] > 1.75) & (wave_2d[yy, :] < 1.97)) | ((wave_2d[yy, :] > 2.08) & (wave_2d[yy, :] < 2.23))

obs = Spectrum1D(spectral_axis=wave_2d[yy, :][mask_line]*u.um, flux=data_2d[yy, :][mask_line]*spec_unit)
cont = continuum.fit_generic_continuum(obs, model=Chebyshev1D(7))

plt.plot(wave_2d[yy, :], data_2d[yy, :], color='r', label=f'Observed at y={yy}')
plt.plot(wave_2d[yy, :][mask_line]*u.um, cont(wave_2d[yy, :][mask_line]*u.um), color='b', label='Fitted continuum')

plt.legend(loc=0)
plt.xlim(1.6, 2.3)
WARNING: Model is linear in parameters; consider using linear fitting methods. [astropy.modeling.fitting]
(1.6, 2.3)
../../_images/d375ba1123c7f55a02fdf33042e78b43874a68cb634e4f60bd511b1b252b05d0.png
cont(wave_2d[yy, :][mask_line]*u.um).value
array([250.33514625, 251.34196997, 252.3399539 , 253.31373358,
       254.24980241, 255.13666809, 255.96455649, 256.72516215,
       257.41178054, 258.01904392, 258.54273027, 258.97986573,
       259.32840475, 259.58730346, 259.75633833, 259.83601505,
       259.82753631, 259.73268276, 259.55376941, 259.29355234,
       258.95516032, 258.54210389, 258.05809227, 257.50714698,
       256.89339286, 256.22107226, 255.4946214 , 254.71842986,
       253.89689648, 253.03453451, 252.13569698, 251.2046636 ,
       227.2937026 , 226.56797698, 225.86951509, 225.19837698,
       224.55451447, 223.93770343, 223.34764344, 222.78388357,
       222.24585274, 221.73279607, 221.24384862, 220.7779845 ,
       220.33396715, 219.91041011, 219.50570908, 219.11804745,
       218.74533159, 218.38522917, 218.03510699, 217.69197741,
       217.35251716, 217.01299411, 216.66924474, 216.31659529,
       215.94986731, 215.56329808, 215.15045747, 214.70424554,
       214.21679044, 213.6794031 , 213.08244929, 212.41535398,
       211.66646567, 210.82300532, 209.87088579, 208.79474276,
       207.57778338, 206.20156842, 204.64608538, 202.88950983,
       200.90816203, 198.67616835, 196.16562581, 193.34633991,
       190.18542588, 186.64758372, 182.694655  ])
# Repeat this along y-axis;
flux_cont2d = data_2d[:, :] * 0
for yy in range(len(data_2d[:, 0])):

    mask_line = ((wave_2d[yy, :] > 1.75) & (wave_2d[yy, :] < 1.97)) | ((wave_2d[yy, :] > 2.08) & (wave_2d[yy, :] < 2.23))

    obs = Spectrum1D(spectral_axis=wave_2d[yy, :][mask_line]*u.um, flux=data_2d[yy, :][mask_line]*spec_unit)
    cont = continuum.fit_generic_continuum(obs, model=Chebyshev1D(7))

    flux_cont2d[yy, :] = cont(wave_2d[yy, :]*u.um).value
plt.imshow(flux_cont2d[:, :])
plt.title('Fitted continuum')
Text(0.5, 1.0, 'Fitted continuum')
../../_images/749af42c1849dbd3d7a3dc288a10228bccd431e213b50ab6190545b44e75deb5.png
plt.imshow(data_2d[:, :] - flux_cont2d[:, :])
plt.title('Continuum subtracted spectrum')
Text(0.5, 1.0, 'Continuum subtracted spectrum')
../../_images/789524588b2ff2685896a13c0930975e08443bbb3f854d886cc00e1e389c75d4.png

Extract Halpha emission from a continuum-subtracted spectrum;#

# Cutout Ha line map;
rsq = data_2d.shape[0]
cut_ha = np.zeros((rsq, rsq), 'float32')

zin = 2.1 # From Notebook No.3, cross-correlation;
lamcen = 0.6564 * (1. + zin)

for yy in range(len(data_2d[:, 0])):
    # This has to be done at each y pixel, as wavelength array can be tilted.
    index_lamcen = np.argmin(np.abs(lamcen - wave_2d[yy, :]))
    cut_ha[yy, :] = (data_2d - flux_cont2d)[yy, int(index_lamcen-rsq/2.):int(index_lamcen+rsq/2.)]

plt.imshow(cut_ha)
plt.title('H$\\alpha$ map')
Text(0.5, 1.0, 'H$\\alpha$ map')
../../_images/87e8f98b63306e40ae8aafa25195bf39a6680640de331597d84ece89adc50c4e.png

2.Get Hbeta and OIII maps#

This is more challenging, as these lines locate close to each other. Ideally, iteration process will be preferred, but here we use Specutils’ double gaussian component fitting, in a similar way for Ha.

filt = 'f150w'

file_2d = f'{DIR_DATA}l3_nis_{filt}_{grism}_s{id}_cal.fits'
hdu_2d = fits.open(file_2d)

# Align grism direction
#   - x-direction = Dispersion (wavelength) direction.
#   - y-direction = Cross-dispersion.
# in this notebook.
    
if grism == 'G150C':
    # If spectrum is horizontal;
    data_2d = hdu_2d[ndither*7+1].data
    dq_2d = hdu_2d[ndither*7+2].data
    err_2d = hdu_2d[ndither*7+3].data
    wave_2d = hdu_2d[ndither*7+4].data
else:
    data_2d = rotate(hdu_2d[ndither*7+1].data, 90)
    dq_2d = rotate(hdu_2d[ndither*7+2].data, 90)
    err_2d = rotate(hdu_2d[ndither*7+3].data, 90)
    wave_2d = rotate(hdu_2d[ndither*7+4].data, 90)
    
# !! Note that the extracted spectra has flipped wavelength direction !!
plt.imshow(data_2d[:, ::-1])
plt.title(f'{filt}')
Text(0.5, 1.0, 'f150w')
../../_images/424861b3a29a433faeb98c7a8e1e5f4e28ce140d6d0a3b27bd66f70fe7c811de.png

In the plot above, you can see Oiii doublet, and Hbeta.#

2a. Get continuum;#

yy = 10 # as an example;

spec_unit = u.dimensionless_unscaled

mask_line = ((wave_2d[yy, :] > 1.35) & (wave_2d[yy, :] < 1.48)) | ((wave_2d[yy, :] > 1.6) & (wave_2d[yy, :] < 1.65))

obs = Spectrum1D(spectral_axis=wave_2d[yy, :][mask_line]*u.um, flux=data_2d[yy, :][mask_line]*spec_unit)
cont = continuum.fit_generic_continuum(obs, model=Chebyshev1D(7))

plt.plot(wave_2d[yy, :], data_2d[yy, :], color='r', label=f'Observed at y={yy}')

plt.plot(wave_2d[yy, :][mask_line]*u.um, cont(wave_2d[yy, :][mask_line]*u.um), color='b', label='Fitted continuum')

plt.legend(loc=0)
plt.xlim(1.2, 1.8)
(1.2, 1.8)
../../_images/9a5c6f1d888bdcc8189366e83bdd2a771328baeeccccc3d176af3522bd619215.png
# Repeat this along y-axis;
flux_cont2d_150 = data_2d[:, :] * 0
for yy in range(len(data_2d[:, 0])):

    mask_line = ((wave_2d[yy, :] > 1.35) & (wave_2d[yy, :] < 1.48)) | ((wave_2d[yy, :] > 1.6) & (wave_2d[yy, :] < 1.65))

    obs = Spectrum1D(spectral_axis=wave_2d[yy, :][mask_line]*u.um, flux=data_2d[yy, :][mask_line]*spec_unit)
    cont = continuum.fit_generic_continuum(obs, model=Chebyshev1D(7))

    flux_cont2d_150[yy, :] = cont(wave_2d[yy, :]*u.um).value

plt.imshow(flux_cont2d_150[:, ::-1])
<matplotlib.image.AxesImage at 0x7fcd79ecb350>
../../_images/eb85d3e494c31daee225aae7341510e16c5adf3d4dba97063658dc555c68a5e6.png
line_2d = data_2d[:, :] - flux_cont2d_150[:, :]

plt.imshow(line_2d[:, ::-1])
<matplotlib.image.AxesImage at 0x7fcd7c483350>
../../_images/288921282cfd986d2d634138e67883859b0572e404bad8735def578077da4847.png

2b. Fit emissionn, with multi-conponent gaussian;#

yy = 10

# Fit the spectrum
con = (1.4 < wave_2d[yy, :]) & (wave_2d[yy, :] < 1.65)

spectrum_cut = Spectrum1D(flux=line_2d[yy, :][con]*spec_unit,
                          spectral_axis=wave_2d[yy, :][con]*u.um)

# !!! Some tweaks may be needed for initial value, to successfuully run the fit;

# For Hb
g1_init = models.Gaussian1D(amplitude=40*spec_unit, mean=1.5*u.um, stddev=0.005*u.um)

# For O3 blue
g2_init = models.Gaussian1D(amplitude=50.*spec_unit, mean=1.535*u.um, stddev=0.002*u.um)

# For O3 red
g3_init = models.Gaussian1D(amplitude=45.*spec_unit, mean=1.55*u.um, stddev=0.001*u.um)

g123_fit = fit_lines(spectrum_cut, g1_init+g2_init+g3_init, window=[0.01*u.um, 0.001*u.um, 0.001*u.um])
y_fit = g123_fit(wave_2d[yy, :]*u.um)

print(g123_fit)
Model: CompoundModel
Inputs: ('x',)
Outputs: ('y',)
Model set size: 1
Expression: [0] + [1] + [2]
Components: 
    [0]: <Gaussian1D(amplitude=105.67672529 , mean=1.50774443 um, stddev=0.00935698 um)>

    [1]: <Gaussian1D(amplitude=82.09499951 , mean=1.53395958 um, stddev=0.0064488 um)>

    [2]: <Gaussian1D(amplitude=186.44732716 , mean=1.54938177 um, stddev=0.0110966 um)>
Parameters:
       amplitude_0           mean_0       ...       stddev_2      
                               um         ...          um         
    ------------------ ------------------ ... --------------------
    105.67672528737337 1.5077444323784512 ... 0.011096600264667938
WARNING: The fit may be unsuccessful; check fit_info['message'] for more information. [astropy.modeling.fitting]
# Plot separately?
plt.plot(wave_2d[yy, :], line_2d[yy, :], marker='.', ls='', color='r', label=f'Observed at y={yy}')
plt.plot(wave_2d[yy, :], y_fit, color='b', label='Fit', zorder=-2, alpha=0.4, lw=5)

y_fit1 = g123_fit[0](wave_2d[yy, :]*u.um)
plt.plot(wave_2d[yy, :], y_fit1, color='g', label='1')

y_fit1 = g123_fit[1](wave_2d[yy, :]*u.um)
plt.plot(wave_2d[yy, :], y_fit1, color='orange', label='2')

y_fit1 = g123_fit[2](wave_2d[yy, :]*u.um)
plt.plot(wave_2d[yy, :], y_fit1, color='purple', label='3')

plt.xlim(1.4, 1.6)

plt.title('Single fit peak')
plt.grid(True)
plt.legend(loc=2)
<matplotlib.legend.Legend at 0x7fcd79f24150>
../../_images/d38423c906e2539cc1fcdd969494fc7a6e6added33442e5eba48fce3a7237c2b.png
print(g123_fit[0])
print(g123_fit[1])
print(g123_fit[2])
Model: Gaussian1D
Inputs: ('x',)
Outputs: ('y',)
Model set size: 1
Parameters:
        amplitude             mean              stddev      
                               um                 um        
    ------------------ ------------------ ------------------
    105.67672528737337 1.5077444323784512 0.0093569770790276
Model: Gaussian1D
Inputs: ('x',)
Outputs: ('y',)
Model set size: 1
Parameters:
        amplitude            mean                stddev       
                              um                   um         
    ----------------- ------------------ ---------------------
    82.09499950852582 1.5339595832025545 0.0064488025726343505
Model: Gaussian1D
Inputs: ('x',)
Outputs: ('y',)
Model set size: 1
Parameters:
        amplitude             mean               stddev       
                               um                  um         
    ------------------ ------------------ --------------------
    186.44732715978859 1.5493817718595204 0.011096600264667938

Fit to the central array looks good. Repeat this along y-axis and get emission line maps, as a same way as for Halpha#

# Cutout Hb, Oiii line maps;
rsq = data_2d.shape[0]
cut_hb = np.zeros((rsq, rsq), 'float32')
cut_o3b = np.zeros((rsq, rsq), 'float32')
cut_o3r = np.zeros((rsq, rsq), 'float32')

zin = 2.1 # Redshift estimate from Notebook No.2, cross-correlation;
lamcen_hb = 0.4862680 * (1. + zin)
lamcen_o3b = 0.4960295 * (1. + zin)
lamcen_o3r = 0.5008240 * (1. + zin)

for yy in range(len(data_2d[:, 0])):
    # Fit the spectrum
    con = (1.4 < wave_2d[yy, :]) & (wave_2d[yy, :] < 1.65)
    spectrum_cut = Spectrum1D(flux=line_2d[yy, :][con]*spec_unit, 
                              spectral_axis=wave_2d[yy, :][con]*u.um)

    # !!! Some tweaks may be needed for initial value, to successfuully run the fit;

    # For Hb
    g1_init = models.Gaussian1D(amplitude=40*spec_unit, mean=1.5*u.um, stddev=0.005*u.um)
    # For O3 blue
    g2_init = models.Gaussian1D(amplitude=50.*spec_unit, mean=1.535*u.um, stddev=0.002*u.um)
    # For O3 red
    g3_init = models.Gaussian1D(amplitude=45.*spec_unit, mean=1.55*u.um, stddev=0.001*u.um)
    g123_fit = fit_lines(spectrum_cut, g1_init+g2_init+g3_init, window=[0.01*u.um, 0.001*u.um, 0.001*u.um])
    y_fit = g123_fit(wave_2d[yy, :]*u.um)

    # This has to be done at each y pixel, as wavelength array can be tilted.
    index_lamcen_hb = np.argmin(np.abs(lamcen_hb - wave_2d[yy, :]))
    cut_hb[yy, :] = g123_fit[0](wave_2d[yy, :]*u.um)[int(index_lamcen_hb-rsq/2.):int(index_lamcen_hb+rsq/2.)]

    index_lamcen_o3b = np.argmin(np.abs(lamcen_o3b - wave_2d[yy, :]))
    cut_o3b[yy, :] = g123_fit[1](wave_2d[yy, :]*u.um)[int(index_lamcen_o3b-rsq/2.):int(index_lamcen_o3b+rsq/2.)]

    index_lamcen_o3r = np.argmin(np.abs(lamcen_o3r - wave_2d[yy, :]))
    cut_o3r[yy, :] = g123_fit[2](wave_2d[yy, :]*u.um)[int(index_lamcen_o3r-rsq/2.):int(index_lamcen_o3r+rsq/2.)]
WARNING: The fit may be unsuccessful; check fit_info['message'] for more information. [astropy.modeling.fitting]
plt.imshow(cut_hb)
plt.title('H$\\beta$ map')
Text(0.5, 1.0, 'H$\\beta$ map')
../../_images/23a1119a68212e49e07bb4857a7db9e32f4d254af2e81d7395abcb375eae44ef.png
plt.imshow(cut_o3b)
plt.title('Oiii 4960')
Text(0.5, 1.0, 'Oiii 4960')
../../_images/58c2dd3943b1325873eecc0976c986b186004c80cc0942937713f5308d1caf6c.png
plt.imshow(cut_o3r)
plt.title('Oiii 5008')
Text(0.5, 1.0, 'Oiii 5008')
../../_images/d980218208a620474c675ff96f2e54a391bbc535daf45151a025733f7c82fb56.png

Summary;#

As seen above, regions except for the very center do not look right. This is due to failure of multi-component fit, especially for Oiii doublet. To improve the fit, one can either;

  • fit and inspect the fit repeatedly at each y-axis until it converges,

  • use MCMC for more intensive fitting, which also enables to fix the ratio of two Oiii lines by setting up a prior.

  • or reduce the number of components, especially for Oiii doublet at the edge of the source position, where the lines are blended.