Calibration frames#

Prerequisites

  • Basic statistics (covered in this notebook)

  • Knowledge about CCD, bias, dark, and flat frames.

We will learn

  • What do bias, dark, and flat frames look like

  • How to deal with outliers; sigma-clipping.

  • How to combine individual calibration frames to make “master” calibration frames.

  • Further familiarize with operations on numpy arrays and data I/O.

Reference

  • Here is a great tutorial on CCD data reduction. Please check out!!

  • Howell, S., Handbook of CCD Astronomy, Second Ed, Cambridge University Press 2006

I suppose Jenny already introduced bias, dark, flat frames. Here I will demonstrate what real bias frames look like, and why we need to stack them.

%load_ext autoreload
%autoreload 2

import os, sys
import numpy as np
import scipy
import matplotlib
import matplotlib.pyplot as plt

# We can beautify our plots by changing the matpltlib setting a little
plt.rcParams['font.size'] = 18
plt.rcParams['figure.figsize'] = (8, 6)
plt.rcParams['figure.dpi'] = 80
plt.rcParams['axes.linewidth'] = 2
Hide code cell source
try:
    import google.colab
    IN_COLAB = True
except:
    IN_COLAB = False

if not IN_COLAB:
    if not os.path.exists("../_static/ObsAstroData/calibration_frames/bias/"):
        os.makedirs("../_static/ObsAstroData/calibration_frames/bias/")
    os.chdir('../_static/ObsAstroData/calibration_frames/')

if os.path.isfile('b0860c1.fits'):
    print('Data is already there')
else:
    print('Download data')
#     !wget https://tigress-web.princeton.edu/~jiaxuanl/cutoutRGB_NIRCAM.pkl
Download data

1. Bias frames#

As introduced in class, a voltage is applied to every pixel to make sure the pixel value is always positive. This voltage introduces a “bias” to pixel values. However, this bias is slightly different for each pixel and might change over time. Therefore, we need to take some zero-time exposures (such that dark current and real photons don’t contribute to the pixel values) to characterize the bias.

Below we load bias frames taken using the camera of IFU-M on the 6.5 m Magellan telescope in Chile.

Read a bias frame#

from astropy.io import fits

It’s our first time reading a FITS file. FITS (Flexible Image Transport System) format is widely used in astronomy for images and tables. The following code will read the file b0854c1.fits, which is a list containing only one HDU (Header Data Unit).

fits.open('./bias/b0860c1.fits')
[<astropy.io.fits.hdu.image.PrimaryHDU object at 0x7f91c2a327c0>]
fits.open('./bias/b0860c1.fits').info()
Filename: ./bias/b0860c1.fits
No.    Name      Ver    Type      Cards   Dimensions   Format
  0  PRIMARY       1 PrimaryHDU     143   (2176, 1156)   int16 (rescales to uint16)   

Now we know that this file has one HDU, which is an image containing 2176 * 1156 pixels. The data type in int16. We can check the header of the HDU.

hdu = fits.open('./bias/b0860c1.fits')[0]
hdu.header
SIMPLE  =                    T                                                  
BITPIX  =                   16                                                  
NAXIS   =                    2                                                  
NAXIS1  =                 2176                                                  
NAXIS2  =                 1156                                                  
BSCALE  =           1.00000000         / real=bzero+bscale*value                
BZERO   =       32768.00000000                                                  
BUNIT   = 'DU/PIXEL'                                                            
ORIGIN  = 'LCO/OCIW'                                                            
OBSERVER= 'Bailey'                     / observer name                          
TELESCOP= 'Clay'                       / telescope                              
SITENAME= 'LCO'                                                                 
SITEALT =                 2405         / meters                                 
SITELAT =            -29.01423                                                  
SITELONG=            -70.69242                                                  
TIMEZONE=                    4                                                  
DATE-OBS= '2023-03-21T10:50:38'        / (start)                                
UT-DATE = '2023-03-21'                 / UT date (start)                        
UT-TIME = '10:50:38'                   / UT time (start) 39038 seconds          
UT-END  = '10:50:39'                   / UT time (end) 39039 seconds            
LC-TIME = '07:50:38'                   / local time (start) 28238 seconds       
NIGHT   = '20Mar2023'                  / local night                            
MJD     =          60024.45183         / modified Julian day                    
INSTRUME= 'IFUM'                       / instrument name                        
SCALE   =                0.250         / arcsec/pixel                           
EGAIN   =                0.680         / electrons/DU                           
ENOISE  =                2.700         / electrons/read                         
NOPAMPS =                    4         / # of op-amps                           
OPAMP   =                    1         / this op-amp                            
CHOFFX  =              256.000         / x-offset [arcsec]                      
CHOFFY  =              257.000         / y-offset [arcsec]                      
DISPAXIS=                    1         / disperser axis                         
RA      = ' 17:57:11.9'                / right ascension                        
RA-D    =          269.2997917         / degrees                                
DEC     = '-28:59:59.0'                / declination                            
DEC-D   =          -28.9997222         / degrees                                
EQUINOX =           2000.00000         / of above coordinates                   
EPOCH   =           2023.21768         / epoch (start)                          
AIRMASS =                1.000         / airmass (start)                        
ST      =              64960.8         / sidereal time: 18:02:41 (start)        
ROTANGLE=               273.69         / rotator offset angle                   
TEL-AZIM=                 90.0         / telescope azimuth                      
TEL-ELEV=                 90.0         / telescope elevation                    
T-DOME  =                 13.9         / dome temperature [C]                   
T-CELL  =                 12.4         / primary cell temperature [C]           
T-TRUSS =                  8.7         / telescope truss temperature [C]        
WX-TEMP =                 13.6         / weather station temperature [C]        
FILENAME= 'b0860c1'                                                             
OBJECT  = '1x2 bias'                   / object name                            
COMMENT =                              / comment                                
EXPTYPE = 'Bias'                       / exposure type                          
EXPTIME =                0.000         / exposure time                          
NLOOPS  =                  250         / # of loops per sequence                
LOOP    =                    7         / # within this sequence                 
BINNING = '1x2'                        / binning                                
SPEED   = 'Slow'                       / readout speed                          
NOVERSCN=                  128         / overscan pixels                        
NBIASLNS=                  128         / bias lines                             
BIASSEC = '[2049:2176,1029:1156]'      / NOAO: bias section                     
DATASEC = '[1:2048,1:1028]'            / NOAO: data section                     
TRIMSEC = '[1:2048,1:1028]'            / NOAO: trim section                     
SUBRASTR= 'none'                       / full frame                             
TEMPCCD =               -109.9         / CCD temperature [C]                    
SHOE    = 'B'                                                                   
IFU     = 'LSB'                                                                 
IFUENC  =                -1801         / IFU encoder                            
OCCULTOR= 'Science Gap'                                                         
OCCENC  =                98000         / occultor encoder                       
SLIDE   = 'LoRes'                                                               
SLIDEENC=               4061.0         / slide encoder                          
SLIDESTP=               285126         / slide step                             
LO-ELEV = '9.1301'                     / step= -107184                          
HI-AZIM = '-0.55001?'                  / step= -14143                           
HI-ELEV = '0.40001?'                   / step= 10286                            
FOCUS   =                196.1         / step= 1961                             
FILTER  = 'BK7'                                                                 
SLITNAME= '80'                         / 607 526                                
BENEAR  =                    0         / lamp level (0..10)                     
LIHE    =                    0         / lamp level (0..10)                     
THXE    =                    0         / lamp level (0..10)                     
LAMPS   = 'Off'                        / lamp combo                             
LED-UV  =                    0         / LED level (0..4096)                    
LED-BL  =                    0         / LED level (0..4096)                    
LED-VI  =                    0         / LED level (0..4096)                    
LED-NR  =                    0         / LED level (0..4096)                    
LED-FR  =                    0         / LED level (0..4096)                    
LED-IR  =                    0         / LED level (0..4096)                    
LEDS    = 'Off'                        / LED combo                              
CONFIGFL= 'unknown'                    / configuration file                     
TEMP01  =               14.500         / IFU_Entrance                           
TEMP02  =               13.750         / IFU_Top                                
TEMP03  =               13.812         / Fiber_Exit                             
TEMP04  =               25.100         / IFU_Motor                              
TEMP05  =               24.600         / IFU_Drive                              
TEMP06  =               19.688         / IFU_Hoffman                            
TEMP07  =               20.562         / IFU_Shoebox                            
TEMP08  =               13.375         / Cradle_R                               
TEMP09  =               13.188         / Cradle_B                               
TEMP10  =               13.562         / Echelle_R                              
TEMP11  =               13.438         / Echelle_B                              
TEMP12  =               13.688         / Prism_R                                
TEMP13  =               13.375         / Prism_B                                
TEMP14  =               13.250         / LoResGrating_R                         
TEMP15  =               13.250         / LoResGrating_B                         
ADC-STGE= 'in'                         / ADC stage                              
ADC-CTRL= 'auto'                       / ADC control                            
MIRROR  = 'in'                         / Mirror stage                           
DCU-FFS = 'retracted'                  / DCU FF-Screen                          
DCU-LMPS= 'Off'                        / DCU lamps                              
SOFTWARE= 'Version beta (0071) (Oct  5 2022, 17:02:27)'                         
FITSVERS= '0.070'                      / FITS header version                    
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         
COMMENT                                                                         

Let’s read the data array.

img = hdu.data
img
array([[817, 817, 817, ..., 816, 813, 819],
       [816, 818, 820, ..., 817, 818, 828],
       [809, 827, 817, ..., 812, 814, 816],
       ...,
       [813, 810, 815, ..., 814, 816, 823],
       [821, 818, 816, ..., 823, 820, 816],
       [812, 819, 815, ..., 819, 816, 814]], dtype=uint16)
np.median(img), np.std(img)
(817.0, 10.545205968480271)

The pixel values in this bias frame are centered around ~817, and have a dispersion of ~10.5. This dispersion is largely contributed by the read-out noise, a noise that added to each pixel when reading the charge out. Read-out noise may be viewed as a “toll” that must be paid for reading the stored charge.

np.sum(img < 0)
0

Thanks to the bias voltage, none of the pixels are negative. We can further visualize the bias frame as follows.

from utils import show_image
show_image(img, show_colorbar=True);
../../_images/aaf7affcc50bf8b20e919ba745eb8210aa009478c65ab7c2245e61ccdcedf072.png

The bias frame shows some spatial patterns: some rows have a lower bias value than others. We also see some distinct “bright” pixels. They could be either bad pixels, or caused by Cosmic Rays (CR). As a consequence, when we plot the histogram of pixel values, we will see some outliers from the bulk of pixels.

fig, [ax1, ax2] = plt.subplots(1, 2, figsize=(12, 4))

ax1.hist(img.flatten(), bins=30)
ax1.set_xlabel('Pixel value [counts]')
ax1.set_ylabel('Frequency')
ax2.hist(img.flatten(), bins=30)
ax2.set_yscale('log')
ax2.set_xlabel('Pixel value [counts]')
ax2.set_ylabel('log Frequency')

plt.tight_layout()
../../_images/1898ef9d38c48691a8fc0a2afc6d4308d2e7766ab0b5eacc7f31a34d8317adc9.png

During the Magellan observation, we took many bias frames (why??). So let’s load another bias frame. What do you expect? Will it be very different from the one above?

show_image(fits.open('./bias/b0861c1.fits')[0].data);
../../_images/778c5726d3865aae1f95df07d36906ad09f022b5cdd0eea78b2e5ff3cc5e423e.png

Yeah, the bright spots changed their positions in the image. So it does seem like they are caused by cosmic rays.

Combine bias frames to make “master” bias#

We have already learned from this notebook that combining images will reduce the noise level. Belowe we try to combine 40 bias frames.


biases = [fits.open(f'./bias/b0{ind}c1.fits')[0].data for ind in range(860, 900)]
biases = np.array(biases)
biases.shape
(40, 1156, 2176)
# We have introduced how to calculate the mean and median image using the `axis` argument.
# Check `basic_stat.ipynb`
med_bias = np.median(biases, axis=0)
mean_bias = np.mean(biases, axis=0)
fig, [ax1, ax2] = plt.subplots(2, 1, figsize=(12, 12))

ax1 = show_image(med_bias, ax=ax1, fig=fig,)
ax1.text(0.1, 0.8, 'Median-combined', fontsize=25, color='w', 
         ha='left', transform=ax1.transAxes)

ax2 = show_image(mean_bias, ax=ax2, fig=fig,)
ax2.text(0.1, 0.8, 'Mean-combined', fontsize=25, color='w', 
         ha='left', transform=ax2.transAxes)

plt.tight_layout()
../../_images/aa4622cb9879b6906beb7aa6be3755951e94c6f8f2b26536aaaf4fb4476bb443.png

Wow! The cosmic rays are everywhere in the mean-combined image! The vast difference between the median-combined and mean-combined images highlights that the mean is far less immune to outliers (i.e., cosmic rays) than the median. Because of this, we often use median to combine images.

The histogram below shows again that combining individual bias frames will help reduce the read-out noise, thus boost the SNR.

plt.hist(biases[0].flatten(), histtype='step', density=True, 
         range=(795, 840), bins=25, lw=1, label='Single bias frames')
for i in range(1, 21):
    plt.hist(biases[i].flatten(), histtype='step', density=True, 
         range=(795, 840), bins=25, alpha=0.1, color='C0')

plt.hist(med_bias.flatten(), histtype='step', density=True, 
         range=(795, 840), bins=25, lw=2, label='40 biases combined')

plt.xlabel('Pixel value [counts]')

plt.legend(loc='upper center', ncols=2, frameon=False, bbox_to_anchor=(0.5, 1.15));
../../_images/a96eecec48970e82f97a09b9777a1986e0bc25e8017b9c5ad7b2882cc73176b7.png

Sigma-clipping#

In astronomy, there is yet another way to deal with outliers.

Recall that for a Gaussian distribution, the probability for a data point being \(>3\sigma\) away from the mean \(\mu\) is very very slim. So if we see such a data point that is quite far from the center of the data set, it is probably an outlier and should be removed. This is the idea of sigma clipping.

In the following, we flag out pixels that are \(>3\sigma\) away from the median value (we don’t use the mean value because it is heavily biased by these cosmic rays). You can see the cosmic rays being marked as red in the figure below (they are tiny dots).

hdu = fits.open('./bias/b0860c1.fits')[0]
img = hdu.data.astype(float)
med, std = np.median(img), np.std(img)
flag = (np.abs(img - med) > 3 * std)
show_image(flag, cmap='Reds', vmin=0., vmax=0.2, show_colorbar=False);
../../_images/ca54ac5d03a9f09e1ec13c01cc68153e38b204feb2aee44e1bfadd7f155420e9.png

We can replace these outlier pixels with np.nan (not a number, typically used to indicate a bad value). Then we repeat this for all 40 bias frames and combine them.

# This might be slow
biases_clipped = []
for ind in range(860, 900):
    img = fits.open(f'./bias/b0{ind}c1.fits')[0].data.astype(float)
    med, std = np.median(img), np.std(img)
    flag = (np.abs(img - med) > 3 * std)
    img[flag] = np.nan
    biases_clipped.append(img)
biases_clipped = np.array(biases_clipped)
# might be slow
med_bias_clipped = np.nanmedian(biases_clipped, axis=0)
mean_bias_clipped = np.nanmean(biases_clipped, axis=0)
fig, [ax1, ax2] = plt.subplots(2, 1, figsize=(12, 12))

ax1 = show_image(med_bias_clipped, ax=ax1, fig=fig, vmin=816.6, vmax=818.4)
ax1.text(0.1, 0.8, 'Median-combined, sigma-clipped', fontsize=25, color='w', 
         ha='left', transform=ax1.transAxes)

ax2 = show_image(mean_bias_clipped, ax=ax2, fig=fig, vmin=816.6, vmax=818.4)
ax2.text(0.1, 0.8, 'Mean-combined, sigma-clipped', fontsize=25, color='w', 
         ha='left', transform=ax2.transAxes)

plt.tight_layout()
../../_images/ccc4e90a1eb4d62578a53208f5ccf203e55bee9f710bec03aaffd130b92f9f06.png

Yeah! After sigma-clipping, the mean-combined image looks much better and very similar to the median-combined one. This sigma-clipping trick is quite common in astronomy. We used a threshold of 3-sigma and only clipped once. You can repeat the procedure for several times to make sure all outliers are flagged. Actually, astropy already provides us a function sigma_clip to do these jobs. Let’s check it out.

from astropy.stats import sigma_clip
astropy_clipped = sigma_clip(biases,  # the bias frames before sigma-clipping
                             sigma=3,  # threshould is 3-sigma
                             maxiters=5,  # do clipping for 5 times at most
                             cenfunc='median', stdfunc='std',
                             # use median and standard deviation to calculate clipping criterion
                             axis=(1, 2), # calc median and std for each bias frame
                             masked=False)
# might be slow
med_bias_apy = np.nanmedian(astropy_clipped, axis=0)
mean_bias_apy = np.nanmean(astropy_clipped, axis=0)
fig, [ax1, ax2] = plt.subplots(2, 1, figsize=(12, 12))

ax1 = show_image(med_bias_apy, ax=ax1, fig=fig, vmin=816.6, vmax=818.4)
ax1.text(0.1, 0.8, 'Median-combined, astropy sigma-clipped', fontsize=25, color='w', 
         ha='left', transform=ax1.transAxes)

ax2 = show_image(mean_bias_apy, ax=ax2, fig=fig, vmin=816.6, vmax=818.4)
ax2.text(0.1, 0.8, 'Mean-combined, astropy sigma-clipped', fontsize=25, color='w', 
         ha='left', transform=ax2.transAxes)

plt.tight_layout()
../../_images/f124d5a8c9eb4cf2b4105cff4ef101d65ee5cece181c83c5e14390a5e8c0df28.png

We can write the median-combined image to a FITS file for future usage.

hdu = fits.PrimaryHDU(med_bias_clipped.astype(np.float32))
hdulist = fits.HDUList([hdu])
hdulist.writeto('./bias/master_bias.fits', overwrite=True)

2. Dark frame#

Just to give you a flavor of dark frame, here we show the five dark frames that we took during the Magellan observation. They are full of cosmic rays. We construct a master dark frame by combining them using median.

darks = [fits.open(f'./dark/b{ind}c1.fits')[0].data for ind in range(1212, 1217)]
darks = np.array(darks)
med_dark = np.median(darks, axis=0)
fig, [ax1, ax2] = plt.subplots(2, 1, figsize=(12, 12))

ax1 = show_image(darks[0], ax=ax1, fig=fig)
ax1.text(0.1, 0.8, 'Single dark frame (3600s)', fontsize=25, color='w', 
         ha='left', transform=ax1.transAxes)

ax2 = show_image(med_dark, ax=ax2, fig=fig)
ax2.text(0.1, 0.8, 'Master dark frame (median-combined)', fontsize=25, color='w', 
         ha='left', transform=ax2.transAxes)

plt.tight_layout()
../../_images/f9038608f0d5c6c14c3dfd91349079e52b0d75287fcc7db7891069f95ebf4377.png

The light area in the lower left corner is due to sensor glow, which is light emitted by the electronics of the CCD. The counts in a dark frame scales linearly with time. The exposure time for a dark frame is typically similar to (or longer than) the exposure time of your science frames.

3. Flat fielding#