Spectroscopic Data Reduction#

Spectroscopic data reduction is the process of converting raw spectroscopic images from a spectrograph into a calibrated spectrum. This process involves several steps, including bias subtraction, flat fielding, wavelength calibration, and extraction of the spectrum. In this notebook, we will go through the process of reducing a spectroscopic image using Python.

The CCD pre-processing steps are as usual: bias/dark subtraction, flat fielding, and cosmic ray removal. The spectroscopic data reduction steps are: extraction of the spectrum, wavelength calibration and flux calibration. Here we will focus mostly on the spectroscopic data reduction steps. After the data reduction, the spectrum can be used for scientific analysis, such as measuring redshifts, line identifications, and measuring line fluxes. In this notebook, we will reduce a 2D spectrum of an afterglow of a gamma-ray burst (GRB). Since this tutorial is prepared for the Project No. 5 of our TAO class, I leave the scientific analysis of the spectrum to readers.

The data used in this notebook is from the Subaru Telescope’s Faint Object Camera and Spectrograph (FOCAS). The data is 2D images of a spectrum of an afterglow of a GRB, GRB 130606A, which is a significantly redshifted source at \(z\sim5.9\). The data was taken on June 7, 2013, using the VPH900 grism, which covers the wavelength range of 7500-10450 Angstroms (Subaru FOCAS Hompage).

Let’s start by organizing the data and displaying the 2D spectral images.

import warnings
from pathlib import Path

import numpy as np
import plotly.express as px
import plotly.graph_objects as go
import plotly.io as pio
import preproc  # custom module for data reduction
from astropy import units as u
from astropy.io import fits
from astropy.modeling.fitting import LevMarLSQFitter
from astropy.modeling.models import Chebyshev2D
from astropy.nddata import CCDData
from astropy.stats import gaussian_fwhm_to_sigma, sigma_clip
from astropy.table import Table
from astropy.visualization import ZScaleInterval
from ccdproc import combine, cosmicray_lacosmic, gain_correct
from matplotlib import pyplot as plt
from mpl_toolkits.axes_grid1 import make_axes_locatable
from numpy.polynomial.chebyshev import chebfit, chebval
from rascal import util
from rascal.atlas import Atlas
from rascal.calibrator import Calibrator
from scipy.ndimage import map_coordinates, median_filter
from scipy.signal import peak_widths
from skimage.feature import peak_local_max
from specutils import Spectrum1D
from specutils.manipulation import FluxConservingResampler

pio.renderers.default = "notebook"

# plt.rcParams['axes.linewidth'] = 2
plt.rcParams["font.size"] = 12
plt.rcParams["axes.labelsize"] = 14

WD = Path("./data/proj5")
RAWDIR = WD / "raw" / "P03hbahk0304145443GZ_data"
OUTDIR = WD / "out"

if not OUTDIR.exists():
    OUTDIR.mkdir(parents=True)

warnings.filterwarnings("ignore", category=UserWarning, append=True)
/opt/anaconda3/envs/main/lib/python3.11/site-packages/rascal/calibrator.py:9: TqdmExperimentalWarning:

Using `tqdm.autonotebook.tqdm` in notebook mode. Use `tqdm.tqdm` instead to force console mode (e.g. in jupyter console)
def make_summary_table_focas(rawdir, suffix=".fits.gz"):
    """Make a summary table of the raw data in the directory. This function is
    specifically designed for the data taken with the FOCAS instrument.

    Args:
        rawdir (pathlib.Path): The directory containing the raw data.
        suffix (str, optional): The suffix of the raw data files. Defaults to
            '.fits.gz'.

    Returns:
        stab (astropy.table.Table): The summary table of the raw data in the directory.

    """
    # making a summary table
    summary = []
    for f in rawdir.glob("*" + suffix):
        hdr = fits.getheader(f)

        # getting the filter information from the header
        filt1 = hdr["FILTER01"]
        filt2 = hdr["FILTER02"]
        filt3 = hdr["FILTER03"]

        X = hdr["AIRMASS"]  # airmass
        disp = hdr["DISPERSR"]  # disperser

        # appending the data to the summary list
        summary.append(
            [
                f.name,
                hdr["DATE-OBS"],
                hdr["OBJECT"],
                hdr["RA"],
                hdr["DEC"],
                hdr["DATA-TYP"],
                hdr["EXPTIME"],
                X,
                hdr["ALTITUDE"],
                disp,
                hdr["SLT-WID"],
                hdr["SLT-PA"],
                hdr["SLIT"],
                filt1,
                filt2,
                filt3,
                hdr["UT-STR"],
                hdr["UT-END"],
            ]
        )

    # creating the summary table
    stab = Table(
        rows=summary,
        names=[
            "filename",
            "date-obs",
            "object",
            "ra",
            "dec",
            "type",
            "exptime",
            "airmass",
            "altitude",
            "disperser",
            "slit-width",
            "slit-pa",
            "slit",
            "filter1",
            "filter2",
            "filter3",
            "ut-str",
            "ut-end",
        ],
        dtype=[
            "U50",
            "U50",
            "U50",
            "U50",
            "U50",
            "U50",
            "f8",
            "f8",
            "f8",
            "U50",
            "f8",
            "f8",
            "U50",
            "U50",
            "U50",
            "U50",
            "U50",
            "U50",
        ],
    )
    return stab
summary_table = make_summary_table_focas(RAWDIR)
summary_table
Table length=60
filenamedate-obsobjectradectypeexptimeairmassaltitudedisperserslit-widthslit-paslitfilter1filter2filter3ut-strut-end
str50str50str50str50str50str50float64float64float64str50float64float64str50str50str50str50str50str50
FCSA00141794.fits.gz2013-06-07DOMEFLAT11:17:48.038+19:54:08.26DOMEFLAT3.01.089.9361SCFCGRHD900.5-90.3SCFCSLLC08NONESCFCFLSO58NONE04:37:11.90604:37:15.139
FCSA00141920.fits.gz2013-06-07GRB 130606A16:37:41.157+29:47:45.71OBJECT1200.01.13661.6129SCFCGRHD900.5-90.3SCFCSLLC08NONESCFCFLSO58NONE07:59:00.67608:19:01.117
FCSA00141930.fits.gz2013-06-07GRB 130606A16:37:41.230+29:47:45.13OBJECT1200.01.03874.4191SCFCGRHD900.5-90.3SCFCSLLC08NONESCFCFLSO58NONE09:03:28.98009:23:29.405
FCSA00141848.fits.gz2013-06-07BIAS11:39:50.841+19:54:11.18BIAS0.01.089.9361SCFCGRMR010.5-90.3SCFCSLLC20NONESCFCFLSO58NONE04:59:10.57104:59:10.574
FCSA00141916.fits.gz2013-06-07GRB 130606A16:37:41.020+29:47:44.75OBJECT1200.01.25452.8249SCFCGRHD900.5-90.3SCFCSLLC08NONESCFCFLSO58NONE07:18:00.16507:38:00.608
FCSA00141734.fits.gz2013-06-06BIAS21:41:03.767+17:46:28.41BIAS0.01.00186.8997SCFCGRHD900.5-0.3SCFCSLLC20NONESCFCFLSO58NONE14:53:03.80714:53:03.810
FCSA00141566.fits.gz2013-06-06DOMEFLAT11:23:03.374+19:54:09.25DOMEFLAT1.11.089.9625SCFCGRHD900.5-90.3SCFCSLLC20NONESCFCFLSO58NONE04:46:28.97104:46:30.721
FCSA00141576.fits.gz2013-06-06DOMEFLAT11:26:18.779+19:54:09.76DOMEFLAT3.01.089.9625SCFCGRHD900.5-90.3SCFCSLLC08NONESCFCFLSO58NONE04:49:43.63904:49:46.993
FCSA00141822.fits.gz2013-06-07DOMEFLAT11:26:05.681+19:54:09.65DOMEFLAT1.11.089.9361SCFCGRHD900.5-90.3SCFCSLLC20NONESCFCFLSO58NONE04:45:27.99004:45:29.664
......................................................
FCSA00141730.fits.gz2013-06-06BIAS23:03:44.623-01:20:01.29BIAS0.01.16958.8068SCFCGRHD900.5-0.3SCFCSLLC20NONESCFCFLSO58NONE14:52:13.40614:52:13.410
FCSA00141818.fits.gz2013-06-07DOMEFLAT11:25:08.002+19:54:09.51DOMEFLAT1.11.089.9361SCFCGRHD900.5-90.3SCFCSLLC20NONESCFCFLSO58NONE04:44:30.39304:44:31.697
FCSA00142010.fits.gz2013-06-07Feige11023:20:01.964-05:10:51.68OBJECT120.01.20855.8712SCFCGRHD900.5-405.3SCFCSLLC20NONESCFCFLSO58NONE15:03:10.69415:05:11.030
FCSA00141798.fits.gz2013-06-07DOMEFLAT11:18:59.560+19:54:08.48DOMEFLAT3.01.089.9361SCFCGRHD900.5-90.3SCFCSLLC08NONESCFCFLSO58NONE04:38:23.18404:38:26.574
FCSA00141854.fits.gz2013-06-07BIAS11:41:06.075+19:54:11.27BIAS0.01.089.9361SCFCGRMR010.5-90.3SCFCSLLC20NONESCFCFLSO58NONE05:00:25.63705:00:25.674
FCSA00141586.fits.gz2013-06-06BIAS11:32:20.002+19:54:10.54BIAS0.01.089.9625SCFCGRHD900.5-90.3SCFCSLLC20NONESCFCFLSO58NONE04:55:43.88004:55:43.883
FCSA00141562.fits.gz2013-06-06DOMEFLAT11:22:06.397+19:54:09.10DOMEFLAT1.11.089.9625SCFCGRHD900.5-90.3SCFCSLLC20NONESCFCFLSO58NONE04:45:32.08004:45:33.717
FCSA00141572.fits.gz2013-06-06DOMEFLAT11:25:16.188+19:54:09.60DOMEFLAT3.01.089.9625SCFCGRHD900.5-90.3SCFCSLLC08NONESCFCFLSO58NONE04:48:41.38204:48:44.710
FCSA00141728.fits.gz2013-06-06BIAS23:19:58.381-05:11:14.89BIAS0.01.24853.1982SCFCGRHD900.5-0.3SCFCSLLC20NONESCFCFLSO58NONE14:51:48.19314:51:48.197
FCSA00141800.fits.gz2013-06-07DOMEFLAT11:19:30.656+19:54:08.58DOMEFLAT3.01.089.9361SCFCGRHD900.5-90.3SCFCSLLC08NONESCFCFLSO58NONE04:38:54.20204:38:57.612
biastab = summary_table[summary_table["type"] == "BIAS"]
flattab = summary_table[summary_table["type"] == "DOMEFLAT"]
arctab = summary_table[
    (summary_table["type"] == "COMPARISON") & (summary_table["slit"] == "SCFCSLLC08")
]
scitab = summary_table[summary_table["object"] == "GRB 130606A"]
stdtab_f110 = summary_table[
    (summary_table["object"] == "Feige110") & (summary_table["type"] == "OBJECT")
]
stdtab_f34 = summary_table[
    (summary_table["object"] == "Feige34") & (summary_table["type"] == "OBJECT")
]
# for displaying in zscale
interval = ZScaleInterval()

# plotting the images
fig, axes = plt.subplots(2, 2, figsize=(15, 4))
titles = ["Bias", "Flat", "Arc", "Sci"]
bias = fits.getdata(RAWDIR / biastab["filename"][0])
flat = fits.getdata(RAWDIR / flattab["filename"][0])
arc = fits.getdata(RAWDIR / arctab["filename"][0])
sci = fits.getdata(RAWDIR / scitab["filename"][0])
for i, img in enumerate([bias, flat, arc, sci]):
    vmin, vmax = interval.get_limits(img)
    ax = axes[i // 2][i % 2]
    ax.imshow(img.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
    ax.text(
        0.05,
        0.95,
        titles[i],
        transform=ax.transAxes,
        ha="left",
        va="top",
        bbox=dict(facecolor="white", alpha=0.8),
    )
    ax.tick_params(axis="both", length=0.0, labelleft=False, labelbottom=False)
plt.tight_layout()

del bias, flat, arc, sci
../_images/6b647f7859f0dd31069988cfc76ed8e76465206e7ca9550de42bc432039edc4e.png

Preprocessing of the Raw Images#

Preprocessing of the raw images is the first step in the data reduction process. The raw images are usually not ready for scientific analysis and need to be preprocessed before they can be used for scientific analysis. The preprocessing steps include the following:

  1. Bias subtraction

  2. Dark subtraction

  3. Flat fielding

  4. Cosmic ray removal

There are lots of software available for preprocessing the raw images. In this tutorial, we will use the ccdproc package for preprocessing the raw images. The ccdproc package is a Python package for reducing astronomical data taken with CCD cameras. It provides many of the necessary tools for processing of CCD images. For more information on the ccdproc package, please visit the documentation, and for the complete guide on the reduction process for astronomical images using Python, CCD Reduction and Photometry Guide.

In this notebook, we will preprocess the raw images using preproc.py script. This script is available in the tutorial directory. Dark subtraction will not be performed in this notebook since the amount of dark current in the images is negligible.

Making the Master Bias#

Before making the master bias, we need to check the bias images for any defects. We skip this step here, but it is important to check since the bias images are used to calibrate all the other images. If there are defects in the bias images, they will be propagated to all the other images.

The master bias is made by combining all the bias images. The master bias will be subtracted from all the images to remove the bias level. For combining the bias images, the sigma-clipping algorithm is used to reject outliers and then the average of the remaining images is calculated.

biaslist = [RAWDIR / f for f in biastab["filename"]]
_ = preproc.combine_bias(biaslist, outdir=OUTDIR, outname="MBias.fits")
del _
Hide code cell output
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
WARNING: FITSFixedWarning: PC001001= 1.00000000 / Pixel Coordinate translation matrix 
this form of the PCi_ja keyword is deprecated, use PCi_ja. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: PC001002= 0.00000000 / Pixel Coordinate translation matrix 
this form of the PCi_ja keyword is deprecated, use PCi_ja. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: PC002001= 0.00000000 / Pixel Coordinate translation matrix 
this form of the PCi_ja keyword is deprecated, use PCi_ja. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: PC002002= 1.00000000 / Pixel Coordinate translation matrix 
this form of the PCi_ja keyword is deprecated, use PCi_ja. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: RADECSYS= 'FK5 ' / The equatorial coordinate system 
the RADECSYS keyword is deprecated, use RADESYSa. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56450.000000 from DATE-OBS.
Set DATE-END to '2013-06-07T04:59:10.645' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'unitfix' made the change 'Changed units:
  'degree' -> 'deg'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'celfix' made the change 'Unmatched celestial axes'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56449.000000 from DATE-OBS.
Set DATE-END to '2013-06-06T14:53:03.880' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56449.000000 from DATE-OBS.
Set DATE-END to '2013-06-06T04:56:58.652' from MJD-END'. [astropy.wcs.wcs]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56450.000000 from DATE-OBS.
Set DATE-END to '2013-06-07T04:59:35.780' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56450.000000 from DATE-OBS.
Set DATE-END to '2013-06-07T15:06:35.545' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56450.000000 from DATE-OBS.
Set DATE-END to '2013-06-07T05:00:00.751' from MJD-END'. [astropy.wcs.wcs]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56449.000000 from DATE-OBS.
Set DATE-END to '2013-06-06T04:56:34.054' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56450.000000 from DATE-OBS.
Set DATE-END to '2013-06-07T15:07:00.686' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56450.000000 from DATE-OBS.
Set DATE-END to '2013-06-07T15:07:53.456' from MJD-END'. [astropy.wcs.wcs]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56449.000000 from DATE-OBS.
Set DATE-END to '2013-06-06T04:56:09.042' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56449.000000 from DATE-OBS.
Set DATE-END to '2013-06-06T14:53:29.035' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56450.000000 from DATE-OBS.
Set DATE-END to '2013-06-07T15:06:10.558' from MJD-END'. [astropy.wcs.wcs]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56449.000000 from DATE-OBS.
Set DATE-END to '2013-06-06T04:55:19.314' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56450.000000 from DATE-OBS.
Set DATE-END to '2013-06-07T04:58:45.726' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56449.000000 from DATE-OBS.
Set DATE-END to '2013-06-06T14:52:38.925' from MJD-END'. [astropy.wcs.wcs]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56450.000000 from DATE-OBS.
Set DATE-END to '2013-06-07T15:07:26.756' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56449.000000 from DATE-OBS.
Set DATE-END to '2013-06-06T14:52:13.481' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56450.000000 from DATE-OBS.
Set DATE-END to '2013-06-07T05:00:25.744' from MJD-END'. [astropy.wcs.wcs]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: using the unit adu passed to the FITS reader instead of the unit adu in the FITS file. [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56449.000000 from DATE-OBS.
Set DATE-END to '2013-06-06T04:55:43.954' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56449.000000 from DATE-OBS.
Set DATE-END to '2013-06-06T14:51:48.267' from MJD-END'. [astropy.wcs.wcs]
mbias = OUTDIR / "MBias.fits"
mbias_img = fits.getdata(mbias)
fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(111)
vmin, vmax = interval.get_limits(mbias_img)
im = ax.imshow(mbias_img.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Master Bias")
divider = make_axes_locatable(ax)
cax = divider.append_axes("bottom", size="5%", pad=0.5)
plt.colorbar(im, cax=cax, orientation="horizontal")
fig.show()
../_images/9f46beaf25df9ec57c69ae77eb83e18b24672b2deb632d7f1ae3f2dbdbd4020d.png

Making Master Flat#

Not like the photometric observation, spectroscopic flat image is typically taken with something like halogen lamp or tungsten lamp, which is inevitably not uniform especially for the dispersion direction. Therefore, we need to make a master flat image flat, which involves modeling the flat spectrum and dividing the flat image by the model. To obtain a good master flat, we need to:

  1. Examine the flat spectra: do they show any variablity throughout the observation? can we combine them without any further consideration? do they have any features like wiggles or bumps? do they have any discontinuity?

  2. Model the flat spectra: we can use a polynomial (or smoothing convolution) to model the flat spectrum. The degree of the polynomial (or size of the smoothing kernel) should be determined by the complexity of the flat spectrum.

  3. Normalize the flat spectra: divide the flat image by the model to obtain a normalized flat image.

Combining can be done either before or after the modeling. If the flat spectra are stable, we can combine them first and then model the combined flat spectrum. If the flat spectra are not stable, we can model each flat spectrum and then combine the normalized flat images. If the flat spectra show significant variability, we can also consider not combining them at all.

Because of this complexity, making a master flat can be a time-consuming part of the data reduction process. Moreover, the quality of the master flat directly affects the quality of the reduced science images, which may introduce systematic errors if not done properly. Therefore, the spectroscopic flat fielding is often omitted in the data reduction process, when the flat fielding conversely introduces more errors than it corrects.

Checking for Flat Image Shift#

The flat image may shift slightly depending on the telescope’s pointing direction. Since different images might require different flats, it can be risky to median combine all the flat images taken overnight. We should perform a quick and rough check to see if there is a shift in the flat images.

To avoid bias might arise from this shift, it is recommended to take flat images at the same pointing direction as the science images. However, if the shift is small, we can still use the median combined flat image for calibration.

Note

With the same reason, we should be careful when we reduce the arc frames. The location of the lines in the arc spectrum may shift slightly depending on the telescope’s pointing direction. Therefore, it is strongly recommended to take arc frames at the same pointing direction as the science frames.

We should check for the shift in the arc frames, but we will skip this process for simplicity.

However, unlike the flat fielding, the wavelength calibration is of critical importance in the spectroscopic data reduction process, since few percent of errors in the wavelength calibration can introduce systematic errors in the scientific analysis, such as the measurement of the redshift of galaxies. Therefore, we should always check for the shift in the arc frames before wavelength calibration.

In this notebook, we will skip this process for simplicity, but you should always check for flat image shift in your own data. We will assume that the flat images are already aligned, and we will proceed to combine them.

flattab_exp3 = flattab[flattab["exptime"] == 3.0]
flattab_exp1 = flattab[flattab["exptime"] == 1.1]
flatlist_exp3 = [RAWDIR / f for f in flattab_exp3["filename"]]
flatlist_exp1 = [RAWDIR / f for f in flattab_exp1["filename"]]

mbias = OUTDIR / "MBias.fits"
_ = preproc.make_master_flat(
    flatlist_exp3,
    outdir=OUTDIR,
    outname="MFlat03.0.fits",
    mbias=mbias,
    filter_key="FILTER02",
    verbose=False,
)
_ = preproc.make_master_flat(
    flatlist_exp1,
    outdir=OUTDIR,
    outname="MFlat01.1.fits",
    mbias=mbias,
    filter_key="FILTER02",
    verbose=False,
)
del _

Comparing with the Standard Star Profile#

Typical flat spectra shows many wiggles and bumps. Yet, we don’t know if these features are originated from the grating (as a representation of common instruments that can affect the both flat and science frames), which in turn will affect our science frame equally. If they are not due to the grating, such as filters in front of the flat-field lamp, the extreme difference in color temperature between the lamps and the object of interest, or wavelength-dependent variations in the reflectivity of the paint used for the flat-field screen (Massey & Hansen, 2010), they are not going to present on the science frame.

Let’s see if a supposedly smooth source (in this case, a standard star) has same “wiggles” and “bumps” on its spectrum. If this is the case, we should normalize the flat spectrum with a constant value (or a “low-order” functional model). If not, we should adopt “high-order” model or constant to normalize our flat field. For more information, please refer to Section 3.1., Massey & Hansen (2010).

fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(111)
flat03 = fits.getdata(OUTDIR / "MFlat03.0.fits")
vmin, vmax = interval.get_limits(flat03)
im = ax.imshow(flat03.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Master Flat (3.0s)")

fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(111)
flat01 = fits.getdata(OUTDIR / "MFlat01.1.fits")
vmin, vmax = interval.get_limits(flat01)
im = ax.imshow(flat03.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Master Flat (1.1s)")

fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(111)
f34 = fits.getdata(RAWDIR / stdtab_f34["filename"][0])
vmin, vmax = interval.get_limits(f34)
im = ax.imshow(f34.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Feige34 (Standard Star)")
Text(0.5, 1.0, 'Feige34 (Standard Star)')
../_images/0d9489f6864161c7cab87eec30fdce2bf45f9450a6cbd3f7856cca2fb84d7c18.png ../_images/7b79e4ede2ad792a301bfc9a930aebbb033c6472aaf8e29bc14c7203ab20c86c.png ../_images/b06bf9ae6cca6024c34aa258decf6bc9add0e98656c2e79e44019bb0803300f6.png
fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(211)
ax.plot(flat03[:, 700])
ax.text(
    0.05,
    0.95,
    "Master Flat, 3.0s, Row 700",
    transform=ax.transAxes,
    ha="left",
    va="top",
)
ax.grid()
ax = fig.add_subplot(212)
ax.plot(f34[:, 737])
ax.text(
    0.05, 0.95, "Feige34, Row 700, Raw", transform=ax.transAxes, ha="left", va="top"
)
ax.grid()
../_images/8f6ce0de6fc34a475c3ee540b4409adeea25240f63111cda4cfffd60fed6639f.png

Although we can see some tiny wiggles in the flat spectrum, this is not significant enough to adopt higher-order polynomials to model the flat spectrum, as opposed to the case if we have significant fringe patterns. If you closely examine the flat spectrum, you will see that the wiggles are not random, but rather located at the atmospheric absorption lines. This is because the flat image is taken with a lamp, and the lamp light is absorbed by the atmosphere at these absorption lines. Since the absorption lines are not random, we can safely ignore them when modeling the flat spectrum.

Here we model the flat spectrum with median filtering, which is a simple and effective way to remove noise while preserving the shape of the spectrum. We will use a median filter in the dispersion direction to model the flat spectrum, with a window size of 100 pixels. This is a good starting point, but you may need to adjust the window size depending on the complexity of the flat spectrum.

Note

Although I compared the horizontal profile of the standard star and the flat image for convenience, it might as well natural to compare the final extracted spectra of the standard star after applying both the high-order and low-order model of the flat field.

For simplicity, we will only use the combined flat image taken with the exposure time of 3 seconds. In practice, you may use all the flat images taken with different exposure times to make a master flat, or you can use the flat images taken with long and short exposure times to make a bad pixel map.

# median filtering the flat field image along the dispersion axis
flat03_filt = median_filter(flat03, size=50, axes=0)
# normalizing the flat field image
normflat = flat03 / flat03_filt
fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(211)
ax.plot(flat03[:, 700], c="k", label="Raw", lw=1)
ax.plot(flat03_filt[:, 700], c="r", label="Filtered", lw=0.8)
ax.set_xlim(500, 4100)
ax.set_ylim(0, 2.2)
ax.legend()
ax.grid()
ax = fig.add_subplot(212)
ax.plot(normflat[:, 700], c="k", label="Normalized", lw=1)
ax.set_xlim(500, 4100)
ax.set_ylim(0.95, 1.05)
ax.legend()
ax.grid()

fig = plt.figure(figsize=(12, 12))
ax = fig.add_subplot(311)
vmin, vmax = interval.get_limits(flat03)
im = ax.imshow(flat03.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Master Flat")
divider = make_axes_locatable(ax)
cax = divider.append_axes("right", size="2%", pad=0.05)
plt.colorbar(im, cax=cax)
ax = fig.add_subplot(312)
im = ax.imshow(flat03_filt.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Filtered Master Flat")
divider = make_axes_locatable(ax)
cax = divider.append_axes("right", size="2%", pad=0.05)
plt.colorbar(im, cax=cax)
ax = fig.add_subplot(313)
vmin, vmax = interval.get_limits(normflat)
im = ax.imshow(normflat.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Normalized Master Flat")
divider = make_axes_locatable(ax)
cax = divider.append_axes("right", size="2%", pad=0.05)
plt.colorbar(im, cax=cax)
fig.show()
../_images/e419a45987536ab789deff391e609e1e5aa775d8ba57ca55f0b1853ac680bbed.png ../_images/9cc5a21c501eb8e2eecbb89af4fa76b3ec881d8e3ad7ad132115638d1802f96e.png
# writing the normalized flat field image to a file
hdr = fits.getheader(OUTDIR / "MFlat03.0.fits")
hdu = fits.PrimaryHDU(normflat, header=hdr)
hdu.writeto(OUTDIR / "nMFlat03.0.fits", overwrite=True)

Preprocessing the Raw Images#

Now we will preprocess the raw images. We will subtract the master bias and divide the master flat from the raw science frames. These preprocessed science frames will not be combined, since the spatial location of the object is different in each frame. We will combine the light from the object after extracting the 1D spectrum.

mbias = OUTDIR / "MBias.fits"
mflat = OUTDIR / "nMFlat03.0.fits"
# preprocessing the arc image
arc_list = [RAWDIR / f for f in arctab["filename"]]
print("Preprocessing arc images...")
arc = preproc.preproc(arc_list, mbias=mbias, mflat=mflat, outdir=OUTDIR)

# combine the preprocessed arc image
print("Combining the preprocessed arc images...")
parcs = [CCDData.read(OUTDIR / ("p" + f.stem)) for f in arc_list]
carc = combine(
    parcs,
    method="average",
    sigma_clip=True,
    sigma_clip_low_thresh=3,
    sigma_clip_high_thresh=3,
)

carc.write(OUTDIR / "cArc.fits", overwrite=True)
print("The combined arc image is saved as cArc.fits")

# preprocessing the science image
print("Preprocessing science images...")
sci_list = [RAWDIR / f for f in scitab["filename"]]
_ = preproc.preproc(sci_list, mbias=mbias, mflat=mflat, outdir=OUTDIR, insert_ivar=True)

# preprocessing the standard star images
print("Preprocessing standard star images...")
print("Standard star: Feige110")
std_list_f110 = [RAWDIR / f for f in stdtab_f110["filename"]]
_ = preproc.preproc(
    std_list_f110, mbias=mbias, mflat=mflat, outdir=OUTDIR, insert_ivar=True
)
print("Standard star: Feige34")
std_list_f34 = [RAWDIR / f for f in stdtab_f34["filename"]]
_ = preproc.preproc(
    std_list_f34, mbias=mbias, mflat=mflat, outdir=OUTDIR, insert_ivar=True
)
Hide code cell output
Preprocessing arc images...
Done: pFCSA00141840.fits
/Users/hbahk/class/TAOtutorials/tutorials/preproc.py:392: RuntimeWarning:

invalid value encountered in divide
Done: pFCSA00141882.fits
Done: pFCSA00141580.fits
Combining the preprocessed arc images...
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
WARNING: FITSFixedWarning: PC001001= 1.00000000 / Pixel Coordinate translation matrix 
this form of the PCi_ja keyword is deprecated, use PCi_ja. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: PC001002= 0.00000000 / Pixel Coordinate translation matrix 
this form of the PCi_ja keyword is deprecated, use PCi_ja. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: PC002001= 0.00000000 / Pixel Coordinate translation matrix 
this form of the PCi_ja keyword is deprecated, use PCi_ja. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: PC002002= 1.00000000 / Pixel Coordinate translation matrix 
this form of the PCi_ja keyword is deprecated, use PCi_ja. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: RADECSYS= 'FK5 ' / The equatorial coordinate system 
the RADECSYS keyword is deprecated, use RADESYSa. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56450.000000 from DATE-OBS.
Set DATE-END to '2013-06-07T04:55:27.734' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'unitfix' made the change 'Changed units:
  'degree' -> 'deg'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'celfix' made the change 'Unmatched celestial axes'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56450.000000 from DATE-OBS.
Set DATE-END to '2013-06-07T05:51:12.249' from MJD-END'. [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 56449.000000 from DATE-OBS.
Set DATE-END to '2013-06-06T04:52:48.140' from MJD-END'. [astropy.wcs.wcs]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
INFO: An exception happened while extracting WCS information from the Header.
InconsistentAxisTypesError: ERROR 4 in wcs_types() at line 3140 of file cextern/wcslib/C/wcs.c:
Unmatched celestial axes.
 [astropy.nddata.ccddata]
The combined arc image is saved as cArc.fits
Preprocessing science images...
Done: pFCSA00141920.fits
/Users/hbahk/class/TAOtutorials/tutorials/preproc.py:392: RuntimeWarning:

invalid value encountered in divide
Done: pFCSA00141930.fits
Done: pFCSA00141916.fits
Done: pFCSA00141928.fits
Done: pFCSA00141938.fits
Done: pFCSA00141940.fits
Done: pFCSA00141918.fits
Done: pFCSA00141936.fits
Done: pFCSA00141926.fits
Preprocessing standard star images...
Standard star: Feige110
Done: pFCSA00142004.fits
Done: pFCSA00142006.fits
Done: pFCSA00142010.fits
Standard star: Feige34
Done: pFCSA00141880.fits
Done: pFCSA00141894.fits
Done: pFCSA00141878.fits
fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(111)
vmin, vmax = interval.get_limits(carc.data)
im = ax.imshow(carc.data.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Combined Image")

fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(111)
sci = fits.getdata(OUTDIR / f"p{sci_list[0].stem}")
vmin, vmax = interval.get_limits(sci)
im = ax.imshow(sci.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Preprocessed Science Image")

fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(111)
std34 = fits.getdata(OUTDIR / f"p{std_list_f34[0].stem}")
vmin, vmax = interval.get_limits(std34)
im = ax.imshow(std34.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Preprocessed Feige34 Image")
fig.show()
../_images/54102e9aec5b969dbd9ac26e49ba748d5ef12ef5ec236657921e1f0a509ea43e.png ../_images/25c812e0f10109564e95dfca392cc078ed1ed29ab3bd3135ba3d2a6342f0a4f0.png ../_images/86e73cf7ee46ce3fcf2e0a74c381f5bafb7106975d2297d147c5a47fbc01da7c.png

Wavelength Calibration#

Wavelength calibration is the process of converting the pixel coordinates of the spectrum into the corresponding wavelengths. This is typically done by using the arc lamp images, which contain known spectral lines at known wavelengths. The arc lamp images are taken with the same setup as the science images, and the spectral lines in the arc lamp images are used to calibrate the wavelength scale of the science images. On the other hand, in some cases, the sky lines can be used for the wavelength calibration, especially when the arc lamp lines are not available or not reliable. We call these images that contain information for the wavelength calibration “comparison images”.

The wavelength calibration is done by fitting a polynomial to the pixel-wavelength relation, using the 1D spectrum extracted from the comparison images, at initial spatial strip. This process is called “identify” in IRAF, since it is done by identifying the spectral lines in the comparison images and fitting a polynomial to the pixel-wavelength relation.

After the “identify” process, the process called “reidentify” should be performed to construct 2D pixel-wavelength relation. This is done by fitting a polynomial to the pixel-wavelength relation, using the 1D spectrum extracted from the comparison images, at other spatial strips. This process is called “reidentify” in IRAF, since it is done by reidentifying the spectral lines at different positions and fitting a polynomial to the pixel-wavelength relations again.

Region of Interest (RoI)#

Region of Interest (RoI) is the region where all the required data for the extraction of science spectrum is located. When we see the above image, a lot portion of the image is not useful for the extraction of the spectrum. Therefore, we need to define the RoI to reduce the computational cost. Let’s define the RoI for all extraction processes, by inspecting the 2D spectrum of the science frames.

# Trim the images
TRIM = [0, 4220, 670, 810]  # y_lower, y_upper, x_lower, x_upper
fname = OUTDIR / f"p{sci_list[0].stem}"
stdhdu = fits.open(fname)
stdhdr = stdhdu[0].header
stdtrim = stdhdu[0].data[TRIM[0] : TRIM[1], TRIM[2] : TRIM[3]]
exptime = stdhdr["EXPTIME"]
rdnoise = stdhdr["RDNOISE"]
pressure = stdhdr["DOM-PRS"]
temperature = stdhdr["DOM-TMP"]
humidity = stdhdr["DOM-HUM"]

fig = plt.figure(figsize=(12, 2))
ax = fig.add_subplot(111)
vmin, vmax = interval.get_limits(stdtrim)
im = ax.imshow(stdtrim.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Preprocessed Science Image (Trimmed)")
fig.show()
../_images/42b72363ab7815603933f32b88d09be1e559d5b077495b96b24170d9836460ae.png

The TRIM parameter is used to trim the images to the RoI. The elements of the TRIM parameter is determined by examining the 2D science frame, to make the source centered in the image. From now on, we will use the trimmed images for the data reduction process, with the same RoI for all the images.

Identify#

Now we can proceed to the wavelength calibration process with following steps:

  1. Extract the 1D spectrum of the comparison image: We will extract the 1D spectrum of the comparison image, simply taking a strip from a row (or summation of a few rows) of the comparison image. We will extract the 1D spectrum at the initial spatial strip, and use it to identify the spectral lines.

  2. Identify the spectral lines: We will find the line peaks in the 1D spectrum and matching them with the known wavelengths of the spectral lines.

  3. Fit a polynomial to the pixel-wavelength relation: We will fit a polynomial to the pixel-wavelength relation, using the pixel coordinates of the line peaks and the known wavelengths of the spectral lines. The degree of the polynomial should be determined by the complexity of the solution.

In this notebook, we will use the arc framesfor the comparison images. However, The FOCAS reduction help page recommends using the sky lines for the wavelength calibration for the VPH900 grism. The sky lines are the emission lines from the Earth’s atmosphere, and they can be used for the wavelength calibration. Especially, our arc lamp images were taken at a few hours earlier than the science frames, and different pointing directions might introduce some deviations for wavelength solutions. Therefore, it is recommended to use the sky lines for the wavelength calibration in this case. In practice, one can use both the sky lines and the arc lamp lines for the wavelength calibration, and compare the results. In this notebook, we will use the arc lamp lines for the wavelength calibration, but you can try using the sky lines for the wavelength calibration as an exercise.

How should we identify the arc lines and assign the wavelengths to them? This task needs manual identification and allocation for the best results. However, it might be very time-consuming, especially when the number of lines is large. There were lots of effort to automate this process, and we can use various softwares such as the autoidentify task in IRAF. In this notebook, we will use the RASCAL package for Python, which is a Python package for automatic spectral line identification and wavelength calibration adopted for the ASPIRED pipeline. For more information on how the RASCAL package works, please refer to the RASCAL documentation.

Before we proceed, install the RASCAL package by running the following command:

pip install rascal

Let’s start from the step 1, extracting the 1D spectrum of the comparison image. Before the extraction, the cosmic ray removal will mitigate some confusion in the identification process.

# Trim the images
fname = OUTDIR / f"cArc.fits"
# fname = OUTDIR / f"p{arc_list[0].stem}"
stdhdu = fits.open(fname)
stdhdr = stdhdu[0].header
stdtrim = stdhdu[0].data[TRIM[0] : TRIM[1], TRIM[2] : TRIM[3]]
exptime = stdhdr["EXPTIME"]
rdnoise = stdhdr["RDNOISE"]
# In case you have vacuum references, you need the followings for vacuum-air conversion
# But we don't have vacuum references in this data, so we don't need them.
pressure = stdhdr["DOM-PRS"]
temperature = stdhdr["DOM-TMP"]
humidity = stdhdr["DOM-HUM"]

fig = plt.figure(figsize=(12, 2))
ax = fig.add_subplot(111)
vmin, vmax = interval.get_limits(stdtrim)
im = ax.imshow(stdtrim.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Preprocessed Arc Image (Trimmed)")
fig.show()
../_images/913f47dc059afdf798f45c2d8a2cd453d261c3c3b3f39fb32793ab38f332b741.png
# Cosmic ray removal
stdccd = CCDData(data=stdtrim, header=stdhdr, unit="adu")
gcorr = gain_correct(stdccd, gain=stdhdr["GAIN"] * u.electron / u.adu)
crrej = cosmicray_lacosmic(
    gcorr, sigclip=7, readnoise=stdhdr["RDNOISE"] * u.electron, verbose=True
)
Starting 4 L.A.Cosmic iterations
Iteration 1:
12682 cosmic pixels this iteration
Iteration 2:
11006 cosmic pixels this iteration
Iteration 3:
9926 cosmic pixels this iteration
Iteration 4:
9492 cosmic pixels this iteration
fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(111)
vmin, vmax = interval.get_limits(crrej.data)
im = ax.imshow(crrej.data.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Cosmic Ray Removed Arc Image")
fig.show()
../_images/7f54a1a6237b13eb2caafc92a15ea8f33c72176b7458bb863d7f1ff2031d112a.png

Now we select a few rows from the image to extract the comparison spectrum. I realized that RASCAL does not work well with the inverse order of the spectrum, i.e., the blue side is on the right side. Therefore, we need to flip the spectrum before the identification process.

rowstart = 50  # starting row index for the spectrum
nrows = 3  # number of rows to sum
rowend = rowstart + nrows  # ending row index for the spectrum
rowmid = (rowstart + rowend) / 2  # middle row position for the spectrum
# summing the rows and inverting the spectrum
compspec = crrej.data[:, rowstart:rowend].sum(axis=1)[::-1]

# identify the peaks in the spectrum
peaks = peak_local_max(compspec, min_distance=10, threshold_rel=0.1)

fig = plt.figure(figsize=(12, 3))
ax = fig.add_subplot(111)
ax.plot(compspec, lw=0.8, c="k")
ax.plot(peaks, compspec[peaks], "r.", label="Peaks")
ax.set_title("Row Spectrum")
ax.legend()
fig.show()
../_images/9d834273dde78397c758e1c4191383fcda0ce74a8c7d854d79205b963a70b258.png

We can get the reference wavelength of the arc lines from thar.v900.dat file in the FOCASRED IRAF package (see here).

arclines = Table.read(
    WD / "thar.v900.dat",
    format="ascii.csv",
    names=["wavelength"],
    data_start=1,
)["wavelength"].value

elements = ["ThAr"] * len(arclines)

arclines
array([9784.501 , 9657.7863, 9399.0891, 9354.2198, 9224.4992, 9122.9674,
       8667.9442, 8521.4422, 8424.6475, 8408.2096, 8264.5225, 8115.311 ,
       8103.6931, 8014.7857, 8006.1567, 7948.1764, 7635.106 , 7514.6518,
       7503.8691, 7383.9805, 7272.9359, 7067.2181, 6965.4307, 6911.2264])
# redefine the peaks using the Gaussian fit
peaks_refined = util.refine_peaks(compspec, peaks[:, 0], window_width=5)

# create the RASCAL calibrator object
cal = Calibrator(peaks_refined, compspec)
# set the properties of the calibrator
cal.set_calibrator_properties(
    num_pix=len(compspec), plotting_library="matplotlib", log_level="info"
)
# set the wavelength calibration properties
cal.set_hough_properties(
    num_slopes=5000,
    range_tolerance=500.0,
    xbins=200,
    ybins=200,
    min_wavelength=7500.0,
    max_wavelength=10500.0,
    linearity_tolerance=50.0,
)
# set the model properties
cal.set_ransac_properties(sample_size=5, top_n_candidate=5)
[Wed, 29 May 2024 08:40:21] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:21] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:21] INFO [calibrator.py:971] Plotting with matplotlib.
cal.plot_arc()
plt.show()
../_images/2c9a4ccb1e40319695cc4ad5b238ff6369bcd3ae350fe23f1872d887eb759f74.png
# create an atlas object with the sky lines provided
atlas = Atlas(
    min_atlas_wavelength=8000.0,
    max_atlas_wavelength=10500.0,
    min_intensity=0.0,
)
atlas.add_user_atlas(
    elements=elements,
    wavelengths=arclines,
    vacuum=False,
    pressure=pressure,
    temperature=temperature,
    relative_humidity=humidity,
)

# set the atlas to the calibrator
cal.set_atlas(atlas)

# calibrate the spectrum
cal.do_hough_transform()

# perform chebyshev fit on samples drawn from RANSAC
fit_result = cal.fit(max_tries=100, fit_deg=3)
(
    fit_coeff,
    matched_peaks,
    matched_atlas,
    rms,
    residual,
    peak_utilisation,
    atlas_utilisation,
) = fit_result

rms_reference = rms
[Wed, 29 May 2024 08:40:23] INFO [calibrator.py:750] Found: 17
xidx = np.arange(len(compspec))
wavelength = cal.polyval(xidx, fit_coeff)

fig, axes = plt.subplots(
    3, 1, figsize=(12, 7), gridspec_kw={"height_ratios": [2, 1, 2]}, sharex=True
)
ax1, ax2, ax3 = axes

ax1.plot(wavelength, compspec, lw=0.8, c="k", zorder=3)
labelp, labela = True, True
for peak in peaks_refined:
    if labelp:
        ax1.axvline(
            cal.polyval(peak, fit_coeff), c="tab:red", lw=1, label="Identified Peaks"
        )
        labelp = False
    else:
        ax1.axvline(cal.polyval(peak, fit_coeff), c="tab:red", lw=1)
for atla in arclines:
    if labela:
        ax1.axvline(atla, c="tab:blue", lw=1, label="References", ls="--")
        labela = False
    else:
        ax1.axvline(atla, c="tab:blue", lw=1, ls="--")
ax1.set_ylabel("Counts")
ax1.legend()

wave_matched = cal.polyval(matched_peaks, fit_coeff)
ax2.plot(wave_matched, residual, ls="none", c="k", marker="+")
ax2.axhline(0, c="k", lw=0.5, ls="--")
ax2.text(
    0.99, 0.05, f"RMS: {rms:.2f}", transform=ax2.transAxes, ha="right", va="bottom"
)
ax2.set_ylabel("Residuals [$\AA$]")
ax3.plot(wavelength, xidx, c="tab:green", lw=0.8, label="Solution")
ax3.plot(matched_atlas, matched_peaks, "r+", label="Matched Peaks")
ax3.set_xlabel("Wavelength [$\AA$]")
ax3.set_ylabel("Pixel")
ax3.legend()

fig.subplots_adjust(hspace=0)
../_images/ef7256f72ff2f10a027a9942ee980531ec03683fb319bd01d5544de35280ab81.png

We now have a wavelength solution for the strip of the comparison image, with RMS of 0.17 Angstroms. We can now proceed to the next step, reidentifying to get the 2D pixel-wavelength relation.

Reidentify#

The reidentification process is similar to the identification process, but we will use the 1D spectrum extracted from the comparison image at different spatial strips. We will fit a polynomial to the pixel-wavelength relation, using the pixel coordinates of the line peaks and the known wavelengths of the spectral lines. The degree of the polynomial should be determined by the complexity of the solution.

nrows = 3  # number of rows to sum
nintv = 5  # number of intervals between the starting rows
rowstart_array = np.arange(0, crrej.data.shape[1] - nrows, nintv)
rowmid_array = (rowstart_array + rowstart_array + nrows) / 2

list_fit_coeff = []
rms_list = []
list_matched_position = []
for i in range(len(rowstart_array)):
    rowstart = rowstart_array[i]  # starting row index for the spectrum
    rowend = rowstart + nrows  # ending row index for the spectrum
    # summing the rows and inverting the spectrum
    compspec = crrej.data[:, rowstart:rowend].sum(axis=1)[::-1]

    # identify the peaks in the spectrum
    peaks = peak_local_max(compspec, min_distance=10, threshold_rel=0.1)

    # redefine the peaks using the Gaussian fit
    peaks_refined = util.refine_peaks(compspec, peaks[:, 0], window_width=5)

    # create the RASCAL calibrator object
    cal = Calibrator(peaks_refined, compspec)
    # set the properties of the calibrator
    cal.set_calibrator_properties(
        num_pix=len(compspec), plotting_library="matplotlib", log_level="info"
    )
    # set the wavelength calibration properties
    cal.set_hough_properties(
        num_slopes=5000,
        range_tolerance=500.0,
        xbins=200,
        ybins=200,
        min_wavelength=7500.0,
        max_wavelength=10500.0,
        linearity_tolerance=50.0,
    )
    # set the model properties
    cal.set_ransac_properties(sample_size=5, top_n_candidate=5)

    atlas = Atlas(
        min_atlas_wavelength=8000.0,
        max_atlas_wavelength=10500.0,
        min_intensity=0.0,
    )
    atlas.add_user_atlas(
        elements=elements,
        wavelengths=arclines,
        vacuum=False,
        pressure=pressure,
        temperature=temperature,
        relative_humidity=humidity,
    )

    # set the atlas to the calibrator
    cal.set_atlas(atlas)

    # calibrate the spectrum
    cal.do_hough_transform()

    # perform chebyshev fit on samples drawn from RANSAC
    fit_result = cal.fit(max_tries=100, fit_deg=3)
    (
        fit_coeff,
        matched_peaks,
        matched_atlas,
        rms,
        residual,
        peak_utilisation,
        atlas_utilisation,
    ) = fit_result

    save = (rms < 2 * rms_reference) | (len(matched_peaks) > 10)

    if save:
        # append the fit coefficients and rms to the lists
        midrow = np.full_like(matched_peaks, rowmid_array[i])
        list_fit_coeff.append(fit_coeff)
        list_matched_position.append(np.vstack([midrow, matched_peaks, matched_atlas]))
        rms_list.append(rms)
Hide code cell output
[Wed, 29 May 2024 08:40:23] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:23] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:23] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:24] INFO [calibrator.py:750] Found: 14
[Wed, 29 May 2024 08:40:24] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:24] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:24] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:25] INFO [calibrator.py:750] Found: 16
[Wed, 29 May 2024 08:40:25] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:25] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:25] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:26] INFO [calibrator.py:750] Found: 14
[Wed, 29 May 2024 08:40:26] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:26] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:26] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:27] INFO [calibrator.py:750] Found: 16
[Wed, 29 May 2024 08:40:27] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:27] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:27] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:28] INFO [calibrator.py:750] Found: 15
[Wed, 29 May 2024 08:40:28] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:28] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:28] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:29] INFO [calibrator.py:750] Found: 16
[Wed, 29 May 2024 08:40:29] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:29] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:29] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:30] INFO [calibrator.py:750] Found: 15
[Wed, 29 May 2024 08:40:30] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:30] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:30] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:31] INFO [calibrator.py:750] Found: 15
[Wed, 29 May 2024 08:40:31] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:31] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:31] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:32] INFO [calibrator.py:750] Found: 15
[Wed, 29 May 2024 08:40:32] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:32] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:32] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:32] INFO [calibrator.py:750] Found: 16
[Wed, 29 May 2024 08:40:32] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:32] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:32] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:33] INFO [calibrator.py:750] Found: 17
[Wed, 29 May 2024 08:40:33] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:33] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:33] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:34] INFO [calibrator.py:750] Found: 15
[Wed, 29 May 2024 08:40:34] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:34] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:34] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:35] INFO [calibrator.py:750] Found: 16
[Wed, 29 May 2024 08:40:35] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:35] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:35] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:36] INFO [calibrator.py:750] Found: 16
[Wed, 29 May 2024 08:40:36] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:36] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:36] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:37] INFO [calibrator.py:750] Found: 16
[Wed, 29 May 2024 08:40:37] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:37] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:37] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:38] INFO [calibrator.py:750] Found: 16
[Wed, 29 May 2024 08:40:38] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:38] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:38] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:39] INFO [calibrator.py:750] Found: 16
[Wed, 29 May 2024 08:40:39] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:39] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:39] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:40] INFO [calibrator.py:750] Found: 16
[Wed, 29 May 2024 08:40:40] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:40] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:40] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:41] INFO [calibrator.py:750] Found: 16
[Wed, 29 May 2024 08:40:41] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:41] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:41] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:41] INFO [calibrator.py:750] Found: 16
[Wed, 29 May 2024 08:40:41] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:41] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:41] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:42] INFO [calibrator.py:750] Found: 15
[Wed, 29 May 2024 08:40:42] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:42] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:42] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:43] INFO [calibrator.py:750] Found: 15
[Wed, 29 May 2024 08:40:43] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:43] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:43] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:44] INFO [calibrator.py:750] Found: 15
[Wed, 29 May 2024 08:40:44] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:44] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:44] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:45] INFO [calibrator.py:750] Found: 6
[Wed, 29 May 2024 08:40:45] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:45] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:45] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:46] INFO [calibrator.py:750] Found: 6
[Wed, 29 May 2024 08:40:46] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:46] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:46] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:47] INFO [calibrator.py:750] Found: 9
[Wed, 29 May 2024 08:40:47] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:47] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:47] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:48] INFO [calibrator.py:750] Found: 15
[Wed, 29 May 2024 08:40:48] INFO [calibrator.py:930] num_pix is set to 4220.
[Wed, 29 May 2024 08:40:48] INFO [calibrator.py:942] pixel_list is set to None.
[Wed, 29 May 2024 08:40:48] INFO [calibrator.py:971] Plotting with matplotlib.
[Wed, 29 May 2024 08:40:49] INFO [calibrator.py:750] Found: 15
matched_positions = np.hstack(list_matched_position)

# fit a 2D Chebyshev polynomial to the matched positions
order_wavelength_reid = 3
order_spatial_reid = 3
coeff_init = Chebyshev2D(x_degree=order_wavelength_reid, y_degree=order_spatial_reid)
fitter = LevMarLSQFitter()
fit2d_reid = fitter(
    coeff_init, matched_positions[0], matched_positions[1], matched_positions[2]
)

coeff_init_inv = Chebyshev2D(
    x_degree=order_spatial_reid, y_degree=order_wavelength_reid
)
fit2d_reid_inv = fitter(
    coeff_init_inv, matched_positions[0], matched_positions[2], matched_positions[1]
)
# plot the 2D wavelength solution map and points used for the fit

dd, ss = np.meshgrid(np.arange(crrej.data.shape[0]), np.arange(crrej.data.shape[1]))
rms_reid = np.sqrt(
    np.mean(
        (fit2d_reid(matched_positions[0], matched_positions[1]) - matched_positions[2])
        ** 2
    )
)
wavesol_reid = fit2d_reid(ss, dd)

fig = plt.figure(figsize=(14, 5))
ax = fig.add_subplot(211)
ax.imshow(crrej.data.T[:, ::-1], origin="lower", cmap="gray", vmin=vmin, vmax=vmax)
ax.plot(
    matched_positions[1],
    matched_positions[0],
    c="tab:red",
    label="Matched Peaks",
    marker="+",
    ms=2,
    ls="none",
)
ax = fig.add_subplot(212)
im = ax.imshow(
    wavesol_reid,
    origin="lower",
    cmap="viridis",
)
ax.plot(
    matched_positions[1],
    matched_positions[0],
    c="tab:red",
    label="Matched Peaks",
    marker="+",
    ms=2,
    ls="none",
)
# ax.set_aspect("auto")

ax.contour(
    wavesol_reid,
    colors="w",
    linewidths=0.3,
    levels=np.arange(7500, 10500, 50),
    ls=":",
    zorder=1,
)

divider = make_axes_locatable(ax)
cax = divider.append_axes("bottom", size="15%", pad=0.5)
fig.colorbar(im, cax=cax, orientation="horizontal")

cax.set_xlabel("Wavelength [$\AA$]")
ax.set_xlabel("Dispersion Direction [pix]")
ax.set_ylabel("Spatial\n Direction [pix]")
ax.set_title(
    f"2D Wavelength Solution Map\n"
    + "Chebyshev, Order (wavelength, dispersion) = "
    + f"({order_wavelength_reid}, {order_spatial_reid})\n"
    + f"2D fit RMS = {rms_reid:.2f} $\AA$"
)
Text(0.5, 1.0, '2D Wavelength Solution Map\nChebyshev, Order (wavelength, dispersion) = (3, 3)\n2D fit RMS = 0.17 $\\AA$')
../_images/2a2105b450e7ae5282791aed35b146bfc4b359134c2be7857ff564ad8135dda1.png
# check the wavelength interval between the pixels
mean_intv = (
    np.mean(np.max(wavesol_reid, axis=1) - np.min(wavesol_reid, axis=1))
    / crrej.data.shape[0]
)
mean_intv
# make a wavelength grid
wavegrid = np.arange(7500, 10200, mean_intv)

# plot the 2D dispersion solution map and points used for the fit

ww, ss = np.meshgrid(wavegrid, np.arange(crrej.data.shape[1]))
rms_reid_inv = np.sqrt(
    np.mean(
        (
            fit2d_reid_inv(matched_positions[0], matched_positions[2])
            - matched_positions[1]
        )
        ** 2
    )
)
disp_reid = fit2d_reid_inv(ss, ww)

fig = plt.figure(figsize=(14, 3))
ax = fig.add_subplot(111)
im = ax.imshow(
    disp_reid,
    origin="lower",
    cmap="viridis",
    extent=[wavegrid[0], wavegrid[-1], 0, disp_reid.shape[0]],
)
ax.plot(
    matched_positions[2],
    matched_positions[0],
    c="tab:red",
    label="Matched Peaks",
    marker="+",
    ms=2,
    ls="none",
)

divider = make_axes_locatable(ax)
cax = divider.append_axes("bottom", size="15%", pad=0.6)
fig.colorbar(im, cax=cax, orientation="horizontal")

cax.set_xlabel("Dispersion Direction [pix]")
ax.set_xlabel("Wavelength [$\AA$]")
ax.set_ylabel("Spatial\n Direction [pix]")
ax.set_title(
    f"2D Pixel Solution Map\n"
    + "Chebyshev, Order (wavelength, dispersion) = "
    + f"({order_wavelength_reid}, {order_spatial_reid})\n"
    + f"2D fit RMS = {rms_reid_inv*mean_intv:.2f} $\AA$"
)
plt.show()
../_images/8716506ab3697eb905ffee7d80c9f2c67ef1b4f3c30c5cf498cb1bd4d5cdb7dd.png

Rectify the 2D Spectrum#

After the reidentification process, we will rectify the 2D spectrum by applying the wavelength solution to the 2D spectrum. This will convert the pixel coordinates of the spectrum into the corresponding wavelengths. The rectified 2D spectrum will be used for the extraction of the 1D spectrum.

For the rectification process, we will use the map_coordinates function from the scipy package. The map_coordinates function interpolates the pixel values of the input image at the given coordinates.

xycoords = np.vstack([disp_reid.ravel(), ss.ravel()])
rectified = map_coordinates(
    crrej.data[::-1, :], xycoords, mode="constant", cval=0
).reshape(ss.shape)
disp_reid[0]
array([  59.44351046,   60.49814155,   61.55272016, ..., 3718.73271463,
       3719.72205358, 3720.7114093 ])
fig, axes = plt.subplots(2, 1, figsize=(12, 3))
ax = axes[0]
ax.imshow(
    crrej.data[::-1, :].T[:, 59:3721], origin="lower", cmap="gray", vmin=vmin, vmax=vmax
)
ax.set_title("Before Rectification")
ax = axes[1]
ax.imshow(
    rectified,
    origin="lower",
    cmap="gray",
    vmin=vmin,
    vmax=vmax,
    extent=[wavegrid[0], wavegrid[-1], 0, rectified.shape[0]],
)
ax.set_title("After Rectification")
ax.set_xlabel("Wavelength [$\AA$]")
Text(0.5, 0, 'Wavelength [$\\AA$]')
../_images/720e9e6da34baee16e565d2279c825a5f4fc18ab42bdc7e85241d548098d63ab.png

Extracting 1D Spectra#

The 1D spectra are extracted from the 2D science frames by summing the flux along the spatial direction. The extraction process consists of the following steps:

  1. Source Identification: Identify the source in the 2D science frame.

  2. Aperture Tracing: Trace the spatial position of the source along the dispersion direction.

  3. Aperture Summation: Sum the flux along the trace to extract the 1D spectrum.

Let’s extract the 1D spectra of the standard star first, because it is brighter and easier to extract.

1. Source Identification#

Let’s trim the images to the region of interest. Then, after the cosmic ray removal, we will identify the source in the 2D science frame. We will use the peak_local_max function from skimage.feature module to identify the source in a slice of the 2D science frame. The peak_local_max function finds the local maxima, which can be used to identify the source in the 2D science frame.

# Trim the images
TRIM = [0, 4220, 670, 810]  # y_lower, y_upper, x_lower, x_upper
fname = OUTDIR / f"p{std_list_f34[0].stem}"
stdhdu = fits.open(fname)
stdhdr = stdhdu[0].header
stdtrim = stdhdu[0].data[TRIM[0] : TRIM[1], TRIM[2] : TRIM[3]]
exptime = stdhdr["EXPTIME"]
rdnoise = stdhdr["RDNOISE"]

fig = plt.figure(figsize=(12, 2))
ax = fig.add_subplot(111)
vmin, vmax = interval.get_limits(stdtrim)
im = ax.imshow(stdtrim.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Preprocessed Feige34 Image (Trimmed)")
fig.show()
../_images/9c46432867448eff1dccd9c77dbad1a375a622fa9e491834c4db2df9e28c5130.png

Cosmic ray removal often takes a long time. Hence, we will apply cosmic ray removal on the trimmed images to save unnecessary computation. To get reliable removal of cosmic rays, we need to mask bad pixels, but here we will skip this step for simplicity. For more information on cosmic ray removal, please refer to the CCD Data Reduction Guide.

# Cosmic ray removal
stdccd = CCDData(data=stdtrim, header=stdhdr, unit="adu")
gcorr = gain_correct(stdccd, gain=stdhdr["GAIN"] * u.electron / u.adu)
crrej = cosmicray_lacosmic(
    gcorr, sigclip=7, readnoise=stdhdr["RDNOISE"] * u.electron, verbose=True
)
Starting 4 L.A.Cosmic iterations
Iteration 1:
325 cosmic pixels this iteration
Iteration 2:
34 cosmic pixels this iteration
Iteration 3:
21 cosmic pixels this iteration
Iteration 4:
19 cosmic pixels this iteration
Hide code cell source
fig = plt.figure(figsize=(12, 6))
ax = fig.add_subplot(111)
vmin, vmax = interval.get_limits(crrej.data)
im = ax.imshow(crrej.data.T, cmap="gray", origin="lower", vmin=vmin, vmax=vmax)
ax.set_title("Cosmic Ray Removed Feige34 Image")
fig.show()
../_images/1f0e6525c1813464a010771499892699c586b5eb52c8582669adb05f5df1ac78.png
# Flip and Rectify the Feige34 image
rectified = map_coordinates(
    crrej.data[::-1, :], xycoords, mode="constant", cval=0
).reshape(ss.shape)
fig = plt.figure(figsize=(12, 3))
ax = fig.add_subplot(111)
ax.imshow(rectified, origin="lower", cmap="gray", vmin=vmin, vmax=vmax)
ax.set_title("Flipped and Rectified Feige34 Image")
fig.show()
../_images/b2dbba68251a9c21e9ddcecbd7d7cce94430f224a5fce5948e1e30d068e23ac8.png

Looks good. Now we will identify the source position.

# Slice the image in the spatial direction
lcut = 1000  # left cut (dispersion direction)
rcut = 1050  # right cut (dispersion direction)

apall_probe = np.sum(rectified[:, lcut:rcut], axis=1)
x_probe = np.arange(apall_probe.size)
peak_pix = peak_local_max(
    apall_probe, num_peaks=1, min_distance=50, threshold_abs=np.median(apall_probe)
)[0][0]
print("Peak pixel:", peak_pix)
Peak pixel: 68
Hide code cell source
fig = plt.figure(figsize=(6, 4))
ax = fig.add_subplot(111)
ax.step(x_probe, apall_probe, where="mid")
ax.plot(
    (peak_pix, peak_pix),
    (
        apall_probe[peak_pix] + 0.01 * apall_probe[peak_pix],
        apall_probe[peak_pix] + 0.05 * apall_probe[peak_pix],
    ),
    c="tab:red",
)
ax.annotate(
    peak_pix,
    (peak_pix, apall_probe[peak_pix] + 0.05 * apall_probe[peak_pix]),
    textcoords="offset points",
    xytext=(0, 10),
    ha="left",
    va="top",
    color="k",
    fontsize="small",
    rotation=45,
)
ax.set_title("Aperture Sum")
ax.set_xlabel("Pixel Number (Spatial Direction)")
ax.set_ylabel("Counts [e-]")
fig.show()
../_images/b0a8e5522ee330f5bb58d2727046821ce23c760b1a5c60d5b1858d6eb4f73b05.png

Here we crop the image in the dispersion direction, from lcut to rcut. We sum the counts in the dispersion direction to get the 1D spatial profile of the source. We then use the peak_local_max function to identify the source position in the spatial profile. The peak_local_max function finds the local maxima in the 1D profile, which can be used to identify the source position. Then we can also estimate the full width at half maximum (FWHM) of the source, using the peak_widths function from the scipy.signal module.

From the above figure, we were able to get the pixel position of the peak of the source. However, the peak position is not always the center of the source, since it is an integer value. Therefore, we need some centering algorithm to get the center of the source to properly trace the spatial position of the source along the dispersion axis.

Here we use the centroid_com function from the photutils package to get the center of the source. The centroid_com function calculates the center of mass of the source, which is a good approximation of the center of the source. This function originally calculate the centroid of the source in the 2D image, but we will use them to calculate the center of the source in the 1D spatial profile. For more information on centroiding, please refer to the photutils documentation.

Before we estimate the center of the source, we need to estimate the background level. The background level is estimated by fitting a polynomial to the 1D spatial profile of the source. The background level is then subtracted from the 1D spatial profile to get the background-subtracted profile. The background-subtracted profile is then used to estimate the center of the source.

Let’s estimate the background level and subtract it from the 1D spatial profile. Here we use a Chebyshev polynomial of degree 3. The degree of the polynomial can be adjusted depending on the complexity of the background. To model the background, we should set the sky region where the source is not present. The sky region should be chosen carefully to avoid any contamination from the source.

# Select the sky region for the background estimation
sky_limit_peak = 10  # pixel limit from the peak pixel

mask_sky = np.ones_like(apall_probe, dtype=bool)
mask_sky[peak_pix - sky_limit_peak : peak_pix + sky_limit_peak] = False

x_probe_sky = x_probe[mask_sky]
apall_probe_sky = apall_probe[mask_sky]

fig = plt.figure(figsize=(6, 4))
ax = fig.add_subplot(111)
ax.step(x_probe, apall_probe, where="mid")
ax.fill_between(
    x_probe, 0, 1, where=mask_sky, alpha=0.1, transform=ax.get_xaxis_transform()
)
ax.set_xlabel("Pixel Number (Spatial Direction)")
ax.set_ylabel("Counts [e-]")
Text(0, 0.5, 'Counts [e-]')
../_images/a4ab3ae22a35f80bede3a2d99d3eaf8d0f83cfd857d919803c345d31d18cef5d.png
# Fit the sky background with a Chebyshev polynomial
# define function for background fitting
def fit_background(x, apall, fitmask, sigma_sigclip, order_skymodel):
    x_sky = x[fitmask]
    apall_sky = apall[fitmask]
    # sigma clipping
    mask_sigclip = sigma_clip(apall_sky, sigma=sigma_sigclip, masked=True).mask

    # fitting the sky background
    coeffs_sky, fitfull_sky = chebfit(
        x_sky[~mask_sigclip],
        apall_sky[~mask_sigclip],
        deg=order_skymodel,
        full=True,
    )

    # sky background model
    x = np.arange(apall.size)
    skymodel = chebval(x, coeffs_sky)

    # calculating the rms
    residual_skymodel = fitfull_sky[0][0]
    rms_skymodel = np.sqrt(residual_skymodel / len(x_sky[~mask_sigclip]))

    return skymodel, rms_skymodel, mask_sigclip
# setting the parameters
sigma_sigclip = 3
order_skymodel = 3

# fitting the sky background
skymodel, rms_skymodel, mask_sigclip = fit_background(
    x_probe,
    apall_probe,
    mask_sky,
    sigma_sigclip=sigma_sigclip,
    order_skymodel=order_skymodel,
)

# number of pixels
npix_sky = len(x_probe_sky)
npix_skymodel = np.count_nonzero(~mask_sigclip)
Hide code cell source
fig, axes = plt.subplots(2, 1, figsize=(12, 8), gridspec_kw={"height_ratios": [4, 1]})
ax = axes[0]
ax.step(x_probe, apall_probe, where="mid")
ax.fill_between(
    x_probe, 0, 1, where=mask_sky, alpha=0.1, transform=ax.get_xaxis_transform()
)

ax.plot(
    x_probe,
    skymodel,
    c="tab:red",
    lw=1,
    label=f"Chebyshev Sky Model ({npix_skymodel:d}/{npix_sky:d})",
)
ax.plot(
    x_probe_sky[mask_sigclip],
    apall_probe_sky[mask_sigclip],
    "x",
    c="tab:orange",
    label=f"Sigma-clipped ({sigma_sigclip:.1f}-sigma)",
)

resax = axes[1]
resax.plot([0, x_probe[-1]], [0, 0], c="k", ls="--", lw=0.8)
resax.step(
    x_probe, apall_probe - skymodel, where="mid", label=f"RMS={rms_skymodel:.2f}"
)
resax.fill_between(
    x_probe,
    0,
    1,
    where=mask_sky,
    color="tab:blue",
    alpha=0.1,
    transform=resax.get_xaxis_transform(),
)
resax.plot(
    x_probe_sky[mask_sigclip],
    apall_probe_sky[mask_sigclip] - skymodel[mask_sky][mask_sigclip],
    "x",
    c="tab:orange",
)
resax.set_xlabel("Pixel Number (Spatial Direction)")
resax.set_ylabel("Residuals [e-]")
# ylim_ax = ax.get_ylim()
ylim_resax = rms_skymodel * np.array([-1, 1]) * 10
resax.set_ylim(ylim_resax)
resax.tick_params(axis="x", direction="in", top="on")
resax.legend()

title_str = f"Skyfit: Chebyshev order {order_skymodel:d} ({sigma_sigclip:.1f}-sigma)"
ax.tick_params(axis="x", direction="in", labelbottom=False)
ax.set_xlabel("Pixel Number (Spatial Direction)")
ax.set_ylabel("Counts [e-]")
ax.set_title(title_str)
ax.legend()

for aa in axes:
    aa.set_xlim(0, x_probe[-1])

fig.subplots_adjust(hspace=0)
../_images/dba239ffe8ab418479ce9602185a45519cd4675a7022f073895e6caecd719a16.png

From the sigma clipping, we can see some rejected points near the source. This indicates that the sky region we set is not sufficiently far from the source. We should set the sky region further away from the source to avoid any contamination from the source. Sigma clipping will also reject any cosmic rays or bad pixels in the sky region, which will affect the background estimation.

from photutils.centroids import centroid_com

apall_probe_skysub = apall_probe - skymodel

center_probe_com = centroid_com(apall_probe_skysub)[0]
print("Center without sky subtraction:", centroid_com(apall_probe)[0])
print("Center after sky subtraction:", center_probe_com)

pwresult = peak_widths(apall_probe_skysub, [peak_pix], rel_height=0.5)
fwhm_probe = pwresult[0][0]
left_ips, right_ips = pwresult[2][0], pwresult[3][0]
fwhm_hight = pwresult[1][0]
print("FWHM:", fwhm_probe)
print("Left and right interpolated positions:", left_ips, right_ips)
Center without sky subtraction: 67.73007980094286
Center after sky subtraction: 67.73009569607028
FWHM: 2.8507875970919656
Left and right interpolated positions: 66.30838212291202 69.15916972000399
Hide code cell source
fig = px.line(
    x=np.arange(len(apall_probe_skysub)),
    y=apall_probe,
    title="Sky Subtracted Sum",
    labels={"x": "Pixel Number (Spatial Direction)", "y": "Counts [e-]"},
    line_shape="hvh",
)

fig.add_vline(
    x=peak_pix,
    line_dash="dash",
    line_color="red",
    annotation_text="Peak",
    annotation_position="top right",
    line_width=0.8,
)

fig.add_vline(
    x=center_probe_com,
    line_dash="dash",
    line_color="green",
    annotation_text="Center",
    annotation_position="top left",
    line_width=0.8,
)

fig.add_trace(
    go.Scatter(
        x=[left_ips, right_ips],
        y=[fwhm_hight, fwhm_hight],
        mode="lines+markers",
        line=dict(color="red", width=1.5),
        name=f"FWHM={fwhm_probe:.2f}",
    )
)

fig

Zoom in to see the subtle difference between the peak position and the center position.

2. Aperture Tracing#

We have identified the source position in the spatial direction by collapsing a slice of the 2D science frame along the dispersion direction. Next, we will trace the spatial position of the source along the dispersion direction. This involves changing the location of the slices in the dispersion direction and identifying the source position in each slice.

# Define the function for determining the center of the source for given slice range
def get_skymask(apall, sky_limit_peak, center=None):
    if center is None:
        peak_pix = peak_local_max(
            apall,
            num_peaks=1,
            min_distance=10,
            threshold_abs=np.mean(apall),
        )[0][0]
    else:
        peak_pix = round(center)

    mask_sky = np.ones_like(apall, dtype=bool)
    mask_sky[peak_pix - sky_limit_peak : peak_pix + sky_limit_peak] = False

    return mask_sky, peak_pix


def find_center(data, lcut, rcut, sky_limit_peak, sigma_sigclip, order_skymodel):
    apall = np.sum(data[:, lcut:rcut], axis=1)
    x = np.arange(apall.size)

    # mask for the sky region and initial peak pixel
    mask_sky, peak_pix = get_skymask(apall, sky_limit_peak)

    # fitting the sky background
    skymodel, rms_skymodel, mask_sigclip = fit_background(
        x,
        apall,
        mask_sky,
        sigma_sigclip=sigma_sigclip,
        order_skymodel=order_skymodel,
    )

    apall_skysub = apall - skymodel

    # center of the source
    center_com = centroid_com(apall_skysub)[0]

    # fwhm of the source
    pwresult = peak_widths(apall_skysub, [round(peak_pix)], rel_height=0.5)
    fwhm = pwresult[0][0]

    return center_com, fwhm


# Find the center of the source for all the slices
slice_width = 50
sky_limit_peak = 20  # increased the distance from the source
midpoints = []
aptrace = []
aptrace_fwhm = []
lcut, rcut = 0, slice_width
while rcut < rectified.shape[1]:
    rcut = lcut + slice_width
    midpoints.append((lcut + rcut) / 2)
    center, fwhm = find_center(
        rectified, lcut, rcut, sky_limit_peak, sigma_sigclip, order_skymodel
    )
    aptrace.append(center)
    aptrace_fwhm.append(fwhm)
    lcut += slice_width
    rcut += slice_width

aptrace = np.array(aptrace)
aptrace_fwhm = np.array(aptrace_fwhm)
aptrace_sigma = aptrace_fwhm * gaussian_fwhm_to_sigma
midpoints = np.array(midpoints)

# Let's fit the aperture trace
order_aptrace = 9
sigma_clip_aptrace = 3

# fitting the trace line
coeffs_aptrace_init = chebfit(midpoints, aptrace, deg=order_aptrace)

# sigma clipping
mask_aptrace = sigma_clip(
    aptrace - chebval(midpoints, coeffs_aptrace_init),
    sigma=sigma_clip_aptrace,
    masked=True,
).mask

# fitting the trace line again
coeffs_aptrace = chebfit(
    midpoints[~mask_aptrace], aptrace[~mask_aptrace], deg=order_aptrace
)
residuals_aptrace = aptrace - chebval(midpoints, coeffs_aptrace)
rejected = ~np.in1d(midpoints, midpoints[~mask_aptrace])
rms_aptrace = np.sqrt(np.mean(residuals_aptrace[~mask_aptrace] ** 2))

# aperture trace model
ximg = np.arange(rectified.shape[1])
aptrace_model = chebval(ximg, coeffs_aptrace)
Hide code cell source
# plot the aperture trace
%matplotlib inline
fig, axes = plt.subplots(2, 1, figsize=(12, 6), gridspec_kw={"height_ratios": [3, 1]})
ax = axes[0]
ax.imshow(rectified, cmap="gray", origin="lower", vmin=vmin, vmax=vmax, aspect="auto")
ax.scatter(midpoints, aptrace, marker="+", c="dodgerblue", s=100, lw=2, label="Data")
ax.scatter(
    midpoints[rejected],
    aptrace[rejected],
    marker="x",
    c="salmon",
    s=100,
    lw=1,
    label="Rejected",
)
ax.plot(ximg, aptrace_model, c="tab:red", lw=1, label="Chebyshev Trace Model")
ax.plot(midpoints, aptrace + 5 * aptrace_sigma, c="r", lw=0.8, ls="--", label="5-sigma")
ax.plot(midpoints, aptrace - 5 * aptrace_sigma, c="r", lw=0.8, ls="--")
ax.plot(
    midpoints, aptrace + 10 * aptrace_sigma, c="r", lw=0.8, ls=":", label="10-sigma"
)
ax.plot(midpoints, aptrace - 10 * aptrace_sigma, c="r", lw=0.8, ls=":")
ax.set_title("Aperture Trace")
ax.set_ylabel("Pixel Number (Spatial Direction)")
ax.set_xticks([])
ax.legend()

resax = axes[1]
resax.plot([0, ximg[-1]], [0, 0], c="k", ls="--", lw=0.8)
resax.scatter(midpoints, residuals_aptrace, marker="+", c="dodgerblue", s=100, lw=2)
resax.scatter(
    midpoints[rejected],
    residuals_aptrace[rejected],
    marker="x",
    c="salmon",
    s=100,
    lw=1,
)
resax.set_xlabel("Pixel Number (Dispersion Direction)")
resax.set_ylabel("Residuals [pix]")
resax.tick_params(axis="x", direction="in", top="on")
resax_ylim = np.max(np.abs(residuals_aptrace)) * np.array([-1, 1])
resax.set_ylim(resax_ylim)
resax.set_xlim(0, ximg[-1])
resax.text(
    0.99,
    0.95,
    f"RMS={rms_aptrace:.2f}",
    transform=resax.transAxes,
    ha="right",
    va="top",
)

fig.subplots_adjust(hspace=0)
../_images/b7ae139dd0b100ec81d2a3bb14d78eaa5ce198fbafcd6a5ea3a1b3d8606240ad.png

3. Aperture Summation#

Now we have traced the spatial position of the source along the dispersion direction. We will sum the flux along the trace to extract the 1D spectrum. The aperture size should be chosen carefully to avoid any contamination from the sky or other sources. The aperture size should be large enough to capture sufficient amount of the flux from the source but small enough to avoid any contamination from the sky or other sources.

See also

The type of aperture used for the extraction can affect the signal-to-noise ratio of the extracted spectrum. One can use a simple aperture, which sums the flux within a fixed aperture size, or an optimal extraction (Horne 1986), which weights the flux based on the spatial profile of the source. The optimal extraction can improve the signal-to-noise ratio of the extracted spectrum, especially when the source is extended or the background is not uniform. In this tutorial, we will use a simple aperture for the extraction, but you can try the optimal extraction for better results.

Let’s make a simple aperture summation to extract the 1D spectrum!

# Define the function for aperture summation
def aperture_sum(
    cut, center, apsum_sigma, ap_sigma, sky_limit_peak, sigma_sigclip, order_skymodel
):
    x = np.arange(len(cut))

    mask_sky, peak_pix = get_skymask(cut, sky_limit_peak, center)
    # aperture size = trace center +/- apsum_sigma * ap_sigma
    appos_lower = int(center - apsum_sigma * ap_sigma)
    appos_upper = int(center + apsum_sigma * ap_sigma)

    # fitting the sky background
    skymodel, rms_skymodel, mask_sigclip = fit_background(
        x,
        cut,
        mask_sky,
        sigma_sigclip=sigma_sigclip,
        order_skymodel=order_skymodel,
    )

    # subtracting the sky background
    cut_skysub = cut - skymodel
    source = cut_skysub[appos_lower:appos_upper]

    # aperture sum
    ap_sum = np.sum(source)

    # calculating the uncertainty
    sig = rdnoise**2 + source + skymodel[appos_lower:appos_upper]
    ap_sig = np.sqrt(np.sum(sig))

    return ap_sum, ap_sig


# Parameters for the aperture summation
apsum_sigma = 10
ap_fwhm = np.median(aptrace_fwhm[~mask_aptrace])  # median FWHM in pixels
ap_sigma = ap_fwhm * gaussian_fwhm_to_sigma  # sigma under the Gaussian assumption

# Extract the spectrum along the dispersion direction
ap_sum = []
ap_sig = []

for i in range(rectified.shape[1]):
    cut_i = rectified[:, i]
    center_i = aptrace_model[i]

    ap_sum_i, ap_sig_i = aperture_sum(
        cut_i,
        center_i,
        apsum_sigma,
        np.median(ap_sigma),
        sky_limit_peak,
        sigma_sigclip,
        order_skymodel,
    )
    ap_sum.append(ap_sum_i)
    ap_sig.append(ap_sig_i)

ap_sum = np.array(ap_sum) / exptime
ap_sig = np.array(ap_sig) / exptime
Hide code cell source
# Plot the extracted spectrum
fig = go.Figure()
fig.add_trace(
    go.Scatter(
        x=wavegrid,
        y=ap_sum,
        error_y=dict(
            type="data",
            array=ap_sig,
            visible=True,
            color="dodgerblue",
            thickness=1,
            width=1,
        ),
        mode="lines+markers",
        marker=dict(color="black", size=3),
        line=dict(color="black", width=0.8),
    )
)
fig.update_layout(
    title="Extracted Spectrum of Feige34",
    xaxis_title="Wavelength [Å]",
    yaxis_title="Counts per Exposure Time[e-/s]",
    showlegend=False,
)
fig

Extracting Spectra of All the Standard Star Frames#

I made a python class to extract the 1D spectra from given 2D frames. You can find the class in the apallutils.py script. The class inherits all the frame-specific variables and can be used to extract the 1D spectra from all the frames. You can find the code in our GitHub repository.

from apallutils import InstrumentalSpectrum

std_insspecs = []
for std in std_list_f34:
    fname = OUTDIR / f"p{std.stem}"
    ins = InstrumentalSpectrum(
        fname,
        sky_limit_peak=20,
        sigma_sigclip=3,
        order_skymodel=3,
        order_aptrace=9,
        sigma_clip_aptrace=3,
        slice_width_aptrace=50,
        apsum_sigma=10,
    )
    ins.get_rectified(xycoords, ss.shape, wavegrid)
    ins.set_aptrace()
    ins.set_apsum()
    std_insspecs.append(ins)
Hide code cell source
fig = go.Figure()
f, axes = plt.subplots(3, 1, figsize=(12, 3))
i = 0
for StdInsSpec in std_insspecs:
    ax = axes[i]
    fig.add_trace(
        go.Scatter(
            x=ximg,
            y=StdInsSpec.ap_sum,
            error_y=dict(
                type="data",
                array=StdInsSpec.ap_sig,
                visible=True,
                thickness=1,
                width=1,
            ),
            mode="lines+markers",
            marker=dict(size=3),
            line=dict(width=0.8),
            name=StdInsSpec.fname.stem,
        )
    )
    i += 1
    ax.imshow(
        StdInsSpec.crrej.data.T,
        cmap="gray",
        origin="lower",
        vmin=vmin,
        vmax=vmax,
    )
    ax.set_title(StdInsSpec.fname.stem)
f.suptitle("2D Images of the Standard Star Frames")
fig.update_layout(
    title="Extracted Spectrum of Feige34",
    xaxis_title="Pixel Number (Dispersion Direction)",
    yaxis_title="Counts per Exposure Time[e-/s]",
    showlegend=True,
    legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
)
fig
../_images/feedcf027b149f12c6cac12233894128079415bd8cbbe75c084b1017faf359cc.png
stdtab_f34
Table length=3
filenamedate-obsobjectradectypeexptimeairmassaltitudedisperserslit-widthslit-paslitfilter1filter2filter3ut-strut-end
str50str50str50str50str50str50float64float64float64str50float64float64str50str50str50str50str50str50
FCSA00141880.fits.gz2013-06-07Feige3410:39:30.972+43:06:49.26OBJECT60.01.18857.2701SCFCGRHD900.5-240.3SCFCSLLC08NONESCFCFLSO58NONE05:49:08.49605:50:08.794
FCSA00141894.fits.gz2013-06-07Feige3410:39:30.923+43:06:49.54OBJECT60.01.20755.9261SCFCGRHD900.5-240.3SCFCSLLC20NONESCFCFLSO58NONE05:58:05.72005:59:05.978
FCSA00141878.fits.gz2013-06-07Feige3410:39:30.978+43:06:49.28OBJECT60.01.18557.5049SCFCGRHD900.5-240.3SCFCSLLC08NONESCFCFLSO58NONE05:47:32.38005:48:32.720

As you can see from the figure above, each extracted spectrum shows different level of continuum. It can be due to various reasons, such as different amount of slit loss due to slight shifts of the source positions with respect to the slit aperture, or the difference in the seeing condition, out-of-focus, etc. To correct this, we can normalize the extracted spectrum by dividing it by the continuum level at some range. Here we will normalize the extracted spectrum by dividing it by the median value of the continuum level at the range of 500-600 pixels.

Note

If we manipulate the continuum level of standard star like this, how should we calibrate the flux of the science frame and trust them? For this reason, we need a process called aperture correction to correct the flux. We will skip this process for simplicity, but you should always consider this process in your own data reduction.

std_norm_factor = np.ones(len(std_insspecs))
refwindow = [500, 600]
for i, StdInsSpec in enumerate(std_insspecs):
    std_norm_factor[i] = np.sum(StdInsSpec.ap_sum[refwindow[0] : refwindow[1]])

# normalize to the maximum value
std_norm_factor = std_norm_factor / np.max(std_norm_factor)

for i, StdInsSpec in enumerate(std_insspecs):
    StdInsSpec.ap_sum /= std_norm_factor[i]
    StdInsSpec.ap_sig /= std_norm_factor[i]
fig = go.Figure()
for i in range(len(std_insspecs)):
    fig.add_trace(
        go.Scatter(
            x=wavegrid,
            y=std_insspecs[i].ap_sum,
            error_y=dict(
                type="data",
                array=std_insspecs[i].ap_sig,
                visible=True,
                thickness=1,
                width=1,
            ),
            mode="lines+markers",
            marker=dict(size=3),
            line=dict(width=0.8),
            name=std_insspecs[i].fname.stem,
        )
    )
fig.update_layout(
    title=" Extracted Spectrum of Feige34 Normalized to the Maximum Value",
    xaxis_title="Wavelength [Å]",
    yaxis_title="Counts per Exposure Time[e-/s]",
    showlegend=True,
    legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
)
fig

If you closely examine the extracted spectra, you will see that there are some shifts in the atmospheric absorption lines. However, the continuum of the standard star shows a good match, indicating that the wavelength solution might be consistent for all the extracted spectra. Still, the shifted frame is taken at different airmass and a different pointing direction. Thus the wavelength solution might be different. Therefore, it is important to check the wavelength solution for all the frames, using the sky lines or the arc lines. In this notebook, we will assume that the wavelength solution is consistent for all the frames, but you should always check the wavelength solution for all the frames in your own data.

Extracting the 1D Spectra of the Science Frames#

Now that we have extracted the 1D spectra of the standard star, we can proceed to extract the 1D spectra of the science frames. The science frames are fainter than the standard star, so we need to be careful when extracting the 1D spectra. We will use the same procedure as for the standard star, but we need to be more careful when identifying the source position and tracing the spatial position of the source along the dispersion direction.

Let’s extract the 1D spectra of the science frames!

sci_insspecs = []
for sci in sci_list:
    fname = OUTDIR / f"p{sci.stem}"
    ins = InstrumentalSpectrum(
        fname,
        sky_limit_peak=10,
        sigma_sigclip=3,
        order_skymodel=3,
        order_aptrace=3,  # reduced the order for the science images
        sigma_clip_aptrace=3,  # reduced the thresold for the science images
        slice_width_aptrace=50,
        apsum_sigma=10,
    )
    ins.get_rectified(xycoords, ss.shape, wavegrid)
    # I added the masking windows for the science images. These masking windows can be
    # used to mask out the regions where the trace is not well defined.
    ins.set_aptrace(masking_windows=[[0, 200], [450, 1250]])
    ins.set_apsum()
    ins.plot_aptrace()  # plot the aperture trace for the science images
    sci_insspecs.append(ins)
Hide code cell output
../_images/5a2f7b0a23e1a90575dcea255ff772b1f8f7d06f943ad4bb6bf7cc4161d6d583.png ../_images/30116ba6201ef0c8bc8173dae3646666559d85a296733e08a854901cf49cd2fa.png ../_images/f559628c04277acf25f076037b05ff378dfda65350100821b1acdec5759d7430.png ../_images/f86f87822c0ac18f093abd893e32f58ac797956787fbb7c219aad6c68ccdcb76.png ../_images/3be50480b0fafd73b99f18d9b45383281b94284ce3ecdbd6c0ed42e7714ff87f.png ../_images/d659f891384d2efb3e77640cec38654586f498b3720d3d4d477e2e3dd33a0f70.png ../_images/eb779a714dd1b81e95b425cb03f6f2a5e978e40632610190415288956cdebfca.png ../_images/f90bbe7df394ba88929dc5bfe5a5e3cfbee942ba007b13121c17c2b1179db4b0.png ../_images/86df372a621a50d9d4307b82509df99b0c7823cb4ce2c60402c750cc4a7aaab1.png
# Plot the extracted spectra
fig = plt.figure(figsize=(12, 4))
ax = fig.add_subplot(111)
ax.set_title("Extracted Spectrum of GRB 130606A")
for SciInsSpec in sci_insspecs:
    ax.plot(SciInsSpec.ap_sum, label=SciInsSpec.fname.stem, lw=0.5, alpha=0.3, c="k")
ax.set_xlabel("Pixel Number (Dispersion Direction)")
ax.set_ylabel("Counts per Exposure Time [e-/s]")
ax.set_ylim(0, 4)
(0.0, 4.0)
../_images/f88307dd1797d93c843ff8c3e9d2b4daf6c7d5ee3750e377aa945330330b3daa.png

Flux Calibration#

The extracted 1D spectra are in counts, which are affected by the instrumental response of the spectrograph. To convert the counts to physical units, such as flux density in erg/s/cm^2/Angstrom, we need to calibrate the flux of the extracted spectra. The flux calibration involves the following steps:

  1. Get the Standard Star Spectrum: Obtain the reference spectrum of your standard star from the literature (or observe, calibrate, and make it yourself).

  2. Evaluate the Sensitivity Function: Calculate the sensitivity function of the spectrograph, which is the ratio of the observed counts to the true flux of the standard star.

  3. Apply the Sensitivity Function: Apply the sensitivity function to the extracted spectra of the science targets to convert the counts to physical units.

The sensitivity function can be calculated by comparing the observed counts of the standard star to the true flux of the standard star. The true flux of the standard star can be obtained from the reference spectrum of the standard star. The sensitivity function is then applied to the extracted spectra of the science targets to convert the counts to physical units.

In this case, we need to obtain the reference spectrum of Feige 34. The reference spectrum of Feige 34 can be obtained from the STScI CALSPEC database. This file is also available in the feige34_stis_004.fits file in our repository.

# reference spectrum
stdref = Table.read(WD / "feige34_stis_004.fits")
stdref_wave = stdref["WAVELENGTH"].value
stdref_flux = stdref["FLUX"].value

# observed spectrum in instrumental units
stdinst_flux = np.mean(
    [std_insspecs[i].ap_sum for i in range(len(std_insspecs))], axis=0
)
stdinst_wave = wavegrid

# resample the reference spectrum using flux-conserving method
input_spectra = Spectrum1D(
    spectral_axis=stdref_wave * u.AA, flux=stdref_flux * u.erg / u.s / u.cm**2 / u.AA
)
fluxc_resample = FluxConservingResampler()
stdref_resampled = fluxc_resample(input_spectra, stdinst_wave * u.AA)
stdref_flux_resampled = stdref_resampled.flux.value
WARNING: UnitsWarning: 'ANGSTROMS' did not parse as fits unit: At col 0, Unit 'ANGSTROMS' not supported by the FITS standard. Did you mean Angstrom or angstrom? If this is meant to be a custom unit, define it with 'u.def_unit'. To have it recognized inside a file reader or other code, enable it with 'u.add_enabled_units'. For details, see https://docs.astropy.org/en/latest/units/combining_and_defining.html [astropy.units.core]
WARNING: UnitsWarning: 'FLAM' did not parse as fits unit: At col 0, Unit 'FLAM' not supported by the FITS standard. Did you mean flm? If this is meant to be a custom unit, define it with 'u.def_unit'. To have it recognized inside a file reader or other code, enable it with 'u.add_enabled_units'. For details, see https://docs.astropy.org/en/latest/units/combining_and_defining.html [astropy.units.core]
WARNING: UnitsWarning: 'NONE' did not parse as fits unit: At col 0, Unit 'NONE' not supported by the FITS standard.  If this is meant to be a custom unit, define it with 'u.def_unit'. To have it recognized inside a file reader or other code, enable it with 'u.add_enabled_units'. For details, see https://docs.astropy.org/en/latest/units/combining_and_defining.html [astropy.units.core]
WARNING: UnitsWarning: 'SEC' did not parse as fits unit: At col 0, Unit 'SEC' not supported by the FITS standard. Did you mean EC? If this is meant to be a custom unit, define it with 'u.def_unit'. To have it recognized inside a file reader or other code, enable it with 'u.add_enabled_units'. For details, see https://docs.astropy.org/en/latest/units/combining_and_defining.html [astropy.units.core]
# setting masking regions
masking_windows = [[7560, 7730], [8120, 8400], [8930, 9700], [10010, 10150]]
fig = plt.figure(figsize=(12, 4))
ax1 = fig.add_subplot(121)
ax1.plot(stdinst_wave, stdinst_flux, c="k", lw=0.8)
ax1.set_title("Feige34 Instrumental Spectrum")
ax1.set_xlabel("Wavelength [Å]")
ax1.set_ylabel("Counts per Exposure Time [e-/s]")
xlim = ax1.get_xlim()
ax2 = fig.add_subplot(122)
ax2.plot(
    stdinst_wave,
    stdref_flux_resampled,
    c="tab:blue",
    lw=0.8,
    label="Resampled",
    zorder=2,
)
ylim = ax2.get_ylim()
ax2.plot(stdref_wave, stdref_flux, c="k", lw=0.8, label="Original")
ax2.set_title("Feige34 Reference Spectrum")
ax2.set_xlabel("Wavelength [Å]")
ax2.set_ylabel("Flux [erg/s/cm²/Å]")
ax2.set_xlim(xlim)
ax2.set_ylim(ylim)
ax2.legend()

for ax in [ax1, ax2]:
    for window in masking_windows:
        ax.axvspan(window[0], window[1], color="gray", alpha=0.1, zorder=1)
../_images/1ed3463e13d3198e8389562977b1d92c11fd36dde1142c3c5f69258f151a0170.png
# polynomial fitting
order_sensfunc = 9

ratio = stdref_flux_resampled / stdinst_flux
mask = np.zeros_like(stdinst_wave, dtype=bool)
for window in masking_windows:
    mask |= (stdinst_wave > window[0]) & (stdinst_wave < window[1])

# fitting the sensitivity function in logarithmic scale
logratio = np.log10(ratio)

coeffs = np.polyfit(stdinst_wave[~mask], logratio[~mask], order_sensfunc)
logsensfunc = np.polyval(coeffs, stdinst_wave)
sensfunc = 10**logsensfunc

# plot the sensitivity function
fig = plt.figure(figsize=(12, 4))
ax = fig.add_subplot(111)
ax.scatter(stdinst_wave, ratio, c="tab:blue", s=2, marker="+", label="Ratio")
ax.plot(stdinst_wave, sensfunc, c="tab:red", lw=0.8, label="Model Sensitivity Function")
ax.set_title("Sensitivity Function")
ax.set_xlabel("Wavelength [Å]")
ax.set_ylabel("Sensitivity [erg/s/cm²/Å/(e-/s)]")
for window in masking_windows:
    ax.axvspan(window[0], window[1], color="gray", alpha=0.1)

ax.legend()
/opt/anaconda3/envs/main/lib/python3.11/site-packages/IPython/core/interactiveshell.py:3577: RankWarning:

Polyfit may be poorly conditioned
<matplotlib.legend.Legend at 0x1f4e25950>
../_images/142bd9521656bdd4247ad3150e11b70fcd6a1e53f29ecbf97aca20c3cbbc8d59.png
# apply the sensitivity function to the observed spectra
fig = plt.figure(figsize=(12, 4))
ax1 = fig.add_subplot(121)
ax1.plot(stdinst_wave, stdinst_flux * sensfunc, c="k", lw=0.8)
ax1.set_title("Feige34 Standardized Spectrum")
ax1.set_xlabel("Wavelength [Å]")
ax1.set_ylabel("Flux [erg/s/cm²/Å]")
xlim = ax1.get_xlim()
ax2 = fig.add_subplot(122)
ax2.plot(
    stdinst_wave,
    stdref_flux_resampled,
    c="tab:blue",
    lw=0.8,
    label="Resampled",
    zorder=2,
)
ylim = ax2.get_ylim()
ax2.plot(stdref_wave, stdref_flux, c="k", lw=0.8, label="Original")
ax2.set_title("Feige34 Reference Spectrum")
ax2.set_xlabel("Wavelength [Å]")
ax2.set_ylabel("Flux [erg/s/cm²/Å]")
ax2.set_xlim(xlim)
ax2.set_ylim(ylim)
ax2.legend()

for ax in [ax1, ax2]:
    for window in masking_windows:
        ax.axvspan(window[0], window[1], color="gray", alpha=0.1, zorder=1)
../_images/c6e364b89a11a2d4889b5544b09772430c40d6508e211cac3a85aa06cd83295b.png
# save the sensitivity function
sensfunc_table = Table([stdinst_wave, sensfunc], names=["WAVELENGTH", "SENSFUNC"])
sensfunc_table.write(OUTDIR / "sensfunc.fits", overwrite=True)

# apply the sensitivity function to the science spectra
for ins in sci_insspecs:
    ins.flux = ins.ap_sum * sensfunc
    ins.fluxerr = ins.ap_sig * sensfunc

# plot the standardized science spectra
fig = go.Figure()
for ins in sci_insspecs:
    fig.add_trace(
        go.Scatter(
            x=wavegrid,
            y=ins.flux,
            error_y=dict(
                type="data",
                array=ins.fluxerr,
                visible=True,
                thickness=1,
                width=1,
            ),
            mode="lines+markers",
            marker=dict(size=3),
            line=dict(width=0.8),
            name=ins.fname.stem,
        )
    )

fig.update_layout(
    title="Standardized Science Spectra",
    xaxis_title="Wavelength [Å]",
    yaxis_title="Flux [erg/s/cm²/Å]",
    showlegend=True,
    legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
)
fig

References#