Lab 2 Solutions

Gather round, for I shall tell a tale old as time. Long, long ago, astronomers gazed at the heavens, and with naught but their eyes, recorded the positions of the stars they saw there. Then, with the telescope, long, heavy contraptions which reached for the skies like an outstretched arm, (thanks, f/20 focal lengths) they gazed through eyepieces in freezing domes, falling, on occasion, to their deaths from cages suspended at prime foci.

Next came glass – emulsion plates – which upon extended exposure revealed upon their ghostly frames the splotches of stars and soon, galaxies and nebulae. Many a grad student, of course, spent their nights twiddling thumbs over micrometer dials to keep these stars in place. Manual guiding… this story teller shudders to imagine it. And yet, even then, with permanent record, no measure could be made that weren’t ‘tween the eyes of an observer, peering at the glass through magnifying eyepiece, assigning grades of brightness and, amazingly, pretty much nailing the spectral classification of stars by their absorption features.

And after this painstaking work, came the earliest CCDs, fractured by detector failures, riddled with readout noise, consumed by cosmic ray hits, laid low by low quantum efficiencies.

Now… we use Python.


Astronomical images are, in some sense, the bread and butter of astronomy. While we now obtain data in myriad ways, from gravitational waves to neutrinos, from spectra to polarimetry, the basic tenant of astronomy (and it's most recongizable public impact) is the images we make of the sky.

That is why the first science topic we’ll be tackling is imaging. And along the way, we’ll learn how astropy makes our lives so much easier when dealing with images, and how to use robust functions to perform the analyses one might want to carry out on images (after we’re done admiring their beauty).

Part I: If the glove FITS

Many of you are probably familiar with the FITS standard. It stands for Flexible Image Transport System, and for all intents and purposes, it acts as a container (much like a zip file). Within it, one can store several kinds of information: images (2d arrays of values), as the name implies, headers (dictionary like objects with metadata), and sometimes tabular data as well. A FITS file can contain any number of extensions, which just refer to “slots” where stuff is stored. Every “slot” has a header attribute and a data attribute. Thus, you could store a hundred astronomical images in 1 file, each with a different extension and a different header describing the image in that slot.

Tip

Contextual clues are important when dealing with FITS files. Files that are explicitly single images almost always have the image stored in the 0th extension, while files that are explicitly table data tend to have the table stored in the 1st extension (with the 0th empty).

The FITS standard is pretty old, and may be retired soon, but almost all ground based telescopes still use it for their native image output, so it behooves us to know how to get image data out of FITS files.

Problem 1: Loading a FITS file

Write a function which takes as its argument a string filepath to a FITS file, and should have an optional argument to set the extension (default 0). It should then load the given extension of that fits file using a context manager, and return a tuple containing the header and data of that extension.

The function should have documentation that describes the inputs and outputs. Documentation is incredibly important when writing code, both in-line and (# comments) and at the top of functions, methods, and classes.

In this class, we’ll be using Sphinx-compatible documentation written in the Numpy/Scipy style. There are several reasons to do this.

  1. It is a user-friendly and write-friendly, readable documentation format that is easy to add to your functions.

  2. It can be rendered by Sphinx, the most popular automatic documentation renderer. If you’ve ever read the online documentation pages for functions in, e.g., numpy and scipy, those pages were rendered automatically based on the docstrings of the functions in question. This is possible with tools like Sphinx, but the documentation must be formatted correctly for this to work.

In the cell below, I’ve provided a dummy function which takes in any number of inputs (mininum 3) and chooses a random input to return. The formatting of the documentation is shown there (as well as in the link above).

import numpy as np 

def random_return(a,b,c,*args):
    '''
    A function which requires three inputs which are floats, accepts any number of additional inputs (of any kind), and returns one randomly chosen input. 
    
    Parameters
    ----------
    a: int
        description of this integer input
    b: int
        description of this integer input
    c: int
        description of this integer input
    *args: tuple
        any additional arguments get stored here
    
    Returns
    -------
    choice 
        The randomly selected input (type not specified)
    '''
    full_input_list = [a,b,c] + list(args)
    choice = np.random.choice(full_input_list)
    return choice
random_return(1,5,4,6,4,21,6)
5

When our function has been imported in some code, we can use the help command to see the documentation at any time:

help(random_return)
Help on function random_return in module __main__:

random_return(a, b, c, *args)
    A function which requires three inputs which are floats, accepts any number of additional inputs (of any kind), and returns one randomly chosen input. 
    
    Parameters
    ----------
    a: int
        description of this integer input
    b: int
        description of this integer input
    c: int
        description of this integer input
    *args: tuple
        any additional arguments get stored here
    
    Returns
    -------
    choice 
        The randomly selected input (type not specified)

You will also notice my use of *args in this function. This allows me to enter additional function arguments. Similar is **kwargs, which allows additional arguments tied to an input keyword. The former gets stored in a tuple, while the latter gets stored in a dictionary. We’ll be using these a lot in class — you can refresh or learn the basics in Section 2.8 of the chapter on functional programming here.

So as a brief overview of documentation, it contains

  1. A brief summary of the function

  2. The word Parameters with the next line having underlines of the same length

  3. arguments, which are followed by a colon and the data type(s). On the next line, indented, descriptions of the arguments

  4. The word Returns, with the same underline scheme

  5. The returned objects, labeled the same way as the input.

Also above, we saw how to format when no data type is specified. There, additional inputs could’ve been any data type, so we can’t be sure what the output will be. The main thing we didn’t cover is optional arguments. Those are set like this:

a: int, optional
    Description of the thing. (default 5)

So we mark it as optional, and then give the default for it.

With that, you’re ready to write and document your code below. All functions you write in this class should have documentation.

# Solution 
from astropy.io import fits 
import os
def load_fits(fpath,extension=0):
    '''
    Function to load a FITS file into python
    
    Parameters
    ----------
    fpath: str
        path to the FITS file to load. must end in .fit/.fits/.FIT/.FITS
    extension: int (optional)
        extension of the FITS file to load. (default 0)
        
    Returns
    -------
    header: dict_like
        the read in header of the chosen extension, as a python dictionary
    data: array_like
        the data contained in the extension, whether an image or table.
    '''
    with fits.open(fpath) as hdu:
        header = hdu[extension].header
        data = hdu[extension].data
    return header, data
            

Problem 2: Data I/O

Problem 2.1

Using the function you created above, read in the header and data of the file antenna_Rband.fits which came with the lab assignment.

Note

While this may seem small, the fact that your read-in is now one line instead of ~5 does improve your efficiency! But only if you use your function enough times to overcome the initial time spent writing it…

We also have the flexibility to take our function and give it more features over time, which we will do in this class.

# Solution
head,data = load_fits('antenna_Rband.fits')

Problem 2.2

Next, we neex to plot the image data. There are several operations that we almost always perform when plotting astronomical data, as well as several user-preferences for how we “by default” plot images before we begin tweaking things. If you spend any time as an astronomer, you will plot quite literally thousands of images — why set all these settings every time, when we can write a handy function to do it for us?

Write a function which takes in as input arguments

  • an image (2D array or masked array)

as well as the following optional arguments (so set a default)

  • figsize (default (15,13) )

  • cmap (default ‘gray_r’)

  • scale (default 0.5)

  • **kwargs (see here)

Inside the function, create figure and axes objects using plt.subplots(). When working in notebooks, it is often useful to set the figsize argument of subplots to a nice large-ish value, such as (15,13), which will make the image fill most of the notebook. Since your function has set figsize as an argument, you can feed figsize directly into the subplots call, so that a user of the function can leave the default or set their own.

Next, use ax.imshow() to actually plot the image. You’ll want to save the output of this, e.g., im = ax.imshow(...). In this plotting call, set imshow’s argument origin='lower'. We always want to do this when dealing with imaging, as we want (0,0) to be a coordinate.

Note

By default, matplotlib uses a “matrix-style” plotting, where 0 of the y axis is in the top left corner, and 0 of the x axis is in the bottom left corner.

Also within the call to imshow(), feed in the cmap from your function (i.e., cmap=cmap). The other critical imshow() arguments are vmin and vmax, which sets the saturation points (and thus the contrast) of the image.

We haven’t set vmin and vmax as arguments of our outer function, but because of kwargs, we can still create a default here that can be overriden from outside.

As a default, within your function, calculate the mean and standard deviation of the image. Set some temporary variables with the quantities mu - scale*sigma and mu + scale*sigma (where here mu is the calculated mean and sigma is the calculated std dev, and scale was the optional input). Next, check the kwargs dictionary (which will exist in your function because we added the packing argument **kwargs to our function. IF vmin and vmax are in this dictionary, plug those into your imshow command. Otherwise, use the values determined by the calculation above. Bonus point for accomodating either no vmin/vmax entered, just vmin or vmax, or both (using the calculated values when things aren’t provided).

Your function should return the created fig and ax objects so the user can continue to tweak them.

Run your function and test its outputs. Once you’re satisfied it’s working, use it to plot the provided data. Find either a vmin/vmax pair, or a choice of scale which makes the image look pretty!

import matplotlib.pyplot as plt
import numpy as np

#Solution 

def implot(image,figsize=(15,13),cmap='gray_r',scale=0.5,**kwargs):
    '''
    Plot an astronomical image, setting default options and easy tweaking of parameters
    
    Parameters
    ----------
    image: array_like
        2D array containing an astronomical image to be plotted. Cutouts should be input as cutout.data.
    figsize: tuple, optional
        figure size to use. Default: (15,13)
    cmap: str, optional
        Colormap to use for the image. Default: 'gray_r'
    scale: float, optional
        By default, function will scale image to some number of standard deviations about the mean pixel value. Scale sets this number (or fraction). Default: 0.5.
    **kwargs
        Additional arguments are passed to matplotlib plotting commands. Currently supported: vmin, vmax.
        
    Returns
    -------
    fig, ax
        figure and axes objects containing currently plotted data.
    '''
    fig, ax = plt.subplots(figsize=figsize)
    mu = np.mean(image)
    s = np.std(image)
    dvmin = mu - scale*s
    dvmax = mu + scale*s
    if all(['vmin','vmax']) in kwargs.keys():
        im = ax.imshow(image,origin='lower',cmap=cmap,vmin=kwargs['vmin'],vmax=kwargs['vmax'])
    elif 'vmin' in kwargs.keys():
        im = ax.imshow(image,origin='lower',cmap=cmap,vmin=kwargs['vmin'],vmax=dvmax)
    elif 'vmax' in kwargs.keys():
        im = ax.imshow(image,origin='lower',cmap=cmap,vmin=dvmin,vmax=kwargs['vmax'])
    else:
        im = ax.imshow(image,origin='lower',cmap=cmap,vmin=dvmin,vmax=dvmax)
    
    return fig, ax
fig, ax = implot(data,scale=2.5,vmin=10,vmax=100)
../_images/Lab2_solutions_12_0.png

Problem 2.3

Copy your function down, we’re going to keep messing with it.

So far, we’ve made it so that with a simple function call and, potentially, with just the input of an image array, we get out a nice plot with a scaling, colormap, and origin selection. In this section, we are going to allow (optionally) for a colorbar to be added to the figure. We’re also going to add in the ability for the figure to be plotted in celestial coordinates (i.e., RA and DEC) instead of pixel units, if information about the image (via the world coordinate system, WCS) exists in the image header.

Note

Generally, WCS information is present in the headers of published images like the one here, but not present in raw data gathered at the telescope. This is because images need to undergo a process known as plate solving to determine both the direction (coordinates) toward the image, as well as the pixel scale (i.e., how many arcseconds per pixel in the image).

Add three new optional arguments to your function.

  • colorbar = False

  • header = None

  • wcs = None

Let’s start with the colorbar. At the end of your plotting commands, check if colorbar=True, and if so, create a colorbar via plt.colorbar(), setting the mappable argument to whatever you saved the output of ax.imshow() into above. Also set the ax argument to be your ax; this will tell matplotlib to steal a bit of space from that axis to make room for the colorbar.

def implot(image,figsize=(15,13),cmap='gray_r',scale=0.5,colorbar=False,header=None,wcs=None,**kwargs):
    '''
    Plot an astronomical image, setting default options and easy tweaking of parameters
    
    Parameters
    ----------
    image: array_like
        2D array containing an astronomical image to be plotted. Cutouts should be input as cutout.data.
    figsize: tuple, optional
        figure size to use. Default: (15,13)
    cmap: str, optional
        Colormap to use for the image. Default: 'gray_r'
    scale: float, optional
        By default, function will scale image to some number of standard deviations about the mean pixel value. Scale sets this number (or fraction). Default: 0.5.
    colorbar: bool, optional
        Whether to add a colorbar or not. Default: False
    header: dict, optional
        If input, function will attempt to create a WCS object from header and plot in celestial coordinates. Default: None
    wcs: WCS object
        If input, the function will plot using a projection set by the WCS. Default: None
    **kwargs
        Additional arguments are passed to matplotlib plotting commands. Currently supported: vmin, vmax.
        
    Returns
    -------
    fig, ax
        figure and axes objects containing currently plotted data.
    '''
    fig, ax = plt.subplots(figsize=figsize)
    mu = np.mean(image)
    s = np.std(image)
    dvmin = mu - scale*s
    dvmax = mu + scale*s
    if all(['vmin','vmax']) in kwargs.keys():
        im = ax.imshow(image,origin='lower',cmap=cmap,vmin=kwargs['vmin'],vmax=kwargs['vmax'])
    elif 'vmin' in kwargs.keys():
        im = ax.imshow(image,origin='lower',cmap=cmap,vmin=kwargs['vmin'],vmax=dvmax)
    elif 'vmax' in kwargs.keys():
        im = ax.imshow(image,origin='lower',cmap=cmap,vmin=dvmin,vmax=kwargs['vmax'])
    else:
        im = ax.imshow(image,origin='lower',cmap=cmap,vmin=dvmin,vmax=dvmax)
    if colorbar:
        cbar = plt.colorbar(im,ax=ax)
    return fig, ax
fig, ax = implot(data,scale=2.5,vmin=10,vmax=100,colorbar=True)
../_images/Lab2_solutions_15_0.png

Tip

You’ll notice in my solution, the colorbar is matching the figure height, rather than the axis height. If that bugs you like it bugs me, check out this solution from StackOverflow, which you can adapt within your function to make the cbar match the axis height.

In order to plot in RA and DEC coordinates, we need to first have an astropy WCS object associated with the image in question. You can import WCS from astropy.wcs. WCS objects are created from the headers of plate-solved fits files. In our function, we allow the user to input either a header or a WCS object directly.

Within your function, check if WCS is input – if it is, we’re good to go and can safely ignore header (even if it is provided). If instead only header is provided, use the WCS() function to create a new wcs object from that header.

Tip

You’ll want to do this at the very top of your function.

We now need to move our fig, ax = .... creation line into an if-statement. If we are using WCS “stuff”, you’ll need to set a projection for your plot that uses the wcs. This is accomplished as follows:

fig, ax = plt.subplots(...,subplot_kw={'projection':wcs}) 

where wcs is whatever you’ve named the output of WCS(header) or is the WCS input directly into the function.

from astropy.wcs import WCS

def implot(image,figsize=(15,13),cmap='gray_r',scale=0.5,colorbar=False,header=None,wcs=None,**kwargs):
    '''
    Plot an astronomical image, setting default options and easy tweaking of parameters
    
    Parameters
    ----------
    image: array_like
        2D array containing an astronomical image to be plotted. Cutouts should be input as cutout.data.
    figsize: tuple, optional
        figure size to use. Default: (15,13)
    cmap: str, optional
        Colormap to use for the image. Default: 'gray_r'
    scale: float, optional
        By default, function will scale image to some number of standard deviations about the mean pixel value. Scale sets this number (or fraction). Default: 0.5.
    colorbar: bool, optional
        Whether to add a colorbar or not. Default: False
    header: dict, optional
        If input, function will attempt to create a WCS object from header and plot in celestial coordinates. Default: None
    wcs: WCS object
        If input, the function will plot using a projection set by the WCS. Default: None
    **kwargs
        Additional arguments are passed to matplotlib plotting commands. Currently supported: vmin, vmax.
        
    Returns
    -------
    fig, ax
        figure and axes objects containing currently plotted data.
    '''
    if (header==None) and (wcs==None):
        fig, ax = plt.subplots(figsize=figsize)
    elif wcs is not None:
        fig, ax = plt.subplots(figsize=figsize,subplot_kw={'projection':wcs})
        ax.set_xlabel('Right Ascension [hms]',fontsize=15)
        ax.set_ylabel('Declination [degrees]',fontsize=15)
        ax.coords.grid(color='gray', alpha=0.5, linestyle='solid')
    elif header is not None:
        for i in header.keys():
            if i.startswith('A_'):
                del header[i]
            elif i.startswith('B_'):
                del header[i]
        header = strip_SIP(header)
        wcs = WCS(header)
        fig, ax = plt.subplots(figsize=figsize,subplot_kw={'projection':wcs})
        ax.set_xlabel('Right Ascension [hms]',fontsize=15)
        ax.set_ylabel('Declination [degrees]',fontsize=15)
        ax.coords.grid(color='gray', alpha=0.5, linestyle='solid')
    mu = np.mean(image)
    s = np.std(image)
    dvmin = mu - scale*s
    dvmax = mu + scale*s
    if all(['vmin','vmax']) in kwargs.keys():
        im = ax.imshow(image,origin='lower',cmap=cmap,vmin=kwargs['vmin'],vmax=kwargs['vmax'])
    elif 'vmin' in kwargs.keys():
        im = ax.imshow(image,origin='lower',cmap=cmap,vmin=kwargs['vmin'],vmax=dvmax)
    elif 'vmax' in kwargs.keys():
        im = ax.imshow(image,origin='lower',cmap=cmap,vmin=dvmin,vmax=kwargs['vmax'])
    else:
        im = ax.imshow(image,origin='lower',cmap=cmap,vmin=dvmin,vmax=dvmax)
    if colorbar:
        cbar = plt.colorbar(im,ax=ax)
    ax.tick_params(direction='in',length=9,width=1.5,labelsize=15)
    
    return fig, ax

Warning

In this case, we will get an error from our function that happens because of some distortion coefficient nonsense between astropy and drizzled HST images. Since it’s not pertinent to our lab, I’m providing below a snippet of code you should use to fix your header before running WCS(header).

def strip_SIP(header):
    A_prefixes = [i for i in header.keys() if i.startswith('A_')]
    B_prefixes = [i for i in header.keys() if i.startswith('B_')]
    for a,b in zip(A_prefixes,B_prefixes):
        del header[a]
        del header[b]
    return header
fig, ax = implot(data,scale=2.5,header=head,vmin=10)
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 53207.000000 from DATE-OBS'. [astropy.wcs.wcs]
../_images/Lab2_solutions_20_1.png

If this worked correctly, when you add the header you read in from our image, you should now see the axes of your plot change from pixels to pos.eq.ra and pos.eq.dec. We’re now looking at actual on-sky coordinates! Yay!

Within the if-blocks of your function that sets the ax to be a wcs projection, set the \(x\) and \(y\) labels to read “Right Ascension [hms]” and “Declination [degrees]” in fontsize 15.

Lastly, to polish things off, use ax.tick_params() to set inward, larger ticks, and increase the axis tick label size to 15.

Note

You’ll notice (especially with larger ticks) that the are not perpendicular to the axes spines. This is because this particular image has been rotated with respect to the standard celestial coordinate axes. This can be seen more clearly if you add the following to your function: ax.coords.grid(color='gray', alpha=0.5, linestyle='solid'). Try doing that, adding an optional keyword to your function called ‘grid’ and enabling this command if it is set.

Tip

To check if a condition is true (e.g, if condition == True:), you can just type if condition:

It’s taken us some time, but this image could now be placed in a scientific publication. And, since we have a handy function for it, we can make images that look this nice on the fly with a short one line command, yet still have a lot of flexibility over many important inputs. And of course, the figure and axes objects are returned, so one could run this function and then continue tweaking the plot after the fact.

Note

As a final note, I want to draw your attention to the fact that once you use the wcs projection on some plot, it’s no longer a normal ax object, it’s a wcsax object. This makes changing certain elements of those axes a little more involved than for a standard one. I use this page and the others linked there when doing so.

Problem 3: Cutouts and Aperture Photometry

When working with astronomical images, like the one we’ve been using in this lab, it is often advantageous to be working with a cutout – a chunk of the image centered on a certain coordinate, and of a certain size. For example, if there is an HII region or star cluster of interest in the above image, we may like to zoom in on it to examine it more closely.

Now that we’ve switched over to using celestial coordinates instead of pixels as our projection frame, zooming in on a region is not as simple as slicing our array, e.g., image[500:700,200:550]. On the plus side, the framework we’ll learn here is very robust, and will allow for all sorts of useful measurements.

To make a cutout, we’ll need the Cutout2D module within astropy, which I’ll import below. To provide the position of the cutout, we need to be able to feed in astronomical coordinates. For this, we’ll use SkyCoord, a module in astropy.coordinates. Finally, we’ll need to integrate the units module in astropy to successfully create coordinate objects.

from astropy.nddata import Cutout2D
from astropy.coordinates import SkyCoord
import astropy.units as u

Let’s start with a SkyCoord object. There are several coordinate systems used in astronomy, e.g., Galactic coordinates (\(b\), \(l\)), Equatorial (\(RA\), \(DEC\)). The most common (especially in any extragalactic setting) is RA and DEC (as you can see in the image you’ve plotted already).

The documentation for SkyCoord is solid, and worth reading.

The general way we create these objects is, e.g.,

coord = SkyCoord('12:01:53.6 -18:53:11',unit=(u.hourangle,u.deg))

Tip

You can input various types of strings and formattings for the coordinates, just be sure to specify the units as shown above. The documentation shows the various methods.

Problem 3.1

In this case, the coordinates I set above are for NGC 4039, which is the smaller of the two galaxies in the image we’re using.

Note

If at any point you’re trying to make a coordinate object for a well known galaxy/object, try, e.g., coord = SkyCoord.from_name('NGC 4038'), and ususally that will work!

In the cell below, use the coordinate we’ve created, plus a size (use 1x1 arcminutes), and the wcs object for our image, to create a Cutout2D object.

#cutout = # your code here
# Solution

cutout = Cutout2D(data,coord,size=(1*u.arcmin,1*u.arcmin),wcs=WCS(head))
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 53207.000000 from DATE-OBS'. [astropy.wcs.wcs]

Now, use your fancy new function to plot the new cutout.

Note

Cutout objects contain the image and their own wcs, accessible via, e.g., cutout.data and cutout.wcs.

implot(cutout.data,scale=2,vmin=10,wcs=cutout.wcs)
(<Figure size 1080x936 with 1 Axes>,
 <WCSAxesSubplot:xlabel='Right Ascension [hms]', ylabel='Declination [degrees]'>)
../_images/Lab2_solutions_30_1.png

Problem 3.2

We’re now going to do some aperture photometry.

Definition

Aperture Photometry is the process of defining a region on an image (generally, but not always circular), and measuring the sum pixel value within that region. The region is known as an aperture, and the “collapsing” of the 2D spatial information about counts in each pixel into a single number is known as photometry.

Below, I provide a new coordinate, this time centered on the region between the two galaxies. Make a new cutout of that region (again, 1x1 arcmin), and plot it.

new_coord = SkyCoord('12:01:55.0 -18:52:45',unit=(u.hourangle,u.deg))
#Solution
new_cutout = Cutout2D(data,new_coord,size=(1*u.arcmin,1*u.arcmin),wcs=WCS(head))
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 53207.000000 from DATE-OBS'. [astropy.wcs.wcs]
implot(new_cutout.data,scale=2,vmin=10,wcs=new_cutout.wcs)
(<Figure size 1080x936 with 1 Axes>,
 <WCSAxesSubplot:xlabel='Right Ascension [hms]', ylabel='Declination [degrees]'>)
../_images/Lab2_solutions_34_1.png

In this region, there are a lot of blobby looking roughly circular sources — Some of the larger ones are HII star forming regions, the smaller ones are likely stars.

Often, for calibration purposes, we’d need to create apertures around all those sources in the image. We definitely don’t want to do that by hand! Instead, we’re going to use the sep package.

Note

Simply pip install sep inside your a330 environment terminal to get it installed, you should then be able to import it.

import sep

There are three steps to performing aperture photometry with sep, which are detailed in it’s documentation.

  • Estimate the background

  • Find Sources

  • Perform Aperture Photometry

Using the instructions presented in the documentation linked, measure the background of the cutout image, and run the source extractor on it. Don’t forget to subtract the background before running the extractor!

To do this, write a function that takes as input the data (in this case, a cutout.data object and a threshold scale (to be multiplied by the globalrms, and performs these steps, returning the objects array. Don’t forget to document it!

Warning

When I ran this, I got an error that my “array was not C-contiguous.” I found the solution to this issue in this stackoverflow post. Loosely, sep uses C-bindings to actually run the heavy lifting code in C rather than Python (it’s faster). This means input arrays must be arranged in physical memory the way C is used to. This particular array was not, but it is easy to order it this way.

# Solution

def run_sep(data,thresh_scale=2.0):
    '''
    Sep wrapper... runs a basic set of sep commands on a given image
    
    Parameters
    ----------
    data: array_like
        the input image
    thresh_scale: float
        a scaling parameter used by sep to determine when to call something a source (see sep documentation)
        
    Returns
    -------
    objects: numpy struct array
        numpy structured array containing the sep-extracted object locations, etc. 
    '''
    data_corder = data.copy(order='C')
    bkg = sep.Background(data_corder)
    back = bkg.back()
    data_bg_sub = data - back
    c_copy = data_bg_sub.copy(order="C")

    thresh = thresh_scale * bkg.globalrms
    objects = sep.extract(c_copy, thresh)
    return objects

Run your function and store the output in a variable called objects. You should now have an object array containing many detected sources.

objects = run_sep(new_cutout.data)
len(objects)
<ipython-input-31-36a3a31cfa1e>:26: DeprecationWarning: `np.int` is a deprecated alias for the builtin `int`. To silence this warning, use `int` by itself. Doing this will not modify any behavior and is safe. When replacing `np.int`, you may wish to use e.g. `np.int64` or `np.int32` to specify the precision. If you wish to review your current use, check the release note link for additional information.
Deprecated in NumPy 1.20; for more details and guidance: https://numpy.org/devdocs/release/1.20.0-notes.html#deprecations
  objects = sep.extract(c_copy, thresh)
103

The positions of the determined sources are stored in the output structured array and can be indexed with, e.g., objects['x'] and objects['y']. Replot your image of the cutout, but now circle the positions of all your detected sources. Do they line up with actual point sources in the image?

fig, ax = implot(new_cutout.data,scale=2,vmin=10,wcs=new_cutout.wcs)
ax.plot(objects['x'],objects['y'],'o',ms=15,color='None',mec='r')
[<matplotlib.lines.Line2D at 0x7fb00834b160>]
../_images/Lab2_solutions_42_1.png

It looks like we’ve done a pretty adequate job finding the sources in this field – there’a a few that got missed, and a few we might want to remove, but overall, this is pretty solid.

We need to start working with these objects we’ve found. While sep can perform aperture photometry itself (reading the docs, you can see it is simple to feed in the objects and a pixel radius), we’re going to be a bit more careful about things. To better visualize and work with this data, I’d like it to be a pandas DataFrame. We’re going to be using those a lot in this course. Converting a numpy structured array to a dataframe is simple: I provide the code below. Run it, and then simply type df in a new cell to see a nicely formatted pandas table of our objects.

import pandas as pd
df = pd.DataFrame(objects)
df
thresh npix tnpix xmin xmax ymin ymax x y x2 ... cxy cflux flux cpeak peak xcpeak ycpeak xpeak ypeak flag
0 13.807426 19 15 291 298 0 2 294.331652 0.748626 3.010990 ... 0.528804 612.740173 767.704102 55.058762 99.534592 294 0 294 0 2
1 13.807426 7 4 108 110 5 7 109.067891 6.332346 0.508919 ... -0.177127 143.963638 178.128448 28.999582 66.128693 109 6 109 6 0
2 13.807426 222 214 0 22 0 16 8.012520 5.514646 32.370749 ... 0.033329 3656.097412 3757.238525 21.895723 24.796696 1 1 1 6 2
3 13.807426 10 4 180 183 27 29 181.260591 28.091515 0.624076 ... 0.004285 455.730774 508.251770 93.388168 261.102325 181 28 181 28 0
4 13.807426 12 9 240 243 30 33 241.296235 31.149263 0.837190 ... 0.627759 476.430389 603.428528 72.289940 157.386230 241 31 242 31 0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
98 13.807426 54 51 185 193 226 233 189.610462 229.462692 1.588913 ... 0.061697 4849.710938 4877.654785 626.254272 1383.217651 190 229 190 229 1
99 13.807426 670 592 152 187 212 250 169.199212 228.711439 62.010877 ... 0.003127 10656.494141 10797.801758 21.452864 27.849247 171 228 171 228 1
100 13.807426 13 10 77 81 268 271 78.812474 269.285180 1.357104 ... 0.780199 233.011490 273.878479 23.973366 43.442318 80 269 80 268 1
101 13.807426 8 6 21 23 285 287 21.845574 286.156461 0.543330 ... -0.990262 153.411697 204.978119 25.171600 50.913998 22 286 22 287 0
102 13.807426 7 4 29 31 287 289 30.329319 287.833190 0.443130 ... -1.248477 125.931458 152.977509 23.354290 42.604931 31 288 31 288 0

103 rows × 30 columns

With sep providing the first pass, we can cull a few objets from our sample that very clearly are not star forming regions. Using your DataFrame, plot the flux of each detected object using the flux column. You should have at least a few that are huge outliers, with dramatically more flux the rest.

plt.plot(df.flux,'.')
[<matplotlib.lines.Line2D at 0x7fafe87023a0>]
../_images/Lab2_solutions_47_1.png

Write a function called remove_outliers that reads in a dataframe and a flux-min and flux-max. It should filter the dataframe to only include fluxes between the input values, and return the new dataframe. Then use this function on your data, choosing an appropriate cutoff.

# Solution 

def remove_outliers(df,flux_min,flux_max):
    return df.loc[(df['flux']>flux_min)&(df['flux']<flux_max)]
df2 = remove_outliers(df,0,10000)
plt.plot(df2.flux,'.')
[<matplotlib.lines.Line2D at 0x7fb02abd3af0>]
../_images/Lab2_solutions_51_1.png
df2
thresh npix tnpix xmin xmax ymin ymax x y x2 ... cxy cflux flux cpeak peak xcpeak ycpeak xpeak ypeak flag
0 13.807426 19 15 291 298 0 2 294.331652 0.748626 3.010990 ... 0.528804 612.740173 767.704102 55.058762 99.534592 294 0 294 0 2
1 13.807426 7 4 108 110 5 7 109.067891 6.332346 0.508919 ... -0.177127 143.963638 178.128448 28.999582 66.128693 109 6 109 6 0
2 13.807426 222 214 0 22 0 16 8.012520 5.514646 32.370749 ... 0.033329 3656.097412 3757.238525 21.895723 24.796696 1 1 1 6 2
3 13.807426 10 4 180 183 27 29 181.260591 28.091515 0.624076 ... 0.004285 455.730774 508.251770 93.388168 261.102325 181 28 181 28 0
4 13.807426 12 9 240 243 30 33 241.296235 31.149263 0.837190 ... 0.627759 476.430389 603.428528 72.289940 157.386230 241 31 242 31 0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
97 13.807426 20 16 16 20 230 234 17.861561 232.250039 0.998464 ... 0.087940 1223.592896 1285.245728 181.206024 462.152161 18 232 18 232 0
98 13.807426 54 51 185 193 226 233 189.610462 229.462692 1.588913 ... 0.061697 4849.710938 4877.654785 626.254272 1383.217651 190 229 190 229 1
100 13.807426 13 10 77 81 268 271 78.812474 269.285180 1.357104 ... 0.780199 233.011490 273.878479 23.973366 43.442318 80 269 80 268 1
101 13.807426 8 6 21 23 285 287 21.845574 286.156461 0.543330 ... -0.990262 153.411697 204.978119 25.171600 50.913998 22 286 22 287 0
102 13.807426 7 4 29 31 287 289 30.329319 287.833190 0.443130 ... -1.248477 125.931458 152.977509 23.354290 42.604931 31 288 31 288 0

93 rows × 30 columns

Re-plot the set of sources you have now, over the data.

fig, ax = implot(new_cutout.data,scale=2,vmin=10,wcs=new_cutout.wcs)
ax.plot(df2['x'],df2['y'],'o',ms=15,color='None',mec='r')
[<matplotlib.lines.Line2D at 0x7faff01129a0>]
../_images/Lab2_solutions_54_1.png

You should see that some of the circles which were over bright parts of the galaxy are no longer here.

Note

You may find that there are some visible sources which, despite tinkering, don’t get caught by sep. That’s okay – if we really wanted them, we could just go in and put down apertures by hand. Generally when performing a step like this in the field, we have a pre-determined set of points to use, because we have measured fluxes for them, and wish to flux calibrate our data.

Problem 4: Continuum Subtraction

At this point, we have the tools necessary to make flux measurements in apertures (at least, via sep – we will also learn how to do this with the photutils package). However, \(R\) band photometry of HII regions is not exceedingly interesting. More interesting is the flux in \(H\alpha\), an emission line caused by Hydrogen recombination. This line (at 6563 Angstrom) is located within the \(R\) band, and is imaged using a narrow filter compared ot \(R\).

The flux measured by an \(H\alpha\) filter contains both the flux from the emission line as well as the underlying continuum — essentially, the starlight from the galaxy. If we can get a clean measure of the flux in \(H\alpha\) (sans this continuum), we can make a direct estimate of the star formation rate of the system.

Problem 4.1

To do this, we need to access an \(H\alpha\) image, and then subtract off the continuum present (which we’ll infer from our \(R\) band image). Located in the lab directory is a file called antenna_Haband.fits. Use your fits loader function from above to read in this new image, create an equivalent sized and centered cutout to the \(R\) band data, and plot it, with the same sources found in the \(R\) band circled.

# Solution 

head2, data2 = load_fits('antenna_Haband.fits')
head2 = strip_SIP(head2)
cutout_ha = Cutout2D(data2,new_coord,size=(1*u.arcmin,1*u.arcmin),wcs=WCS(head2))
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 53207.000000 from DATE-OBS'. [astropy.wcs.wcs]
fig, ax = implot(cutout_ha.data,scale=0.7,vmin=1,wcs=cutout_ha.wcs)
ax.plot(df2['x'],df2['y'],'o',ms=15,color='None',mec='r')
[<matplotlib.lines.Line2D at 0x7fb02011d040>]
../_images/Lab2_solutions_57_1.png

There’s a few interesting things to note right away here. Looking just north of the center of the image, there’s now a large amount of flux in \(H\alpha\) coming from some blobs which do not appear in the \(R\) band. This is likely due to the fact that by zero-ing in on the ionized gas, we can see the large envelopes of gas being lit up by the star formation in this region.

Note

The active merger scenario between these two galaxies is triggering a lot of star formation.

Problem 4.2

To get a better idea of how the \(H\alpha\) flux compares to the \(R\) band distribution, use the plt.contour() tool to measure contours of \(H\alpha\), drawing them over the \(H\alpha\) image and tweaking the levels until you think you are well tracing the distribution. Then, plot those contours over the \(R\) band data instead.

Hint

It is often beneficial to use np.logspace() when defining contour levels, as it allows you to cover large dynamic range with fewer contours. You may also find it helpful to set your contour alpha to something < 1, to better see both the image underneath and the contours.

# Solution 

fig, ax = implot(cutout_ha.data,scale=0.7,wcs=cutout_ha.wcs)
contours = ax.contour(cutout_ha.data,levels=np.logspace(1.8,4,20),colors='r',alpha=0.5)
../_images/Lab2_solutions_59_0.png

To help guide you, I’ve shown what I got for my plot below — you need not emulate it exactly. The blue box shown in the image will be useful to you in the next part of the problem.

fig, ax = implot(new_cutout.data,scale=2,vmin=10,wcs=new_cutout.wcs)
ax.contour(cutout_ha.data,levels=np.logspace(1.8,4,10),colors='r',alpha=0.5,transform=ax.get_transform(cutout_ha.wcs))
patch_cent = SkyCoord('12:01:53.7 -18:52:52',unit=(u.hourangle,u.deg))
patch = SkyRectangularAperture(patch_cent,0.27*u.arcmin,0.2*u.arcmin)
patch.to_pixel(wcs=new_cutout.wcs).plot(lw=3,color='C0');
../_images/Lab2_solutions_61_0.png

We can now see things a lot more clearly! It is obvious that the \(H\alpha\) contours trace the sources we were seeing in the \(R\) band, but that the gas is more extended around these clusters of sources, as we might expect.

Problem 4.3

We must now attempt a continuum subtraction of the data. We can’t simply subtract the \(R\) band from the \(H\alpha\) band, because the \(R\) band filter is much wider, and thus for similar exposure times, collects many more photons than the narrower \(H\alpha\) filter. Ideally, one could use a set of foreground stars (which are pure continuum sources in both images) to measure fluxes in both, and find a scaling constant. Here, all of the sources we see in this image are most likely actually HII regions. This means if we use them to scale between our images, we will likely oversubtract true flux.

Instead, what I’m going to do here (which may be slightly sketchy), is pick a “blank” region of continuum emission from the \(R\) band which has no \(H\alpha\) contours, and assert that this patch must have the same flux in both images. In the picture above, I indicated a blue rectangular patch on the sky. This is what we’ll be using to measure our continuum-to-narrowband ratio.

To make this patch, we’re going to use SkyRectangularAperture, from photutils. You can pip install photutils in your a330 environment if you don’t have it. Then do the following imports:

from photutils.aperture import SkyRectangularAperture
from photutils.aperture import aperture_photometry

As shown in it’s documentation, we need to feed in a SkyCoord position, as well as a width and a height. I’ve provided the new coordinate to use below. Create a rectangle of your own, measuring \(0.27''\) by \(0.2''\). You can now use the aperture_photometry() function to measure the flux in your aperture, applied to a certain image. Using the documentation as needed, find the flux in this box for both the \(R\) band and \(H\alpha\) band data, and then determine the ratio between those values. Don’t forget about the wcs!

# Solution
patch_cent = SkyCoord('12:01:53.7 -18:52:52',unit=(u.hourangle,u.deg))
patch = SkyRectangularAperture(patch_cent,0.27*u.arcmin,0.2*u.arcmin)
patch_R_flux_table = aperture_photometry(new_cutout.data,patch,wcs=new_cutout.wcs)
patch_Ha_flux_table = aperture_photometry(cutout_ha.data,patch,wcs=cutout_ha.wcs)

R_flux = patch_R_flux_table['aperture_sum'][0]
Ha_flux = patch_Ha_flux_table['aperture_sum'][0]
fluxratio = R_flux/Ha_flux
print(fluxratio)
1.5917633100928574

According to my calculation, the \(R\) band sky is about 1.6 times brighter in our patch than the \(H\alpha\). So we can now use this ratio to perform our subtraction.

Note

This result is a bit strange to me. The R band filter is quite a bit larger, so I would expect the ratio to be larger. But the images may have different exposure times or reductions, which may be why this has happened.

Problem 4.4

Now, fill in the function below, which should read in your rectangular patch, the \(R\) band cutout object, and the \(H\alpha\) cutout object. Within the function, copy in the code that determines the ratio, and then use the ratio you found to scale your \(R\) band image, then subtract it from the \(H\alpha\) image. The function should return this new image array.

Plot up your continuum subtracted image using your implot() function, adding back in the apertures we found earlier and the contours we made from the full \(H\alpha\) image.

# Solution 

def continuum_subtract(patch,Rcutout,Hacutout):
    '''
    Determines a continuum subtraction ratio between R band and Ha band image, then performs continuum subtraction
    
    Parameters
    ----------
    patch: SkyRectangularAperture object 
        A rectangular patch defined in sky coordinates
    Rcutout: Cutout2D object 
        Cutout2D object of the R band image
    Hacutout: Cutout2D object
        Cutout2D object of the Ha band image
    
    Returns
    -------
    sub_img: array_like
        continuum subtracted image
    '''
    patch_R_flux_table = aperture_photometry(Rcutout.data,patch,wcs=Rcutout.wcs) # flux table in rect R
    patch_Ha_flux_table = aperture_photometry(Hacutout.data,patch,wcs=Hacutout.wcs) # flux table in rect Ha
    R_flux = patch_R_flux_table['aperture_sum'][0] #extract flux
    Ha_flux = patch_Ha_flux_table['aperture_sum'][0] #extract flux
    fluxratio = R_flux/Ha_flux #determine ratio 
    sub_img = Hacutout.data - (Rcutout.data/fluxratio) #perform subtraction
    return sub_img

sub_data = continuum_subtract(patch,new_cutout,cutout_ha)

fig, ax = implot(sub_data,scale=0.9,wcs=new_cutout.wcs)
ax.contour(cutout_ha.data,levels=np.logspace(1.8,4,10),colors='r',alpha=0.5,transform=ax.get_transform(cutout_ha.wcs))

ax.plot(df2['x'],df2['y'],'o',ms=15,color='None',mec='w')
[<matplotlib.lines.Line2D at 0x7fb008a479a0>]
../_images/Lab2_solutions_67_1.png

What do you notice about the distribution of apertures with respect to the distribution of \(H\alpha\) gas? Is there a strong alignment of the \(R\) band sources and the gas? What does this imply about most of the \(R\) band sources?

Problem 4.5

Lastly, let’s load up the \(B\) band image. As you probably know, the \(B\) band traces bluer light, and thus will more preferentially see young, hot stars (whereas the \(R\) band traces the main sequence and turn off stellar distribution).

Load up the antenna_Bband.fits image, make a cutout, and plot it below. Make a new set of countours from your continuum subtracted \(H\alpha\) data, and overplot that onto the \(B\) band data. What do you see?

head3, data3 = load_fits('antenna_Bband.fits')
head3 = strip_SIP(head3)
cutout_B = Cutout2D(data3,new_coord,size=(1*u.arcmin,1*u.arcmin),wcs=WCS(head3))
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 53207.000000 from DATE-OBS'. [astropy.wcs.wcs]
fig, ax = implot(cutout_B.data,scale=1.5,vmin=10,wcs=cutout_B.wcs)
ax.contour(sub_data,levels=np.logspace(1.3,4,10),colors='r',alpha=0.5,transform=ax.get_transform(cutout_ha.wcs))
<matplotlib.contour.QuadContourSet at 0x7f8236659b80>
../_images/Lab2_solutions_70_1.png

In my own image, there are some clusters of \(B\)-band flux that align well with the \(H\alpha\) contours, and some \(B\) band sources out on their own. It is unsurprising that there is a correspondance between them; excess in \(B\) light implies the prescence of UV radiation as well, from young O/B stars. It is this radiation responsible for ionizing the gas that is shining in \(H\alpha\), so the \(H\alpha\)-emitting gas should be loosely clustered around sources bright in the \(B\) band (the experiment would be even cleaner if we used, say, GALEX FUV data).

Bonus Question (up to 3 points)

If you want to take your analysis farther, try measuring some fluxes off of your continuum subtracted \(H\alpha\) data, placing several manual apertures down over the regions of highest \(H\alpha\) concentration (which also align with \(B\) band concentrations).

The measures you get from this will be in counts on the detector. We need to convert these counts into flux units (e.g., erg s\(^{-1}\) cm\(^{-2}\)). To do this,

  • pull the ‘PHOTFLAM’ keyword from the header of your \(H\alpha\) data

  • also pull the ‘EXPTIME’ value from the header.

Start by taking your fluxes in counts, multiplying by the PHOTFLAM value, and dividing by the EXPTIME value. This puts you in erg s\(^{-1}\) cm\(^{-2}\) per Angstrom. Thus, what we have is technically a flux density. To convert to a true flux, we’ll simply integrate over the bandpass… but for this example, let’s assume a constant flux across the bandpass, which for the ACS 658N filter (\(H\alpha\)), is 136.27 angstroms.

Once you have fluxes, use the distance to NGC 4038/9 to determine the luminosity in erg/s of your collective set of sources, and then use the \(SFR(H\alpha)\) calibration of Kennicut & Bell to convert to an SFR. How does your answer compare to, e.g.,

  • The SFR of the Milky Way?

  • The SFR of the Orion Nebula?

  • The SFR of the Tarantula Nebula?