Source code for corgidrp.klip_fm

import os
import warnings
from astropy.io import fits
import numpy as np
from corgidrp.data import PyKLIPDataset, Image 
from pyklip.parallelized import klip_dataset
from pyklip.fakes import gaussfit2d, inject_planet
from scipy.ndimage import shift, rotate
from scipy.stats import t as t_dist, norm
from corgidrp.astrom import get_polar_dist, seppa2dxdy, seppa2xy
from corgidrp.fluxcal import phot_by_gauss2d_fit
import corgidrp.check as check

[docs] def get_closest_psf(ct_calibration,cenx,ceny,dx,dy): """_summary_ NOTE: CT excam locations have (0,0) as the bottom left corner of the bottom left pixel TODO: Calculate subpixel shifts if star or PSF model aren't perfectly in the center of a pixel Args: ct_calibration (corgidrp.data.CoreThroughputCalibration): CT calibration object. cenx (float): x location of mask center, measured from the center of the bottom left pixel of the 1024x1024 pixel science array. ceny (float): y location of mask center, measured from the center of the bottom left pixel of the 1024x1024 pixel science array. dx (float): x separation from the mask center in pixels dy (float): y separation from the mask center in pixels Returns: np.array: 2D PSF model closest to the desired location. """ # Shift so (0,0) is the center of the bottom left pixel x_arr = ct_calibration.ct_excam[0] - 0.5 y_arr = ct_calibration.ct_excam[1] - 0.5 rel_x_arr = x_arr - cenx rel_y_arr = y_arr - ceny dists = np.sqrt((rel_x_arr-dx)**2 + (rel_y_arr-dy)**2) arg_closest = np.argmin(dists) return ct_calibration.data[arg_closest]
[docs] def inject_psf(frame_in, ct_calibration, amp, sep_pix,pa_deg): """Injects a fake psf from the CT calibration object into a corgidrp Image with the desired position and amplitude. Args: frame_in (corgidrp.data.Image): 2D image to inject a fake signal into. ct_calibration (corgidrp.data.CoreThroughputCalibration): CT calibration object containing PSF samples. amp (float): peak pixel amplitude of psf to inject. sep_pix (float): separation from star in pixels to inject pa_deg (float): position angle to inject (counterclockise from north/up) Returns: corgidrp.data.Image: a copy of the input Image but with a fake PSF injected. """ frame = frame_in.copy() # Get closest psf model pa_aper_deg = frame.pri_hdr['PA_APER'] rel_pa = pa_deg - pa_aper_deg dx,dy = seppa2dxdy(sep_pix,rel_pa) psf_model = get_closest_psf(ct_calibration, frame.ext_hdr['STARLOCX'], frame.ext_hdr['STARLOCY'], dx,dy).copy() # Scale counts peak_count = np.nanmax(psf_model) psf_model *= amp / peak_count # Assume PSF is centered in the data cutout for now model_shape = np.array(psf_model.shape) psf_cenyx_ind = (np.array(model_shape)/2 - 0.5).astype(int) psf_cenyx_inframe = np.array([frame.ext_hdr['STARLOCY'],frame.ext_hdr['STARLOCX']]) + np.array([dy,dx]) injected_psf_cenyx_ind = np.round(psf_cenyx_inframe).astype(int) startyx = injected_psf_cenyx_ind - psf_cenyx_ind endyx = startyx + model_shape inject_planet([frame.data], [[frame.ext_hdr['STARLOCX'], frame.ext_hdr['STARLOCY']]], [psf_model], [None], sep_pix, 0, thetas=[rel_pa + 90]) psf_cenxy = [psf_cenyx_inframe[1],psf_cenyx_inframe[0]] return frame, psf_model, psf_cenxy
[docs] def measure_noise(frame, seps_pix, hw, klmode_index=None, cand_locs = [], nsigma=1, fwhm=None, small_sample_correction=False): """Calculates the noise (standard deviation of counts) of an annulus at a given separation from the mask or star center, scaled to a given sigma level, with optional small sample statistics correction (Mawet et al. 2014). TODO: Mask known off-axis sources. Args: frame (corgidrp.Image): Image containing data as well as "STARLOCX/Y" in header seps_pix (np.array of float): Separations (in pixels from specified center) at which to calculate the noise level. hw (float): halfwidth of the annulus to use for noise calculation. klmode_index (int, optional): If provided, returns only the noise values for the KL mode with the given index. I.e. klmode_index=0 would return only the values for the first KL mode truncation choice. If None (by default), all indices are returned. cand_locs (list of tuples, optional): Locations of known off-axis sources, so we can mask them. This is a list of tuples (sep_pix,pa_degrees) for each source. Defaults to []. nsigma (float, optional): Sigma multiplier for the noise. E.g. nsigma=5 returns a 5-sigma noise curve. Defaults to 1. fwhm (float or array-like, optional): PSF FWHM in pixels, used for computing the number of resolution elements at each separation when small_sample_correction is True. A scalar is broadcast to all separations; an array must match the length of seps_pix. Required when small_sample_correction is True. Defaults to None. small_sample_correction (bool, optional): If True, apply the small sample statistics correction from Mawet et al. (2014) using the Student's t-distribution. This accounts for the limited number of independent resolution elements at small separations. Defaults to False. Returns: np.array: array of shape (number of separations, number of KL modes) containing the annular noise. If klmode_index specified, the number of KL modes in the output array is 1. """ cenx, ceny = (frame.ext_hdr['STARLOCX'],frame.ext_hdr['STARLOCY']) # Validate nsigma and small sample correction inputs check.real_positive_scalar(nsigma, 'nsigma', ValueError) if small_sample_correction: if fwhm is None: raise ValueError("fwhm must be provided when small_sample_correction is True.") fwhm_arr = np.atleast_1d(np.array(fwhm, dtype=float)) if fwhm_arr.size == 1: fwhm_arr = np.full(len(seps_pix), fwhm_arr[0]) elif len(fwhm_arr) != len(seps_pix): raise ValueError("fwhm array length must match seps_pix length.") # Mask data outside the specified annulus y, x = np.indices(frame.data.shape[1:]) sep_map = np.sqrt((y-ceny)**2 + (x-cenx)**2) sep_map3d = np.ones_like(frame.data) * sep_map stds = [] for sep_pix in seps_pix: r_inner = sep_pix - hw r_outer = sep_pix + hw masked_data = np.where(np.logical_and(sep_map3d<r_outer,sep_map3d>r_inner), frame.data,np.nan) # import matplotlib.pyplot as plt # plt.imshow(masked_data[0],origin='lower') # plt.colorbar() # plt.title('Candlocs not masked') # plt.show() if len(cand_locs) > 0: for cand_loc in cand_locs: cand_x, cand_y = seppa2xy(*cand_loc,cenx,ceny) dists = np.sqrt((y-cand_y)**2 + (x-cand_x)**2) masked_data = np.where(dists > 5 * hw, masked_data.copy(),np.nan) # import matplotlib.pyplot as plt # plt.imshow(masked_data[0],origin='lower') # plt.colorbar() # plt.title('Candlocs masked') # plt.show() # Calculate standard deviation with warnings.catch_warnings(): # warnings.filterwarnings("ignore", category=UserWarning) # catch Not all requested_separations from l4_to_tda warnings.filterwarnings("ignore", category=RuntimeWarning) # catch Not all requested_separations from l4_to_tda std = np.nanstd(masked_data,axis=(1,2)) stds.append(std) stds_arr = np.array(stds) # Apply nsigma scaling, with optional small sample statistics correction if small_sample_correction: fpf = norm.sf(nsigma) # false positive fraction for the given nsigma for s_idx, sep_pix in enumerate(seps_pix): n = 2 * np.pi * sep_pix / fwhm_arr[s_idx] if n <= 2: warnings.warn( f"Only {n:.1f} resolution elements at separation {sep_pix:.1f} pix; " "small sample correction unreliable, falling back to nsigma * std." ) stds_arr[s_idx] *= nsigma else: # Mawet et al. 2014, Eq. 9 stds_arr[s_idx] *= t_dist.ppf(1 - fpf, n - 1) * np.sqrt(1 + 1/n) elif nsigma != 1: stds_arr *= nsigma if klmode_index != None: return stds_arr[:,klmode_index] return stds_arr
[docs] def meas_klip_thrupt(sci_dataset_in,ref_dataset_in, # pre-psf-subtracted dataset psfsub_dataset, ct_calibration, klip_params, inject_snr = 5, sep_spacing = 3., n_pas = 5, seps = None, # in pixels from mask center pas = None, cand_locs = [], # list of tuples (sep_pix,pa_deg) of known off-axis source locations, num_processes = None ): """Measures the throughput of the KLIP algorithm via injection-recovery of fake off-axis sources. Args: sci_dataset_in (corgidrp.data.Dataset): input dataset containing science observations ref_dataset_in (corgidrp.data.Dataset): input dataset containing reference observations (set to None for ADI-only reductions) psfsub_dataset (corgidrp.data.Dataset): dataset containing PSF subtraction result ct_calibration (corgidrp.data.CoreThroughputCalibration): core throughput calibration object containing off-axis PSFs. klip_params (dict): dictionary containing the same KLIP parameters used for PSF subtraction. Must contain the keywords 'mode','annuli','subsections','movement','numbasis','outdir'. See corgidrp.l3_to_l4.do_psf_subtraction() for descriptions of each of these parameters. inject_snr (int, optional): SNR at which to inject fake PSFs. Defaults to 5. sep_spacing (float, optional): multiples of the FWHM at which to space separation samples. Defaults to 3. Overridden by passing in explicit separations to the seps keyword. n_pas (int,optional): number of evenly spaced position angles at which to inject PSFs. Defaults to 5. Overridden by in explicit PAs to the pas keyword. seps (np.array, optional): Separations (in pixels from the star center) at which to inject fake PSFs. If not provided, a linear spacing of separations between the IWA & OWA will be chosen. pas (np.array, optional): Position angles (in degrees counterclockwise from north/up) at which to inject fake PSFs at each separation. Defaults to [0.,90.,180.,270.]. cand_locs (list of tuples, optional): Locations of known off-axis sources, so we don't inject a fake PSF too close to them. This is a list of tuples (sep_pix,pa_degrees) for each source. Defaults to []. num_processes (int): number of processes for parallelizing the PSF subtraction Returns: np.array: array of shape (N,n_seps,2), where N is 1 + the number of KL mode truncation choices and n_seps is the number of separations sampled. Index 0 contains the separations sampled, and each following index contains the dimensionless KLIP throughput and FWHM in pixels measured at each separation for each KL mode truncation choice. An example for 4 KL mode truncation choices, using r1 and r2 for separations and n_seps=2: [ [[r1,r1],[r2,r2]], [[KL_thpt_r1_KL1, FWHM_r1_KL1],[KL_thpt_r2_KL1, FWHM_r2_KL1]], [[KL_thpt_r1_KL2, FWHM_r1_KL2],[KL_thpt_r2_KL2, FWHM_r2_KL2]], [[KL_thpt_r1_KL3, FWHM_r1_KL3],[KL_thpt_r2_KL3, FWHM_r2_KL3]], [[KL_thpt_r1_KL4, FWHM_r1_KL4],[KL_thpt_r2_KL4, FWHM_r2_KL4]] ] """ if sci_dataset_in[0].ext_hdr['CFAMNAME'] == '1F': lam = 573.8e-9 #m elif sci_dataset_in[0].ext_hdr['CFAMNAME'] == '4F': lam =825.8e-9 #m else: raise NotImplementedError("Only band 1 observations using CFAMNAME 1F are currently configured.") d = 2.36 #m pixscale_mas = sci_dataset_in[0].ext_hdr['PLTSCALE'] fwhm_mas = 1.22 * lam / d * 206265. * 1000. fwhm_pix = fwhm_mas / pixscale_mas res_elem = sep_spacing * fwhm_pix # pix if seps is None: match sci_dataset_in[0].ext_hdr['FPAMNAME']: case 'SPC34_R5C1': owa_mas = 1447.4 iwa_mas = 425.9 case 'SPC12_R1C1': owa_mas = 1008.8 iwa_mas = 296.1 case 'HLC12_C2R1': owa_mas = 450.0 iwa_mas = 140.0 case _: raise NotImplementedError("Automatic separation choices not configured for this mode.") owa_pix = owa_mas / pixscale_mas iwa_pix = iwa_mas / pixscale_mas seps = np.arange(iwa_pix,owa_pix,res_elem) # Some linear spacing between the IWA & OWA, around 5x the fwhm if pas is None: pas = np.linspace(0.,360.,n_pas+1)[:-1] # Some linear spacing between the IWA & OWA, around 5x the fwhm thrupts = [] outfwhms = [] for k,klmode in enumerate(klip_params['numbasis']): sci_dataset = sci_dataset_in.copy() pa_aper_degs = [frame.pri_hdr['PA_APER'] for frame in sci_dataset] # Measure noise at each separation in psf subtracted dataset (for this kl mode) noise_vals = measure_noise(psfsub_dataset[0],seps,fwhm_pix,k,cand_locs) # Inject planets: seppas_skipped = [] this_klmode_seppas = [] this_klmode_psfmodels = [] this_klmode_inject_peaks = [] this_klmode_psfcenxy = [] for i,frame in enumerate(sci_dataset): this_klmode_seppas.append([]) this_klmode_psfmodels.append([]) this_klmode_inject_peaks.append([]) this_klmode_psfcenxy.append([]) # Initialize PA offset to spiral the injections pa_off = 0. pa_step = 360. / len(pas) / 3 for s,sep in enumerate(seps): noise = noise_vals[s] inject_peak = noise * inject_snr for pa in pas: pa = (pa + pa_off) % 360. inject_loc = (sep,pa) if inject_loc in seppas_skipped: continue # Check that we're not too close to a candidate during the first frame if i==0: too_close = False for cand_sep, cand_pa in cand_locs: # Account for rotations, skip if any are too close for pa_aper_deg in pa_aper_degs: # NOTE: rotation angle is not applied here, cand_pa_adj == cand_pa. # It may not be necessary to loop over rolls here. cand_pa_adj = cand_pa dist = get_polar_dist((cand_sep,cand_pa_adj),inject_loc) if dist < res_elem: too_close=True break if too_close: break if too_close: seppas_skipped.append(inject_loc) continue frame, psf_model, psf_cenxy = inject_psf(frame, ct_calibration, inject_peak, *inject_loc) # Save these for later this_klmode_psfmodels[i].append(psf_model.copy()) this_klmode_seppas[i].append(inject_loc) this_klmode_inject_peaks[i].append(inject_peak) this_klmode_psfcenxy[i].append(psf_cenxy) pa_off += pa_step sci_dataset[i].data[:] = frame.data[:] # Debugging things psfmodels_arr = np.array(this_klmode_psfmodels) seppas_arr = np.array(this_klmode_seppas) inj_peaks = np.array(this_klmode_inject_peaks) psfcenxys = np.array(this_klmode_psfcenxy) psfmodel_sums = np.sum(psfmodels_arr,axis=(2,3)) psfmodel_peaks = np.max(psfmodels_arr,axis=(2,3)) # Init pyklip dataset pyklip_dataset = PyKLIPDataset(sci_dataset,psflib_dataset=ref_dataset_in) # Run pyklip klip_dataset(pyklip_dataset, outputdir=klip_params['outdir'], annuli=klip_params['annuli'], subsections=klip_params['subsections'], movement=klip_params['movement'], numbasis=[klmode], calibrate_flux=False, mode=klip_params['mode'], psf_library=pyklip_dataset._psflib, fileprefix=f"FAKE_{klmode}KLMODES", numthreads=num_processes) # Get photometry of each injected source # Load pyklip output pyklip_fpath = os.path.join(klip_params['outdir'],f"FAKE_{klmode}KLMODES-KLmodes-all.fits") pyklip_data = fits.getdata(pyklip_fpath)[0] pyklip_hdr = fits.getheader(pyklip_fpath) # Measure background via sigma clip n_loops = 5 masked_data = pyklip_data.copy() for n in range(n_loops): std = np.nanstd(masked_data) med = np.nanmedian(masked_data) clip_thresh = 3 * std masked_data = np.where(np.abs(masked_data-med)>clip_thresh,np.nan,masked_data) # Subtract median bg_level = np.nanmedian(masked_data) medsubtracted_data = pyklip_data - bg_level # # Plot Psf subtraction with fakes # if klip_params['mode'] == 'RDI': # analytical_result = rotate(sci_dataset[0].data - ref_dataset_in[0].data,-rolls[0],reshape=False,cval=np.nan) # elif klip_params['mode'] == 'ADI': # analytical_result = (rotate(sci_dataset[0].data - sci_dataset[1].data,-rolls[0],reshape=False,cval=0) + rotate(sci_dataset[1].data - sci_dataset[0].data,-rolls[1],reshape=False,cval=0)) / 2 # elif klip_params['mode'] == 'ADI+RDI': # analytical_result = (rotate(sci_dataset[0].data - (sci_dataset[1].data/2+ref_dataset_in[0].data/2),-rolls[0],reshape=False,cval=0) + rotate(sci_dataset[1].data - (sci_dataset[0].data/2+ref_dataset_in[0].data/2),-rolls[1],reshape=False,cval=0)) / 2 # import matplotlib.pyplot as plt # fig,axes = plt.subplots(1,3,sharey=True,layout='constrained',figsize=(12,3)) # im0 = axes[0].imshow(medsubtracted_data,origin='lower') # plt.colorbar(im0,ax=axes[0],shrink=0.8) # locsxy = seppa2xy(seppas_arr[0,:,0],seppas_arr[0,:,1],pyklip_hdr['PSFCENTX'],pyklip_hdr['PSFCENTY']) # axes[0].scatter(locsxy[0],locsxy[1],label='Injected PSFs',s=1,c='r',marker='x') # axes[0].set_title(f'Output data') # axes[0].legend() # im1 = axes[1].imshow(analytical_result,origin='lower') # plt.colorbar(im1,ax=axes[1],shrink=0.8) # axes[1].set_title('Analytical result') # im2 = axes[2].imshow(medsubtracted_data - analytical_result,origin='lower') # plt.colorbar(im2,ax=axes[2],shrink=0.8) # axes[2].set_title('Difference') # plt.suptitle(f'PSF Subtraction {klip_params["mode"]} ({klmode} KL Modes)') # plt.show() # After psf subtraction this_klmode_peakin = [] this_klmode_peakout = [] this_klmode_sumin = [] this_klmode_influxs = [] this_klmode_outfluxs = [] this_klmode_infwhms = [] this_klmode_outfwhms = [] this_klmode_thrupts = [] for ll,loc in enumerate(this_klmode_seppas[0]): psf_model = this_klmode_psfmodels[0][ll] # Pad psf model with zeros so we can measure background model_shape = np.array(psf_model.shape) cutout_shape = model_shape * 2 + 1 cutoutcenyx = cutout_shape/2. - 0.5 psf_model_padded = np.zeros(cutout_shape) start_ind_model = (cutoutcenyx-model_shape//2).astype(int) end_ind_model = (start_ind_model + model_shape).astype(int) x1_model,y1_model = start_ind_model x2_model,y2_model = end_ind_model psf_model_padded[y1_model:y2_model, x1_model:x2_model] = psf_model # Crop data around location to be same as psf_model cutout locxy = seppa2xy(*loc,pyklip_hdr['PSFCENTX'],pyklip_hdr['PSFCENTY']) # Crop the data, pad with nans if we're cropping over the edge cutout = np.zeros_like(psf_model_padded) cutout[:] = np.nan cutout_starty, cutout_startx = (0,0) cutout_endy, cutout_endx = cutout.shape data_shape = medsubtracted_data.shape data_center_indyx = np.array([locxy[1],locxy[0]]).astype(int) data_start_indyx = (data_center_indyx - cutout_shape//2) data_end_indyx = (data_start_indyx + cutout_shape) data_starty,data_startx = data_start_indyx data_endy,data_endx = data_end_indyx if data_starty < 0: cutout_starty = -data_starty data_starty = 0 if data_startx < 0: cutout_startx = -data_startx data_startx = 0 if data_endy >= data_shape[0]: y_overhang = data_endy - medsubtracted_data.shape[0] cutout_endy = cutout_shape[0] - y_overhang data_endy = data_shape[0] if data_endx >= data_shape[1]: x_overhang = data_endx - medsubtracted_data.shape[1] cutout_endx = cutout_shape[1] - x_overhang data_endx = data_shape[1] cutout[cutout_starty:cutout_endy, cutout_startx:cutout_endx] = medsubtracted_data[data_starty:data_endy, data_startx:data_endx] # # Plot PSF Model # import matplotlib.pyplot as plt # fig,ax = plt.subplots(1,3, # sharey=True, # layout='constrained', # figsize=(12,4)) # vmax = np.max(psf_model_padded) # im0 = ax[0].imshow(psf_model_padded,origin='lower', # vmax=vmax,vmin=-vmax) # plt.colorbar(im0,ax=ax[0]) # ax[0].set_title('PSF Model') # im1 = ax[1].imshow(cutout,origin='lower', # vmax=vmax,vmin=-vmax) # plt.colorbar(im1,ax=ax[1]) # ax[1].set_title('Data Cutout') # im2 = ax[2].imshow(cutout-psf_model_padded,origin='lower', # vmax=vmax,vmin=-vmax) # plt.colorbar(im2,ax=ax[2]) # ax[2].set_title('Residuals') # plt.show() # Using pyklip.fakes.gaussfit2d preklip_peak, pre_fwhm, pre_xfit, pre_yfit = gaussfit2d( psf_model_padded, cutoutcenyx[1], cutoutcenyx[0], searchrad=5, guessfwhm=fwhm_pix, guesspeak=inject_peak, refinefit=True) postklip_peak, post_fwhm, post_xfit, post_yfit = gaussfit2d( cutout, cutoutcenyx[1], cutoutcenyx[0], searchrad=5, guessfwhm=fwhm_pix, guesspeak=inject_peak, refinefit=True) # # Plot Final PSF Model # import matplotlib.pyplot as plt # post_sigma = post_fwhm / (2 * np.sqrt(2. * np.log(2.))) # from corgidrp.mocks import gaussian_array # final_model = gaussian_array(array_shape=cutout_shape, # sigma=post_sigma, # amp=postklip_peak, # xoffset=post_xfit-19., # yoffset=post_yfit-19.) # fig,ax = plt.subplots(1,3, # sharey=True, # layout='constrained', # figsize=(12,4)) # vmax = np.max(psf_model_padded) # im0 = ax[0].imshow(final_model,origin='lower', # vmax=vmax,vmin=-vmax) # plt.colorbar(im0,ax=ax[0]) # ax[0].set_title('Final PSF Model') # im1 = ax[1].imshow(cutout,origin='lower', # vmax=vmax,vmin=-vmax) # plt.colorbar(im1,ax=ax[1]) # ax[1].set_title('Data Cutout') # im2 = ax[2].imshow(cutout-final_model,origin='lower', # vmax=vmax,vmin=-vmax) # plt.colorbar(im2,ax=ax[2]) # ax[2].set_title('Residuals') # plt.show() # Get total counts from 2D gaussian fit preklip_counts = np.pi * preklip_peak * pre_fwhm**2 / 4. / np.log(2.) postklip_counts = np.pi * postklip_peak * post_fwhm**2 / 4. / np.log(2.) thrupt = postklip_counts/preklip_counts this_klmode_peakin.append(preklip_peak) this_klmode_peakout.append(postklip_peak) this_klmode_sumin.append(np.sum(psf_model)) this_klmode_influxs.append(preklip_counts) this_klmode_outfluxs.append(postklip_counts) this_klmode_infwhms.append(pre_fwhm) this_klmode_outfwhms.append(post_fwhm) this_klmode_thrupts.append(thrupt) seppas_arr = np.array(this_klmode_seppas[0]) seps_arr = seppas_arr[:,0] # # Plot injected and recovered counts # import matplotlib.pyplot as plt # fig,ax = plt.subplots() # plt.scatter(seps_arr,this_klmode_influxs,label='Injected counts') # plt.scatter(seps_arr,this_klmode_outfluxs,label='Recovered counts') # plt.xlabel('separation (pixels)') # plt.legend() # plt.show() # # Plot injected and recovered peaks # fig,ax = plt.subplots() # plt.scatter(seps_arr,this_klmode_peakin,label='Injected peaks') # plt.scatter(seps_arr,this_klmode_peakout,label='Recovered peaks') # plt.xlabel('separation (pixels)') # plt.legend() # plt.show() mean_thrupts = [] mean_outfwhms = [] # TODO: If no measurements available for a given sep # show warning and add np.nan for sep in np.unique(seps_arr): this_sep_thrupts = np.where(seps_arr==sep,this_klmode_thrupts,np.nan) mean_thrupt = np.nanmedian(this_sep_thrupts) mean_thrupts.append(mean_thrupt) this_sep_outfwhms = np.where(seps_arr==sep,this_klmode_outfwhms,np.nan) mean_outfwhm = np.nanmedian(this_sep_outfwhms) mean_outfwhms.append(mean_outfwhm) thrupts.append(mean_thrupts) outfwhms.append(mean_outfwhms) thrupt_arr = np.array([[(sep,sep) for sep in seps],*[[(thrupts[kk][ss],outfwhms[kk][ss]) for ss in range(len(seps))] for kk in range(len(klip_params['numbasis']))]]) return thrupt_arr