Lightcurves, transits, and TESS! Oh My!#

Step 0: imports (Read the comment below please. You’ll need to import a new package)#

# Run this once to install a python package for downloading lightcurves from the TESS mission (include the exclamation point)
# ! python -m pip install lightkurve --upgrade
# these should be familar
import scipy
import numpy as np
import pandas as pd

import matplotlib
import matplotlib.pyplot as plt

# this package will allow us to make progress bars
from tqdm import tqdm

# We can beautify our plots by changing the matpltlib setting a little
plt.rcParams['font.size'] = 18
matplotlib.rcParams['axes.linewidth'] = 2
matplotlib.rcParams['font.family'] = "serif"

Step 1: loading data from the TESS mission#

The function below uses the python package lightkurve to load data from the TESS mission. To search for exoplanets, TESS takes pictures of a patch of the sky once every 2 minutes. The function load_TESS_data takes in the name of a star and the size of image cutout we want to downdload (centered on the star).

# this function downloades TESS lightcurves (I recommend we supply this function
# and have the student's use it as a black box)

def load_TESS_data(target, cutout_size=20, verbose=False, exptime=None):
    """
    Download TESS images for a given target.

    inputs:
        target: str, same of star (ex. TOI-700)
        cutout_size: int, size of the returned cutout
                     (in units of pixels)
        verbose: bool, controls whether to include printouts
        exptime: str, determines if we use short or long cadence mode
                 (if None, defaults to first observing semester)
    outputs:
        time: numpy 1D array
        flux: numpy 3D array (time x cutout_size x cutout_size)
    """

    import lightkurve as lk

    # find all available data
    search_result = lk.search_tesscut(target)
    if exptime == 'short':
        search_result = search_result[search_result.exptime.value==search_result.exptime.value.min()]
    elif exptime == 'long':
        search_result = search_result[search_result.exptime.value==search_result.exptime.value.max()]
    print(search_result)

    # select the first available observing sector
    if exptime:
        search_result = search_result[search_result.exptime == exptime]
    search_result = search_result[search_result.mission == search_result.mission[0]]

    # download it
    tpf = search_result.download_all(cutout_size=cutout_size)[0]

    # convert it to a numpy array format
    flux = np.array([
        tpf.flux[i].value for i in range(len(tpf.flux)) if tpf.flux[i].value.min()>0
    ])
    flux_err = np.array([
        tpf.flux_err[i].value for i in range(len(tpf.flux_err)) if tpf.flux[i].value.min()>0
    ])

    time = np.array([
        tpf.time.value[i] for i in range(len(tpf.flux)) if tpf.flux[i].value.min()>0
                    ])

    return time, flux, flux_err

Exercise: Run the code below to load all the TESS data for one small patch of the sky. We’ll center our images on a single variable star.

target = 'Gaia_DR2279382060625871360'
cutout_size = 20 # dimensions of the image in pixels x pixels
time, flux, flux_err = load_TESS_data(target,verbose=True,cutout_size=cutout_size)
SearchResult containing 4 data products.

 #     mission     year  author exptime        target_name         distance
                                   s                                arcsec 
--- -------------- ---- ------- ------- -------------------------- --------
  0 TESS Sector 19 2019 TESScut    1426 Gaia_DR2279382060625871360      0.0
  1 TESS Sector 59 2022 TESScut     158 Gaia_DR2279382060625871360      0.0
  2 TESS Sector 73 2023 TESScut     158 Gaia_DR2279382060625871360      0.0
  3 TESS Sector 86 2024 TESScut     158 Gaia_DR2279382060625871360      0.0

Exercise: Run the code below to plot the mean of the TESS cutouts.

def plot_cutout(image, logscale = False, vrange = [1,99]):
    """
    Plot 2D image.

    inputs:
        image: 2D numpy array
        logscale: bool, if True plots np.log10(image)
        vrnge: list, sets range of colorbar
               (based on percentiles of 'image')
    """
    if logscale:
        image = np.log10(image)

    q = np.percentile(image, q = vrange)
    plt.imshow( image, vmin = q[0], vmax = q[1] )
    plt.show()
# linear scale
plot_cutout(np.mean(flux,axis=0))
../_images/a08e10ae72b81aadb29a63791107d6e4c9b2764bac0a07f971f4c987b1de68b3.png

Exercise: Now use the same function to plot the mean TESS cutout with logscale=True. How many stars to do see in our cutout? How might this make searching for planets complicated?

# answer here

Step 2: Make a lightcurve#

We want to measure the flux of the star in each image. We can then see how the star’s flux changes with time. This allows us to study binary stars, exoplanets, and stellar pulsations (like Earthquakes but on stars). Let’s start with the simplest option: taking the sum all pixels in image.

Exercise: Plot the total flux in each cutout as a function of time. Be sure to label your plot.

# answer here

We can see this area of the sky is populated by more than one star (this is especially clear when we plot in log scale). This is very common occurance.

Because the image includes more than one star, simply taking the sum of all pixels in the image will not give us a very accurate number for the flux of the star we’re intersted in. We also have to be careful about the background flux changing with time due to instrumental systematics.

A common method of overcoming these challenges is fitting the image with a two component model: background + star.

Since the star is being ‘blurred’ by the point spread function of TESS, we can reasonably model the star as a 2-dimensional Gaussian.

To keep things simple, let’s assume a uniform background.

Exercise: Using the function below, plot a 2-d gaussian with the plot_cutout function. Set mu=0, bkg=0, xmu=0, ymu=0. Since we are plotting a 2-d gaussian, we need to create a grid of x,y values. Use the code below, which turns 1-d arrays of x and y into a 2-d grid of x and y.

x = np.linspace(-5,5,50)
y = np.linspace(-5,5,50)
X, Y = np.meshgrid(x, y)
def psf(x,y,sigma,scale,bkg,xmu=0,ymu=0,unravel=False):
    """
    Symmetric 2D point-spread function (PSF)

    inputs:
        x: np.ndarray, where to evaluate the PSF
        y: np.ndarray, where to evaluate the PSF
        sigma: float, width of the PDF
        scale: float, integrated area under the PSF
        bkg: float, background flux level
        xmu: float, center of PSF in x
        ymu: float, center of PSF in y
    outputs:
        PSF evalauted at x and y
    """
    exponent = (np.power(x-xmu,2) + np.power(y-ymu,2) ) / sigma**2

    mod = scale * np.exp(-exponent) / (2*np.pi*sigma**2) + bkg
    if unravel:
        return mod.ravel()
    else:
        return mod
Z = psf(X,Y,sigma=1,scale=1,bkg=0,xmu=0,ymu=0)

plot_cutout(Z,logscale=False)
../_images/9236fa20e2a2f56e62c35394beac03f4a9906f788fd18adcb3c8540d3f84b33e.png

We now have the ingredients for our model. To fit our model we need to quantify ‘goodness of fit.’

Exercise: Run the code below to evaluate the residual between the first TESS cutout and our model.

def residual(theta, x, y, z, z_err):
    sigma, scale, bkg, xmu, ymu = theta

    model = psf(x,y,sigma=sigma,scale=scale,bkg=bkg,xmu=xmu,ymu=ymu)

    chi = np.power(model - z,2)/z_err**2

    return np.sum(chi)
# where to evaluate our PSF
x = np.arange(flux.shape[1])
y = np.arange(flux.shape[2])
X,Y = np.meshgrid(x,y)
# lets evaluate the model for a set of parameters
theta = [
    1,  # sigma
    200,  # scale
    0,  # bkg
    cutout_size/2, # xmu
    cutout_size/2, # ymu
]

residual(theta, X, Y, flux[0]/np.mean(flux), flux_err[0]/np.mean(flux))
np.float64(415386221.45279515)

Using our residual function, we can now ask: what parameters (like stellar flux) best fit the image? To give some intuition, let’s start with a simple grid search.

Exercise: For 0 < scale < 1000 calculate residual. Then plot scale vs. residual. Set sigma=1, bkg =1, xmu=cutout_size/2, ymu=cutout_size/2.

# answer here

Exercise: Based on the above plot, what scale gives the best fit?

answer here

Above, we found the best fit flux (what we call scale in our function) for a single TESS cutout. To make a lightcurve we need to repeat that process for each image.

Exercise: For each cutout, find the scale that minimizes residual (search for 0 < scale < 1000). Like above, set sigma=1, bkg =1, xmu=cutout_size/2, ymu=cutout_size/2. Plot the best fit scale vs. time to make our lightcurve. Plot our simple “summed” lightcurve for comparison.

# answer here

Exercise: What are the differences between our “grid-search” and “sum” light curves? What might be the reason(s)?

answer here

We’ve made a lightcurve! And we can clearly see by eye that the star is variable (it’s flux is changing with time). However, our approach is still a little too simple. While we are optimizing the scale parameter, we’ve left all the other model parameters fixed.

As we discussed in class, we can optimize all the parameters of model using scipy.optimize.minimize. This is much much faster than trying a grid search across all the parameters at once.

Exercise: Using scipy.optimize.minimize find the best-fit scale for each TESS cutout. Plot the resulting lightcurve alongside out “grid-search” and “sum” lightcurves.

You’ll want to try searching in these ranges: (.1,5) for sigma, (0,1000) for scale, (0,10) for bkg, (5,15) for xmu and ymu.

# answer here