From 84d16858614fcae32e203f71fad026a28892b5aa Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Wed, 22 May 2024 17:02:07 -0400 Subject: [PATCH 01/27] Initial addition of ocrreject_exam --- stistools/ocrrejectexam.py | 150 +++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 stistools/ocrrejectexam.py diff --git a/stistools/ocrrejectexam.py b/stistools/ocrrejectexam.py new file mode 100644 index 0000000..7aff254 --- /dev/null +++ b/stistools/ocrrejectexam.py @@ -0,0 +1,150 @@ +#! /usr/bin/env python +import os +import numpy as np +import astropy.io.fits as fits +import argparse + +__author__ = 'Joleen K. Carlberg & Matt Dallas' +__version__ = 1.0 + +def ocrrej_exam(obsid, dir=None): + ''' Compares the rate of cosmic rays in the extraction box and everywhere else + in a CCD spectroscopic image. Based on crrej_exam from STIS ISR 2019-02. + + Higher ratios of cosmic ray rates in the extraction box to the rest of the image + may indicate the need to rerun stistools.ocrreject() with different parameters. + + Parameters + ---------- + obsid : str + A STIS observation ID rootname in ipppssoots format (ie odvkl1040) + + dir : str + Directory containing both the flat fielded (_flt.fits) and extracted + spectrum (_sx1.fits or _x1d.fits) files of the observation. + + Returns + ------- + results : dict + Dictionary containing + extr_fracs : cr rejection rates in the extraction boxes for each crsplit + outside_fracs : cr rejection rates outside the extraction boxes for each crsplit + ratios : extr_fracs/outside_fracs + avg_extr_frac : The average of extr_fracs + avg_outside_frac : The average of outside_fracs + avg_ratio : avg_extr_frac/avg_outside_frac + + If called from the command line, prints the avg extraction, outside, and ratio values for quick verification + + ''' + if not dir: + dir = os.getcwd()+'/' + + # Get flt and sx1 filepaths + flt_file = os.path.join(dir, obs_id+'_flt.fits') + + if not os.path.exists(flt_file): + raise IOError(f"No _flt file in working directory for {obs_id}") + + sx1_file = os.path.join(dir, obs_id+'_sx1.fits') + + if not os.path.exists(sx1_file): + raise IOError(f"No _flt file in working directory for {obs_id}") + + # Check that the number of sci extensions matches the number of crsplits + with fits.open(flt_file) as flt_hdul: + nrptexp_num = flt_hdul[0].header['NRPTEXP'] + crsplit_num = flt_hdul[0].header['CRSPLIT'] + sci_num = len([hdu.name for hdu in flt_hdul if "SCI" in hdu.name]) # Counts the number of sci extensions + + if ((crsplit_num)*(nrptexp_num))-(sci_num)!= 0: + raise ValueError(f"cr-split or nrptexp value in flt header does not match the number of sci extentsions for {obs_id}") + + # Calculate cr fraction in and out of extraction box + with fits.open(sx1_file) as sx1_hdul: + spec = sx1_hdul[1].data[0] + shdr = sx1_hdul[0].header + + extrlocy = spec['EXTRLOCY']-1 # y coord floats of the middle of the extraction box + del_pix = spec['EXTRSIZE']/2. # float value the extraction box extends above or below extrlocy + box_upper=np.ceil(extrlocy+del_pix).astype(int) # Ints of pixel values above end of trace bc python is upper bound exclusive + box_lower=np.floor(extrlocy-del_pix).astype(int) # Ints of pixel values below end of trace + + # Fill each of these lists with values for each cr split + extr_fracs = [] # float of the fraction of pixels flagged as cr inside the extraction box + outside_fracs = [] # float of the fraction of pixels flagged as cr outside the extraction box + cr_rejected_locs = [] # 2d array of 1s where a cr exists and 0 elsewhere + exposure_times = [] + + with fits.open(flt_file) as flt_hdul: + flt_shape = flt_hdul['sci', 1].data.shape # shape of the data + + # Check that the extraction box doesn't extend beyond the image: this breaks the method + if np.any(box_lower < 0) or np.any(box_upper-1 > flt_shape[0]): # Subtract 1 because the box extends to the value of the pixel before + raise ValueError(f"Extraction box coords extend above or below the cr subexposures for {obs_id}") + + extr_mask = np.zeros(flt_shape) + outside_mask = np.ones(flt_shape) + + for column in range(0,flt_shape[1]): + extr_mask[box_lower[column]:box_upper[column], column] = 1 # 1s inside the extraction box, 0s outside + outside_mask[box_lower[column]:box_upper[column], column] = 0 # 0s inside the extraction box, 1s outside + + n_extr = np.count_nonzero(extr_mask) # number of pixels inside the extraction box + n_outside = np.count_nonzero(outside_mask) # number of pixels outside the extraction box + + for i, hdu in enumerate(flt_hdul): + if hdu.name == 'SCI': + exposure_times.append(hdu.header['EXPTIME']) + dq_array = flt_hdul[i+2].data # dq array corresponding to each sci extentsion + + extr_rej_pix = np.zeros(flt_shape) # 2d array where there is a 1 if a pixel inside the extraction box is marked as a cr + np.place(extr_rej_pix, ((extr_mask == 1) & (dq_array & 2**13 != 0)), 1) + + outside_rej_pix = np.zeros(flt_shape) # 2d array where there is a 1 if a pixel outside the extraction box is marked as a cr + np.place(outside_rej_pix, ((outside_mask == 1) & (dq_array & 2**13 != 0)), 1) + + extr_cr_count = np.count_nonzero(extr_rej_pix) + outside_cr_count = np.count_nonzero(outside_rej_pix) + + extr_fracs.append(extr_cr_count/n_extr) + outside_fracs.append(outside_cr_count/n_outside) + + cr_rejected_pix = extr_rej_pix+outside_rej_pix + cr_rejected_locs.append(cr_rejected_pix) + + extr_fracs = np.asarray(extr_fracs) + outside_fracs = np.asarray(outside_fracs) + ratios = extr_fracs/outside_fracs # ratio of extraction to outside the box in each image + + avg_extr_frac = (np.sum(extr_fracs))/(len(extr_fracs)) # Average fraction of crs inside extraction box + avg_outside_frac = (np.sum(outside_fracs))/(len(outside_fracs)) # Average fraction of crs outside extraction box + avg_ratio = avg_extr_frac/avg_outside_frac # Average ratio of the stack + + results ={'extr_fracs':extr_fracs, 'outside_fracs':outside_fracs, 'ratios':ratios, 'avg_extr_frac':avg_extr_frac, 'avg_outside_frac':avg_outside_frac, 'avg_ratio':avg_ratio} + + return results + +def call_ocrrej_exam(): + '''Command line usage of ocrrejectexam''' + + parser = argparse.ArgumentParser(description='Calculate fractions of cosmic ray rejected pixels inside and outside of an extraction box to test for cr algorithm failures.', + epilog=f'v{__version__}; Written by {__author__}') + + parser.add_argument(dest='obsids', nargs='*', help='observation ids in ipppssoots format') + parser.add_argument('-d', dest='dir', default=None, + help="directory containing observation flt and sx1 files os.getcwd()+'/'") + args = parser.parse_args() + + for obsid in args.obsids: + print(f'Analyzing {osbid}:') + result = ocrrej_exam(obsid, dir=args.dir) + + print('Fraction of Pixels Rejected as CRs') + print(f" Average across all extraction boxes: {result['avg_extr_frac']:.1%}") + print(f" Average across all external regions: {result['avg_outside_frac']:.1%}") + print(f" Average ratio between the two: {result['avg_ratio']:.1%}") + + +if __name__ == '__main__': + call_crrej_exam() \ No newline at end of file From e13bf3b73b41637996d9ab46a1d7ef7ed303d03f Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Tue, 28 May 2024 16:20:03 -0400 Subject: [PATCH 02/27] Added plotting functionality --- stistools/__init__.py | 1 + stistools/ocrreject_exam.py | 328 ++++++++++++++++++++++++++++++++++++ stistools/ocrrejectexam.py | 150 ----------------- 3 files changed, 329 insertions(+), 150 deletions(-) create mode 100644 stistools/ocrreject_exam.py delete mode 100644 stistools/ocrrejectexam.py diff --git a/stistools/__init__.py b/stistools/__init__.py index 65864d4..ddfa0d0 100644 --- a/stistools/__init__.py +++ b/stistools/__init__.py @@ -16,6 +16,7 @@ from . import tastis from . import ctestis from . import defringe +from . import ocrreject_exam # These lines allow TEAL to print out the names of TEAL-enabled tasks # upon importing this package. diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py new file mode 100644 index 0000000..ea32720 --- /dev/null +++ b/stistools/ocrreject_exam.py @@ -0,0 +1,328 @@ +#! /usr/bin/env python +import os +import numpy as np +import astropy.io.fits as fits +import argparse +from matplotlib import colormaps +import matplotlib.cm as colormap +import matplotlib.colors as colors +import matplotlib.pyplot as plt + +__author__ = 'Joleen K. Carlberg & Matt Dallas' +__version__ = 1.0 + +def ocrrej_exam(obs_id, dir=None, plot=False, plot_dir=None): + ''' Compares the rate of cosmic rays in the extraction box and everywhere else + in a CCD spectroscopic image. Based on crrej_exam from STIS ISR 2019-02. + + Higher ratios of cosmic ray rates in the extraction box to the rest of the image + may indicate the need to rerun stistools.ocrreject() with different parameters. + + Parameters + ---------- + obsid : str + A STIS observation ID rootname in ipppssoot format (ie odvkl1040) + + dir : str + Directory containing both the flat fielded (_flt.fits) and extracted + spectrum (_sx1.fits or _x1d.fits) files of the observation. + Defaults to pwd and requires trailing / + + plot : bool + Option to generate diagnostic plots, default=False + + plot_dir : str + Directory to save diagnostic plots in if plot=True. + Defaults to pwd and requires trailing / + + Returns + ------- + results : dict + Dictionary containing + extr_fracs : cr rejection rates in the extraction boxes for each crsplit + outside_fracs : cr rejection rates outside the extraction boxes for each crsplit + ratios : extr_fracs/outside_fracs + avg_extr_frac : The average of extr_fracs + avg_outside_frac : The average of outside_fracs + avg_ratio : avg_extr_frac/avg_outside_frac + + If called from the command line, prints the avg extraction, outside, and ratio values for quick verification + + ''' + if not dir: + dir = os.getcwd()+'/' + + if not plot_dir: + plot_dir = os.getcwd()+'/' + + # Get flt and sx1 filepaths + flt_file = os.path.join(dir, obs_id+'_flt.fits') + + if not os.path.exists(flt_file): + raise IOError(f"No _flt file in working directory for {obs_id}") + + sx1_file = os.path.join(dir, obs_id+'_sx1.fits') + + if not os.path.exists(sx1_file): + raise IOError(f"No _sx1 file in working directory for {obs_id}") + + # Check that the number of sci extensions matches the number of crsplits + with fits.open(flt_file) as flt_hdul: + propid = flt_hdul[0].header['PROPOSID'] + nrptexp_num = flt_hdul[0].header['NRPTEXP'] + crsplit_num = flt_hdul[0].header['CRSPLIT'] + sci_num = len([hdu.name for hdu in flt_hdul if "SCI" in hdu.name]) # Counts the number of sci extensions + + if ((crsplit_num)*(nrptexp_num))-(sci_num)!= 0: + raise ValueError(f"cr-split or nrptexp value in flt header does not match the number of sci extentsions for {obs_id}") + + # Calculate cr fraction in and out of extraction box + with fits.open(sx1_file) as sx1_hdul: + spec = sx1_hdul[1].data[0] + shdr = sx1_hdul[0].header + + extrlocy = spec['EXTRLOCY']-1 # y coord floats of the middle of the extraction box + del_pix = spec['EXTRSIZE']/2. # float value the extraction box extends above or below extrlocy + box_upper=np.ceil(extrlocy+del_pix).astype(int) # Ints of pixel values above end of trace bc python is upper bound exclusive + box_lower=np.floor(extrlocy-del_pix).astype(int) # Ints of pixel values below end of trace + + # Fill each of these lists with values for each cr split + extr_fracs = [] # float of the fraction of pixels flagged as cr inside the extraction box + outside_fracs = [] # float of the fraction of pixels flagged as cr outside the extraction box + cr_rejected_locs = [] # 2d array of 1s where a cr exists and 0 elsewhere + exposure_times = [] + + with fits.open(flt_file) as flt_hdul: + flt_shape = flt_hdul['sci', 1].data.shape # shape of the data + + # Check that the extraction box doesn't extend beyond the image: this breaks the method + if np.any(box_lower < 0) or np.any(box_upper-1 > flt_shape[0]): # Subtract 1 because the box extends to the value of the pixel before + raise ValueError(f"Extraction box coords extend above or below the cr subexposures for {obs_id}") + + extr_mask = np.zeros(flt_shape) + outside_mask = np.ones(flt_shape) + + for column in range(0,flt_shape[1]): + extr_mask[box_lower[column]:box_upper[column], column] = 1 # 1s inside the extraction box, 0s outside + outside_mask[box_lower[column]:box_upper[column], column] = 0 # 0s inside the extraction box, 1s outside + + n_extr = np.count_nonzero(extr_mask) # number of pixels inside the extraction box + n_outside = np.count_nonzero(outside_mask) # number of pixels outside the extraction box + + for i, hdu in enumerate(flt_hdul): + if hdu.name == 'SCI': + exposure_times.append(hdu.header['EXPTIME']) + dq_array = flt_hdul[i+2].data # dq array corresponding to each sci extentsion + + extr_rej_pix = np.zeros(flt_shape) # 2d array where there is a 1 if a pixel inside the extraction box is marked as a cr + np.place(extr_rej_pix, ((extr_mask == 1) & (dq_array & 2**13 != 0)), 1) + + outside_rej_pix = np.zeros(flt_shape) # 2d array where there is a 1 if a pixel outside the extraction box is marked as a cr + np.place(outside_rej_pix, ((outside_mask == 1) & (dq_array & 2**13 != 0)), 1) + + extr_cr_count = np.count_nonzero(extr_rej_pix) + outside_cr_count = np.count_nonzero(outside_rej_pix) + + extr_fracs.append(extr_cr_count/n_extr) + outside_fracs.append(outside_cr_count/n_outside) + + cr_rejected_pix = extr_rej_pix+outside_rej_pix + cr_rejected_locs.append(cr_rejected_pix) + + extr_fracs = np.asarray(extr_fracs) + outside_fracs = np.asarray(outside_fracs) + ratios = extr_fracs/outside_fracs # ratio of extraction to outside the box in each image + + avg_extr_frac = (np.sum(extr_fracs))/(len(extr_fracs)) # Average fraction of crs inside extraction box + avg_outside_frac = (np.sum(outside_fracs))/(len(outside_fracs)) # Average fraction of crs outside extraction box + avg_ratio = avg_extr_frac/avg_outside_frac # Average ratio of the stack + + results ={'extr_fracs':extr_fracs, 'outside_fracs':outside_fracs, 'ratios':ratios, 'avg_extr_frac':avg_extr_frac, 'avg_outside_frac':avg_outside_frac, 'avg_ratio':avg_ratio} + + if plot: + cr_rejected_stack = sum(cr_rejected_locs) # stack all located crs on top of eachother + stacked_exposure_time = sum(exposure_times) + stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, obs_id, propid, plot_dir) + split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, obs_id, propid, plot_dir) + + return results + +def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, propid, plot_dir): + """Creates a visualization of where cr pixels are in a stacked image + + Parameters + ---------- + stack_image : array + 2d array to plot. + + box_lower : array + 1d array of ints of the bottom of the extraction box 0 indexed. + + box_upper : array + 1d array of ints of the top of the extraction box 0 indexed. + + split_num : int + Number of splits in the stack. + + texpt : float + Value of total exposure time. + + obs_id : str + ipppssoot of observation + + propid : int + proposal id of observation + + plot_dir : str + Directory to save plot in. Requires trailing / + """ + + stack_shape = stack_image.shape + cmap = colors.ListedColormap(gen_color('turbo', split_num+1)) + bounds = np.arange(split_num+2) + norm = colors.BoundaryNorm(bounds, cmap.N) + + fig, (ax1,ax2,ax3) = plt.subplots(nrows=1, ncols=3, figsize=(9,20*(9/41)), gridspec_kw={'width_ratios': [1, 1, 0.05], 'height_ratios': [1]}) + + for axis in [ax1,ax2]: + axis.imshow(stack_image, interpolation='nearest', origin="lower", extent=(0, stack_shape[1], 0, stack_shape[0]), cmap=cmap, norm=norm, aspect='auto') + axis.step(np.arange(len(box_upper)), box_upper, color='w', where='post', lw=0.5, alpha=0.5, ls='--') + axis.step(np.arange(len(box_lower)), box_lower, color='w', where='post', lw=0.5, alpha=0.5, ls='--') + + ax1.set_title('Full image') + + # If it is a large enough image, zoom the 2nd subplot around the extraction box region + if ((stack_shape[0] - max(box_upper)) > 20) and (min(box_lower) > 20): + ax2.set_ylim([(min(box_lower)-20),(max(box_upper)+20)]) + ax2.set_title('zoomed to 20 pixels above/below extraction box') + + # Otherwise just don't zoom in at all + else: + ax2.set_title('full image already 20 pixels above/below extraction box') + + fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, label='# times flagged as cr') + + fig.suptitle('CR flagged pixels in stacked image: '+obs_id+'\n Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') + fig.tight_layout() + + plot_name = obs_id + '_stacked.png' + file_path = plot_dir + plot_name + plt.savefig(file_path, dpi=150) + plt.close() + +def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_times, texpt, obs_id, propid, plot_dir): + """Creates a visualization of where cr pixels are in each subexposure + + Parameters + ---------- + splits : list + list of cr placements in each subexposure (ie the cr_rejected_locs output of ocrrej_exam) + + box_lower : array + 1d array of ints of the bottom of the extraction box 0 indexed. + + box_upper : array + 1d array of ints of the top of the extraction box 0 indexed. + + split_num : int + Number of splits in the stack, (ie len(cr_rejected_locs)). + + individual_exposure_times: list + List of exposure times for each subexposure + + texpt : float + Value of total exposure time + + obs_id : str + ipppssoot of observation + + propid : int + proposal id of observation + + plot_dir : str + Directory to save plot in. Requires trailing / + """ + + # Define grid, dependent on number of splits: + if ((len(splits))%2) == 0: + nrows = (len(splits))/2 + else: + nrows = ((len(splits))+1)/2 + + row_value = int(nrows) + + fig, ax = plt.subplots(nrows=row_value, ncols=2, figsize=(9, nrows*2)) + ax = ax.flatten() + + cmap = colors.ListedColormap(gen_color('autumn', 3)) + bounds = np.arange(4) + norm = colors.BoundaryNorm(bounds, cmap.N) + + # Plot each subexposure with cr pixels a different color + for num, axis in enumerate(ax): + if num 20) and (min(box_lower) >20): + axis.set_ylim([(min(box_lower)-20),(max(box_upper)+20)]) + axis.set_title('zoomed subexposure '+str(num+1)+', exposure time '+str(individual_exposure_times[num])) + + else: + axis.set_title('subexposure '+str(num+1)+', exposure time '+str(individual_exposure_times[num])) + + else: + axis.set_axis_off() + + fig.suptitle('CR flagged pixels in individual splits for: '+obs_id+ '\n Proposal '+str(propid)+', total exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') + fig.tight_layout() + + plot_name = obs_id + '_splits.png' + file_path = plot_dir + plot_name + plt.savefig(file_path, dpi=150) + plt.close() + +def gen_color(cmap, n): + """Generates n distinct colors from a given colormap. + + Based on mycolorpy's gen_color() from https://github.com/binodbhttr/mycolorpy""" + + c_map = colormaps[cmap] + colorlist = [] + + for c in np.linspace(0,1,n): + rgba=c_map(c) # select the rgba value of the cmap at point c which is a number between 0 to 1 + clr=colors.rgb2hex(rgba) # convert to hex + colorlist.append(str(clr)) # create a list of these colors + + colorlist.pop(0) # Make it dark grey rather than black at the beginning (I think it's easier on the eyes) + colorlist.insert(0, '#A9A9A9') + + return colorlist + +def call_ocrrej_exam(): + '''Command line usage of ocrreject_exam''' + + parser = argparse.ArgumentParser(description='Calculate fractions of cosmic ray rejected pixels inside and outside of an extraction box to test for cr algorithm failures.', + epilog=f'v{__version__}; Written by {__author__}') + + parser.add_argument(dest='obsids', nargs='*', help='observation ids in ipppssoots format') + parser.add_argument('-d', dest='dir', default=None, + help="directory containing observation flt and sx1 files os.getcwd()+'/'") + args = parser.parse_args() + + for obsid in args.obsids: + print(f'Analyzing {osbid}:') + result = ocrrej_exam(obsid, dir=args.dir) + + print('Fraction of Pixels Rejected as CRs') + print(f" Average across all extraction boxes: {result['avg_extr_frac']:.1%}") + print(f" Average across all external regions: {result['avg_outside_frac']:.1%}") + print(f" Average ratio between the two: {result['avg_ratio']:.1%}") + + +if __name__ == '__main__': + call_crrej_exam() \ No newline at end of file diff --git a/stistools/ocrrejectexam.py b/stistools/ocrrejectexam.py deleted file mode 100644 index 7aff254..0000000 --- a/stistools/ocrrejectexam.py +++ /dev/null @@ -1,150 +0,0 @@ -#! /usr/bin/env python -import os -import numpy as np -import astropy.io.fits as fits -import argparse - -__author__ = 'Joleen K. Carlberg & Matt Dallas' -__version__ = 1.0 - -def ocrrej_exam(obsid, dir=None): - ''' Compares the rate of cosmic rays in the extraction box and everywhere else - in a CCD spectroscopic image. Based on crrej_exam from STIS ISR 2019-02. - - Higher ratios of cosmic ray rates in the extraction box to the rest of the image - may indicate the need to rerun stistools.ocrreject() with different parameters. - - Parameters - ---------- - obsid : str - A STIS observation ID rootname in ipppssoots format (ie odvkl1040) - - dir : str - Directory containing both the flat fielded (_flt.fits) and extracted - spectrum (_sx1.fits or _x1d.fits) files of the observation. - - Returns - ------- - results : dict - Dictionary containing - extr_fracs : cr rejection rates in the extraction boxes for each crsplit - outside_fracs : cr rejection rates outside the extraction boxes for each crsplit - ratios : extr_fracs/outside_fracs - avg_extr_frac : The average of extr_fracs - avg_outside_frac : The average of outside_fracs - avg_ratio : avg_extr_frac/avg_outside_frac - - If called from the command line, prints the avg extraction, outside, and ratio values for quick verification - - ''' - if not dir: - dir = os.getcwd()+'/' - - # Get flt and sx1 filepaths - flt_file = os.path.join(dir, obs_id+'_flt.fits') - - if not os.path.exists(flt_file): - raise IOError(f"No _flt file in working directory for {obs_id}") - - sx1_file = os.path.join(dir, obs_id+'_sx1.fits') - - if not os.path.exists(sx1_file): - raise IOError(f"No _flt file in working directory for {obs_id}") - - # Check that the number of sci extensions matches the number of crsplits - with fits.open(flt_file) as flt_hdul: - nrptexp_num = flt_hdul[0].header['NRPTEXP'] - crsplit_num = flt_hdul[0].header['CRSPLIT'] - sci_num = len([hdu.name for hdu in flt_hdul if "SCI" in hdu.name]) # Counts the number of sci extensions - - if ((crsplit_num)*(nrptexp_num))-(sci_num)!= 0: - raise ValueError(f"cr-split or nrptexp value in flt header does not match the number of sci extentsions for {obs_id}") - - # Calculate cr fraction in and out of extraction box - with fits.open(sx1_file) as sx1_hdul: - spec = sx1_hdul[1].data[0] - shdr = sx1_hdul[0].header - - extrlocy = spec['EXTRLOCY']-1 # y coord floats of the middle of the extraction box - del_pix = spec['EXTRSIZE']/2. # float value the extraction box extends above or below extrlocy - box_upper=np.ceil(extrlocy+del_pix).astype(int) # Ints of pixel values above end of trace bc python is upper bound exclusive - box_lower=np.floor(extrlocy-del_pix).astype(int) # Ints of pixel values below end of trace - - # Fill each of these lists with values for each cr split - extr_fracs = [] # float of the fraction of pixels flagged as cr inside the extraction box - outside_fracs = [] # float of the fraction of pixels flagged as cr outside the extraction box - cr_rejected_locs = [] # 2d array of 1s where a cr exists and 0 elsewhere - exposure_times = [] - - with fits.open(flt_file) as flt_hdul: - flt_shape = flt_hdul['sci', 1].data.shape # shape of the data - - # Check that the extraction box doesn't extend beyond the image: this breaks the method - if np.any(box_lower < 0) or np.any(box_upper-1 > flt_shape[0]): # Subtract 1 because the box extends to the value of the pixel before - raise ValueError(f"Extraction box coords extend above or below the cr subexposures for {obs_id}") - - extr_mask = np.zeros(flt_shape) - outside_mask = np.ones(flt_shape) - - for column in range(0,flt_shape[1]): - extr_mask[box_lower[column]:box_upper[column], column] = 1 # 1s inside the extraction box, 0s outside - outside_mask[box_lower[column]:box_upper[column], column] = 0 # 0s inside the extraction box, 1s outside - - n_extr = np.count_nonzero(extr_mask) # number of pixels inside the extraction box - n_outside = np.count_nonzero(outside_mask) # number of pixels outside the extraction box - - for i, hdu in enumerate(flt_hdul): - if hdu.name == 'SCI': - exposure_times.append(hdu.header['EXPTIME']) - dq_array = flt_hdul[i+2].data # dq array corresponding to each sci extentsion - - extr_rej_pix = np.zeros(flt_shape) # 2d array where there is a 1 if a pixel inside the extraction box is marked as a cr - np.place(extr_rej_pix, ((extr_mask == 1) & (dq_array & 2**13 != 0)), 1) - - outside_rej_pix = np.zeros(flt_shape) # 2d array where there is a 1 if a pixel outside the extraction box is marked as a cr - np.place(outside_rej_pix, ((outside_mask == 1) & (dq_array & 2**13 != 0)), 1) - - extr_cr_count = np.count_nonzero(extr_rej_pix) - outside_cr_count = np.count_nonzero(outside_rej_pix) - - extr_fracs.append(extr_cr_count/n_extr) - outside_fracs.append(outside_cr_count/n_outside) - - cr_rejected_pix = extr_rej_pix+outside_rej_pix - cr_rejected_locs.append(cr_rejected_pix) - - extr_fracs = np.asarray(extr_fracs) - outside_fracs = np.asarray(outside_fracs) - ratios = extr_fracs/outside_fracs # ratio of extraction to outside the box in each image - - avg_extr_frac = (np.sum(extr_fracs))/(len(extr_fracs)) # Average fraction of crs inside extraction box - avg_outside_frac = (np.sum(outside_fracs))/(len(outside_fracs)) # Average fraction of crs outside extraction box - avg_ratio = avg_extr_frac/avg_outside_frac # Average ratio of the stack - - results ={'extr_fracs':extr_fracs, 'outside_fracs':outside_fracs, 'ratios':ratios, 'avg_extr_frac':avg_extr_frac, 'avg_outside_frac':avg_outside_frac, 'avg_ratio':avg_ratio} - - return results - -def call_ocrrej_exam(): - '''Command line usage of ocrrejectexam''' - - parser = argparse.ArgumentParser(description='Calculate fractions of cosmic ray rejected pixels inside and outside of an extraction box to test for cr algorithm failures.', - epilog=f'v{__version__}; Written by {__author__}') - - parser.add_argument(dest='obsids', nargs='*', help='observation ids in ipppssoots format') - parser.add_argument('-d', dest='dir', default=None, - help="directory containing observation flt and sx1 files os.getcwd()+'/'") - args = parser.parse_args() - - for obsid in args.obsids: - print(f'Analyzing {osbid}:') - result = ocrrej_exam(obsid, dir=args.dir) - - print('Fraction of Pixels Rejected as CRs') - print(f" Average across all extraction boxes: {result['avg_extr_frac']:.1%}") - print(f" Average across all external regions: {result['avg_outside_frac']:.1%}") - print(f" Average ratio between the two: {result['avg_ratio']:.1%}") - - -if __name__ == '__main__': - call_crrej_exam() \ No newline at end of file From 584e03c303419bfcf65c479fee9d5ca1b7a40dac Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Thu, 6 Jun 2024 14:30:31 -0400 Subject: [PATCH 03/27] Updated call_ocrreject_exam() --- stistools/ocrreject_exam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index ea32720..dcfd037 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -325,4 +325,4 @@ def call_ocrrej_exam(): if __name__ == '__main__': - call_crrej_exam() \ No newline at end of file + call_ocrrej_exam() \ No newline at end of file From 1f028043d34f4b1144c2cef622ddd7cf7aa20fcf Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Tue, 18 Jun 2024 17:32:58 -0400 Subject: [PATCH 04/27] Improved ocrrej_exam naming conventions and docstrings --- stistools/ocrreject_exam.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index dcfd037..fa51fa1 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -11,7 +11,7 @@ __author__ = 'Joleen K. Carlberg & Matt Dallas' __version__ = 1.0 -def ocrrej_exam(obs_id, dir=None, plot=False, plot_dir=None): +def ocrreject_exam(obs_id, dir=None, plot=False, plot_dir=None): ''' Compares the rate of cosmic rays in the extraction box and everywhere else in a CCD spectroscopic image. Based on crrej_exam from STIS ISR 2019-02. @@ -33,7 +33,7 @@ def ocrrej_exam(obs_id, dir=None, plot=False, plot_dir=None): plot_dir : str Directory to save diagnostic plots in if plot=True. - Defaults to pwd and requires trailing / + Defaults to dir parameter and requires trailing / Returns ------- @@ -53,18 +53,18 @@ def ocrrej_exam(obs_id, dir=None, plot=False, plot_dir=None): dir = os.getcwd()+'/' if not plot_dir: - plot_dir = os.getcwd()+'/' + plot_dir = dir # Get flt and sx1 filepaths flt_file = os.path.join(dir, obs_id+'_flt.fits') if not os.path.exists(flt_file): - raise IOError(f"No _flt file in working directory for {obs_id}") + raise IOError(f"No _flt file in {dir} for {obs_id}") sx1_file = os.path.join(dir, obs_id+'_sx1.fits') if not os.path.exists(sx1_file): - raise IOError(f"No _sx1 file in working directory for {obs_id}") + raise IOError(f"No _sx1 file in {dir} for {obs_id}") # Check that the number of sci extensions matches the number of crsplits with fits.open(flt_file) as flt_hdul: @@ -140,7 +140,7 @@ def ocrrej_exam(obs_id, dir=None, plot=False, plot_dir=None): results ={'extr_fracs':extr_fracs, 'outside_fracs':outside_fracs, 'ratios':ratios, 'avg_extr_frac':avg_extr_frac, 'avg_outside_frac':avg_outside_frac, 'avg_ratio':avg_ratio} if plot: - cr_rejected_stack = sum(cr_rejected_locs) # stack all located crs on top of eachother + cr_rejected_stack = np.sum(cr_rejected_locs, axis=0) # stack all located crs on top of eachother stacked_exposure_time = sum(exposure_times) stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, obs_id, propid, plot_dir) split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, obs_id, propid, plot_dir) @@ -216,7 +216,7 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time Parameters ---------- splits : list - list of cr placements in each subexposure (ie the cr_rejected_locs output of ocrrej_exam) + list of cr placements in each subexposure (ie the cr_rejected_locs output of ocrreject_exam) box_lower : array 1d array of ints of the bottom of the extraction box 0 indexed. @@ -303,26 +303,29 @@ def gen_color(cmap, n): return colorlist -def call_ocrrej_exam(): +def call_ocrreject_exam(): '''Command line usage of ocrreject_exam''' parser = argparse.ArgumentParser(description='Calculate fractions of cosmic ray rejected pixels inside and outside of an extraction box to test for cr algorithm failures.', epilog=f'v{__version__}; Written by {__author__}') parser.add_argument(dest='obsids', nargs='*', help='observation ids in ipppssoots format') - parser.add_argument('-d', dest='dir', default=None, - help="directory containing observation flt and sx1 files os.getcwd()+'/'") + parser.add_argument('-d', dest='dir', default=None, help="directory containing observation flt and sx1 files. Defaults to pwd and requires trailing /") + parser.add_argument('-p', dest='plot', help="option to create diagnostic plots", action='store_true') + parser.add_argument('-pd', dest='plot_dir', default=None, help="directory to store diagnostic plots if plot=True. Defaults to dir argument and requires trailing /") + args = parser.parse_args() + #TODO add check that obsid arg actually has stuff stored in it for obsid in args.obsids: - print(f'Analyzing {osbid}:') - result = ocrrej_exam(obsid, dir=args.dir) + print(f'\nAnalyzing {obsid}:') + result = ocrreject_exam(obsid, dir=args.dir, plot=args.plot, plot_dir=args.plot_dir) print('Fraction of Pixels Rejected as CRs') print(f" Average across all extraction boxes: {result['avg_extr_frac']:.1%}") print(f" Average across all external regions: {result['avg_outside_frac']:.1%}") - print(f" Average ratio between the two: {result['avg_ratio']:.1%}") + print(f" Average ratio between the two: {result['avg_ratio']:.2f}") if __name__ == '__main__': - call_ocrrej_exam() \ No newline at end of file + call_ocrreject_exam() \ No newline at end of file From c9804f4fbf9df38420ac9608789413941f8e44be Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Thu, 20 Jun 2024 13:18:20 -0400 Subject: [PATCH 05/27] Cleaned up docstrings --- stistools/ocrreject_exam.py | 292 ++++++++++++++++++------------------ 1 file changed, 148 insertions(+), 144 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index fa51fa1..2358b9c 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -12,59 +12,62 @@ __version__ = 1.0 def ocrreject_exam(obs_id, dir=None, plot=False, plot_dir=None): - ''' Compares the rate of cosmic rays in the extraction box and everywhere else - in a CCD spectroscopic image. Based on crrej_exam from STIS ISR 2019-02. - - Higher ratios of cosmic ray rates in the extraction box to the rest of the image - may indicate the need to rerun stistools.ocrreject() with different parameters. - - Parameters - ---------- - obsid : str - A STIS observation ID rootname in ipppssoot format (ie odvkl1040) - - dir : str - Directory containing both the flat fielded (_flt.fits) and extracted - spectrum (_sx1.fits or _x1d.fits) files of the observation. - Defaults to pwd and requires trailing / - - plot : bool - Option to generate diagnostic plots, default=False - - plot_dir : str - Directory to save diagnostic plots in if plot=True. - Defaults to dir parameter and requires trailing / - - Returns - ------- - results : dict - Dictionary containing - extr_fracs : cr rejection rates in the extraction boxes for each crsplit - outside_fracs : cr rejection rates outside the extraction boxes for each crsplit - ratios : extr_fracs/outside_fracs - avg_extr_frac : The average of extr_fracs - avg_outside_frac : The average of outside_fracs - avg_ratio : avg_extr_frac/avg_outside_frac - - If called from the command line, prints the avg extraction, outside, and ratio values for quick verification - - ''' + """Compares the rate of cosmic rays in the extraction box and everywhere else + in a CCD spectroscopic image. Based on crrej_exam from STIS ISR 2019-02. + + Higher ratios of cosmic ray rates in the extraction box to the rest of the image + may indicate the need to rerun stistools.ocrreject() with different parameters. + + Parameters + ---------- + obsid : str + A STIS observation ID rootname in ipppssoot format (ie odvkl1040) + + dir : str + Directory containing both the flat fielded (_flt.fits) and extracted + spectrum (_sx1.fits or _x1d.fits) files of the observation. + Defaults to pwd and requires trailing / + + plot : bool + Option to generate diagnostic plots, default=False + + plot_dir : str + Directory to save diagnostic plots in if plot=True. + Defaults to dir parameter and requires trailing / + + Returns + ------- + results : dict + Dictionary containing + extr_fracs : cr rejection rates in the extraction boxes for each crsplit + outside_fracs : cr rejection rates outside the extraction boxes for each crsplit + ratios : extr_fracs/outside_fracs + avg_extr_frac : The average of extr_fracs + avg_outside_frac : The average of outside_fracs + avg_ratio : avg_extr_frac/avg_outside_frac + + If called from the command line, prints the avg extraction, outside, and ratio values for quick verification. + + """ + if not dir: dir = os.getcwd()+'/' if not plot_dir: plot_dir = dir - # Get flt and sx1 filepaths + # Get flt and sx1/x1d filepaths flt_file = os.path.join(dir, obs_id+'_flt.fits') if not os.path.exists(flt_file): - raise IOError(f"No _flt file in {dir} for {obs_id}") + raise IOError(f"No _flt file in {dir} for {obs_id}") sx1_file = os.path.join(dir, obs_id+'_sx1.fits') if not os.path.exists(sx1_file): - raise IOError(f"No _sx1 file in {dir} for {obs_id}") + sx1_file = os.path.join(dir, obs_id+'_x1d.fits') # if sx1 doesn't exist check for custom made x1d + if not os.path.exists(sx1_file): + raise IOError(f"No _sx1 file in {dir} for {obs_id}") # Check that the number of sci extensions matches the number of crsplits with fits.open(flt_file) as flt_hdul: @@ -81,16 +84,16 @@ def ocrreject_exam(obs_id, dir=None, plot=False, plot_dir=None): spec = sx1_hdul[1].data[0] shdr = sx1_hdul[0].header - extrlocy = spec['EXTRLOCY']-1 # y coord floats of the middle of the extraction box - del_pix = spec['EXTRSIZE']/2. # float value the extraction box extends above or below extrlocy + extrlocy = spec['EXTRLOCY']-1 # y coords of the middle of the extraction box + del_pix = spec['EXTRSIZE']/2. # value the extraction box extends above or below extrlocy box_upper=np.ceil(extrlocy+del_pix).astype(int) # Ints of pixel values above end of trace bc python is upper bound exclusive box_lower=np.floor(extrlocy-del_pix).astype(int) # Ints of pixel values below end of trace # Fill each of these lists with values for each cr split - extr_fracs = [] # float of the fraction of pixels flagged as cr inside the extraction box - outside_fracs = [] # float of the fraction of pixels flagged as cr outside the extraction box + extr_fracs = [] # fraction of pixels flagged as cr inside the extraction box for each split + outside_fracs = [] # fraction of pixels flagged as cr outside the extraction box for each split cr_rejected_locs = [] # 2d array of 1s where a cr exists and 0 elsewhere - exposure_times = [] + exposure_times = [] with fits.open(flt_file) as flt_hdul: flt_shape = flt_hdul['sci', 1].data.shape # shape of the data @@ -148,142 +151,143 @@ def ocrreject_exam(obs_id, dir=None, plot=False, plot_dir=None): return results def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, propid, plot_dir): - """Creates a visualization of where cr pixels are in a stacked image + """Creates a visualization of where cr pixels are in a stacked image - Parameters - ---------- - stack_image : array - 2d array to plot. + Parameters + ---------- + stack_image : array + 2d array to plot. - box_lower : array - 1d array of ints of the bottom of the extraction box 0 indexed. + box_lower : array + 1d array of ints of the bottom of the extraction box 0 indexed. - box_upper : array - 1d array of ints of the top of the extraction box 0 indexed. + box_upper : array + 1d array of ints of the top of the extraction box 0 indexed. - split_num : int - Number of splits in the stack. + split_num : int + Number of splits in the stack. - texpt : float - Value of total exposure time. + texpt : float + Value of total exposure time. - obs_id : str - ipppssoot of observation + obs_id : str + ipppssoot of observation - propid : int - proposal id of observation + propid : int + proposal id of observation - plot_dir : str - Directory to save plot in. Requires trailing / - """ + plot_dir : str + Directory to save plot in. Requires trailing / - stack_shape = stack_image.shape - cmap = colors.ListedColormap(gen_color('turbo', split_num+1)) - bounds = np.arange(split_num+2) - norm = colors.BoundaryNorm(bounds, cmap.N) + """ - fig, (ax1,ax2,ax3) = plt.subplots(nrows=1, ncols=3, figsize=(9,20*(9/41)), gridspec_kw={'width_ratios': [1, 1, 0.05], 'height_ratios': [1]}) + stack_shape = stack_image.shape + cmap = colors.ListedColormap(gen_color('turbo', split_num+1)) + bounds = np.arange(split_num+2) + norm = colors.BoundaryNorm(bounds, cmap.N) - for axis in [ax1,ax2]: - axis.imshow(stack_image, interpolation='nearest', origin="lower", extent=(0, stack_shape[1], 0, stack_shape[0]), cmap=cmap, norm=norm, aspect='auto') - axis.step(np.arange(len(box_upper)), box_upper, color='w', where='post', lw=0.5, alpha=0.5, ls='--') - axis.step(np.arange(len(box_lower)), box_lower, color='w', where='post', lw=0.5, alpha=0.5, ls='--') + fig, (ax1,ax2,ax3) = plt.subplots(nrows=1, ncols=3, figsize=(9,20*(9/41)), gridspec_kw={'width_ratios': [1, 1, 0.05], 'height_ratios': [1]}) - ax1.set_title('Full image') + for axis in [ax1,ax2]: + axis.imshow(stack_image, interpolation='nearest', origin="lower", extent=(0, stack_shape[1], 0, stack_shape[0]), cmap=cmap, norm=norm, aspect='auto') + axis.step(np.arange(len(box_upper)), box_upper, color='w', where='post', lw=0.5, alpha=0.5, ls='--') + axis.step(np.arange(len(box_lower)), box_lower, color='w', where='post', lw=0.5, alpha=0.5, ls='--') - # If it is a large enough image, zoom the 2nd subplot around the extraction box region - if ((stack_shape[0] - max(box_upper)) > 20) and (min(box_lower) > 20): - ax2.set_ylim([(min(box_lower)-20),(max(box_upper)+20)]) - ax2.set_title('zoomed to 20 pixels above/below extraction box') + ax1.set_title('Full image') - # Otherwise just don't zoom in at all - else: - ax2.set_title('full image already 20 pixels above/below extraction box') + # If it is a large enough image, zoom the 2nd subplot around the extraction box region + if ((stack_shape[0] - max(box_upper)) > 20) and (min(box_lower) > 20): + ax2.set_ylim([(min(box_lower)-20),(max(box_upper)+20)]) + ax2.set_title('zoomed to 20 pixels above/below extraction box') - fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, label='# times flagged as cr') + # Otherwise just don't zoom in at all + else: + ax2.set_title('full image already 20 pixels above/below extraction box') - fig.suptitle('CR flagged pixels in stacked image: '+obs_id+'\n Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') - fig.tight_layout() + fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, label='# times flagged as cr') - plot_name = obs_id + '_stacked.png' - file_path = plot_dir + plot_name - plt.savefig(file_path, dpi=150) - plt.close() + fig.suptitle('CR flagged pixels in stacked image: '+obs_id+'\n Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') + fig.tight_layout() -def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_times, texpt, obs_id, propid, plot_dir): - """Creates a visualization of where cr pixels are in each subexposure + plot_name = obs_id + '_stacked.png' + file_path = plot_dir + plot_name + plt.savefig(file_path, dpi=150) + plt.close() - Parameters - ---------- - splits : list - list of cr placements in each subexposure (ie the cr_rejected_locs output of ocrreject_exam) +def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_times, texpt, obs_id, propid, plot_dir): + """Creates a visualization of where cr pixels are in each subexposure - box_lower : array - 1d array of ints of the bottom of the extraction box 0 indexed. + Parameters + ---------- + splits : list + list of cr placements in each subexposure (ie the cr_rejected_locs output of ocrreject_exam) - box_upper : array - 1d array of ints of the top of the extraction box 0 indexed. + box_lower : array + 1d array of ints of the bottom of the extraction box 0 indexed. - split_num : int - Number of splits in the stack, (ie len(cr_rejected_locs)). + box_upper : array + 1d array of ints of the top of the extraction box 0 indexed. - individual_exposure_times: list - List of exposure times for each subexposure + split_num : int + Number of splits in the stack, (ie len(cr_rejected_locs)). - texpt : float - Value of total exposure time + individual_exposure_times: list + List of exposure times for each subexposure - obs_id : str - ipppssoot of observation + texpt : float + Value of total exposure time - propid : int - proposal id of observation + obs_id : str + ipppssoot of observation - plot_dir : str - Directory to save plot in. Requires trailing / - """ + propid : int + proposal id of observation - # Define grid, dependent on number of splits: - if ((len(splits))%2) == 0: - nrows = (len(splits))/2 - else: - nrows = ((len(splits))+1)/2 + plot_dir : str + Directory to save plot in. Requires trailing / + """ - row_value = int(nrows) + # Define grid, dependent on number of splits: + if ((len(splits))%2) == 0: + nrows = (len(splits))/2 + else: + nrows = ((len(splits))+1)/2 - fig, ax = plt.subplots(nrows=row_value, ncols=2, figsize=(9, nrows*2)) - ax = ax.flatten() + row_value = int(nrows) - cmap = colors.ListedColormap(gen_color('autumn', 3)) - bounds = np.arange(4) - norm = colors.BoundaryNorm(bounds, cmap.N) + fig, ax = plt.subplots(nrows=row_value, ncols=2, figsize=(9, nrows*2)) + ax = ax.flatten() - # Plot each subexposure with cr pixels a different color - for num, axis in enumerate(ax): - if num 20) and (min(box_lower) >20): - axis.set_ylim([(min(box_lower)-20),(max(box_upper)+20)]) - axis.set_title('zoomed subexposure '+str(num+1)+', exposure time '+str(individual_exposure_times[num])) + # Plot each subexposure with cr pixels a different color + for num, axis in enumerate(ax): + if num 20) and (min(box_lower) >20): + axis.set_ylim([(min(box_lower)-20),(max(box_upper)+20)]) + axis.set_title('zoomed subexposure '+str(num+1)+', exposure time '+str(individual_exposure_times[num])) else: - axis.set_axis_off() + axis.set_title('subexposure '+str(num+1)+', exposure time '+str(individual_exposure_times[num])) + + else: + axis.set_axis_off() - fig.suptitle('CR flagged pixels in individual splits for: '+obs_id+ '\n Proposal '+str(propid)+', total exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') - fig.tight_layout() + fig.suptitle('CR flagged pixels in individual splits for: '+obs_id+ '\n Proposal '+str(propid)+', total exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') + fig.tight_layout() - plot_name = obs_id + '_splits.png' - file_path = plot_dir + plot_name - plt.savefig(file_path, dpi=150) - plt.close() + plot_name = obs_id + '_splits.png' + file_path = plot_dir + plot_name + plt.savefig(file_path, dpi=150) + plt.close() def gen_color(cmap, n): """Generates n distinct colors from a given colormap. @@ -304,7 +308,7 @@ def gen_color(cmap, n): return colorlist def call_ocrreject_exam(): - '''Command line usage of ocrreject_exam''' + """Command line usage of ocrreject_exam""" parser = argparse.ArgumentParser(description='Calculate fractions of cosmic ray rejected pixels inside and outside of an extraction box to test for cr algorithm failures.', epilog=f'v{__version__}; Written by {__author__}') From 9acb4ab8d139f15c5dc7e9b363ae98f3efaf92de Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Thu, 20 Jun 2024 15:45:19 -0400 Subject: [PATCH 06/27] Centered plot labels inbetween values for clarity --- stistools/ocrreject_exam.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 2358b9c..744bf8d 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -204,7 +204,8 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop else: ax2.set_title('full image already 20 pixels above/below extraction box') - fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, label='# times flagged as cr') + cb = fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, label='# times flagged as cr', ticks=np.arange(split_num, split_num+2)-0.5) + cb.set_ticklabels(np.arange(split_num, split_num+2)-1) fig.suptitle('CR flagged pixels in stacked image: '+obs_id+'\n Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') fig.tight_layout() From 4bb00c169f7ca9fc85439f237a087422be3ccd5b Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Fri, 11 Oct 2024 19:21:26 -0400 Subject: [PATCH 07/27] Added plotly functionality for stack plot --- stistools/ocrreject_exam.py | 302 +++++++++++++++++++++++++----------- 1 file changed, 210 insertions(+), 92 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 744bf8d..cfa41cb 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -1,5 +1,6 @@ #! /usr/bin/env python import os +import warnings import numpy as np import astropy.io.fits as fits import argparse @@ -8,10 +9,19 @@ import matplotlib.colors as colors import matplotlib.pyplot as plt +try: + import plotly.graph_objects as go + from plotly.subplots import make_subplots + HAS_PLOTLY = True +except ImportError: + HAS_PLOTLY = False + +USER_WARNED = False + __author__ = 'Joleen K. Carlberg & Matt Dallas' __version__ = 1.0 -def ocrreject_exam(obs_id, dir=None, plot=False, plot_dir=None): +def ocrreject_exam(obs_id, data_dir=None, plot=False, plot_dir=None, interactive=False): """Compares the rate of cosmic rays in the extraction box and everywhere else in a CCD spectroscopic image. Based on crrej_exam from STIS ISR 2019-02. @@ -23,7 +33,7 @@ def ocrreject_exam(obs_id, dir=None, plot=False, plot_dir=None): obsid : str A STIS observation ID rootname in ipppssoot format (ie odvkl1040) - dir : str + data_dir : str Directory containing both the flat fielded (_flt.fits) and extracted spectrum (_sx1.fits or _x1d.fits) files of the observation. Defaults to pwd and requires trailing / @@ -35,6 +45,9 @@ def ocrreject_exam(obs_id, dir=None, plot=False, plot_dir=None): Directory to save diagnostic plots in if plot=True. Defaults to dir parameter and requires trailing / + interactive : bool + Option to generate zoomable html plots using plotly, default=False + Returns ------- results : dict @@ -49,25 +62,26 @@ def ocrreject_exam(obs_id, dir=None, plot=False, plot_dir=None): If called from the command line, prints the avg extraction, outside, and ratio values for quick verification. """ + global USER_WARNED - if not dir: - dir = os.getcwd()+'/' + if not data_dir: + data_dir = os.getcwd()+'/' if not plot_dir: - plot_dir = dir + plot_dir = data_dir # Get flt and sx1/x1d filepaths - flt_file = os.path.join(dir, obs_id+'_flt.fits') + flt_file = os.path.join(data_dir, obs_id+'_flt.fits') if not os.path.exists(flt_file): - raise IOError(f"No _flt file in {dir} for {obs_id}") + raise IOError(f"No _flt file in {data_dir} for {obs_id}") - sx1_file = os.path.join(dir, obs_id+'_sx1.fits') + sx1_file = os.path.join(data_dir, obs_id+'_sx1.fits') if not os.path.exists(sx1_file): - sx1_file = os.path.join(dir, obs_id+'_x1d.fits') # if sx1 doesn't exist check for custom made x1d + sx1_file = os.path.join(data_dir, obs_id+'_x1d.fits') # if sx1 doesn't exist check for custom made x1d if not os.path.exists(sx1_file): - raise IOError(f"No _sx1 file in {dir} for {obs_id}") + raise IOError(f"No _sx1 file in {data_dir} for {obs_id}") # Check that the number of sci extensions matches the number of crsplits with fits.open(flt_file) as flt_hdul: @@ -142,15 +156,70 @@ def ocrreject_exam(obs_id, dir=None, plot=False, plot_dir=None): results ={'extr_fracs':extr_fracs, 'outside_fracs':outside_fracs, 'ratios':ratios, 'avg_extr_frac':avg_extr_frac, 'avg_outside_frac':avg_outside_frac, 'avg_ratio':avg_ratio} - if plot: + if plot and not interactive: # case with interactive = false + cr_rejected_stack = np.sum(cr_rejected_locs, axis=0) # stack all located crs on top of eachother + stacked_exposure_time = sum(exposure_times) + stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, obs_id, propid, plot_dir, interactive=interactive) + split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, obs_id, propid, plot_dir, interactive=interactive) + + elif plot and interactive and HAS_PLOTLY: # case with interactive = True and plotly is installed cr_rejected_stack = np.sum(cr_rejected_locs, axis=0) # stack all located crs on top of eachother stacked_exposure_time = sum(exposure_times) - stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, obs_id, propid, plot_dir) - split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, obs_id, propid, plot_dir) + stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, obs_id, propid, plot_dir, interactive=interactive) + split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, obs_id, propid, plot_dir, interactive=interactive) + + elif plot and interactive and not USER_WARNED: # case with interactive = True and plotly is not installed + warnings.warn('Plotly required for intercative plotting') + USER_WARNED = True return results -def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, propid, plot_dir): +# Plotting specific functions: +def gen_color(cmap, n): + """Generates n distinct colors from a given colormap. + + Based on mycolorpy's gen_color() from https://github.com/binodbhttr/mycolorpy""" + + c_map = colormaps[cmap] + colorlist = [] + + for c in np.linspace(0,1,n): + rgba=c_map(c) # select the rgba value of the cmap at point c which is a number between 0 to 1 + clr=colors.rgb2hex(rgba) # convert to hex + colorlist.append(str(clr)) # create a list of these colors + + colorlist.pop(0) # Make it dark grey rather than black at the beginning (I think it's easier on the eyes) + colorlist.insert(0, '#A9A9A9') + + return colorlist + +def discrete_colorscale(bvals, colors): + """Takes desired boundary values and colors from a matplotlib colorplot and makes a plotly colorscale. + + Based on discrete_colorscale() from https://community.plot.ly/t/colors-for-discrete-ranges-in-heatmaps/7780""" + + if len(bvals) != len(colors)+1: + raise ValueError('len(boundary values) should be equal to len(colors)+1') + bvals = sorted(bvals) + nvals = [(v-bvals[0])/(bvals[-1]-bvals[0]) for v in bvals] # normalized values + + dcolorscale = [] # discrete colorscale + for k in range(len(colors)): + dcolorscale.extend([[nvals[k], colors[k]], [nvals[k+1], colors[k]]]) + + return dcolorscale + +def generate_intervals(n, divisions): + """Creates a list of strings that are the positions requred for centering an evenly spaced colorbar in plotly""" + + result = np.linspace(0, n, divisions, endpoint=False) + offset = (result[1]-result[0])/2 + result = result + offset + result = [str(x) for x in list(result)[:len(list(result))]] + + return result + +def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, propid, plot_dir, interactive): """Creates a visualization of where cr pixels are in a stacked image Parameters @@ -179,43 +248,103 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop plot_dir : str Directory to save plot in. Requires trailing / + interactive : bool + If True, uses plotly to create an interactive zoomable html plot """ + + if not interactive: + stack_shape = stack_image.shape + cmap = colors.ListedColormap(gen_color('turbo', split_num+1)) + bounds = np.arange(split_num+2) + norm = colors.BoundaryNorm(bounds, cmap.N) - stack_shape = stack_image.shape - cmap = colors.ListedColormap(gen_color('turbo', split_num+1)) - bounds = np.arange(split_num+2) - norm = colors.BoundaryNorm(bounds, cmap.N) + fig, (ax1,ax2,ax3) = plt.subplots(nrows=1, ncols=3, figsize=(9,20*(9/41)), gridspec_kw={'width_ratios': [1, 1, 0.05], 'height_ratios': [1]}) - fig, (ax1,ax2,ax3) = plt.subplots(nrows=1, ncols=3, figsize=(9,20*(9/41)), gridspec_kw={'width_ratios': [1, 1, 0.05], 'height_ratios': [1]}) + for axis in [ax1,ax2]: + axis.imshow(stack_image, interpolation='none', origin="lower", extent=(0, stack_shape[1], 0, stack_shape[0]), cmap=cmap, norm=norm, aspect='auto') + axis.step(np.arange(len(box_upper)), box_upper, color='w', where='post', lw=0.5, alpha=0.5, ls='--') + axis.step(np.arange(len(box_lower)), box_lower, color='w', where='post', lw=0.5, alpha=0.5, ls='--') + + ax1.set_title('Full image') - for axis in [ax1,ax2]: - axis.imshow(stack_image, interpolation='nearest', origin="lower", extent=(0, stack_shape[1], 0, stack_shape[0]), cmap=cmap, norm=norm, aspect='auto') - axis.step(np.arange(len(box_upper)), box_upper, color='w', where='post', lw=0.5, alpha=0.5, ls='--') - axis.step(np.arange(len(box_lower)), box_lower, color='w', where='post', lw=0.5, alpha=0.5, ls='--') + # If it is a large enough image, zoom the 2nd subplot around the extraction box region + if ((stack_shape[0] - max(box_upper)) > 20) and (min(box_lower) > 20): + ax2.set_ylim([(min(box_lower)-20),(max(box_upper)+20)]) + ax2.set_title('zoomed to 20 pixels above/below extraction box') + + # Otherwise just don't zoom in at all + else: + ax2.set_title('full image already 20 pixels above/below extraction box') - ax1.set_title('Full image') + cb = fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, label='# times flagged as cr', ticks=np.arange(split_num, split_num+2)-0.5) + cb.set_ticklabels(np.arange(split_num, split_num+2)-1) - # If it is a large enough image, zoom the 2nd subplot around the extraction box region - if ((stack_shape[0] - max(box_upper)) > 20) and (min(box_lower) > 20): - ax2.set_ylim([(min(box_lower)-20),(max(box_upper)+20)]) - ax2.set_title('zoomed to 20 pixels above/below extraction box') + fig.suptitle('CR flagged pixels in stacked image: '+obs_id+'\n Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') + fig.tight_layout() - # Otherwise just don't zoom in at all + plot_name = obs_id + '_stacked.png' + file_path = plot_dir + plot_name + plt.savefig(file_path, dpi=150, bbox_inches='tight') + plt.close() + else: - ax2.set_title('full image already 20 pixels above/below extraction box') + stack_shape = stack_image.shape + cmap = colors.ListedColormap(gen_color('turbo', split_num+1)) + bounds = np.arange(split_num+2) + norm = colors.BoundaryNorm(bounds, cmap.N) + + # Create plotly image + fig = go.Figure() + + # calculate required x and y range, colorbar info, and figure titles + x = np.arange(start=0, stop=stack_shape[1]+1, step=1) + y = np.arange(start=0, stop=stack_shape[0]+1, step=1) + + dcolorsc = discrete_colorscale(bvals=list(bounds), colors=cmap.colors) + + ticktext = [str(x) for x in list(bounds)[:len(list(bounds))-1]] + tickvals = generate_intervals(len(ticktext)-1, len(ticktext)) + + title_text = 'CR flagged pixels in stacked image: '+obs_id+'
'+'Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures' + plot_name = obs_id + '_stacked.html' + file_path = plot_dir + plot_name + + # add image of detector + fig.add_trace(go.Heatmap(z=stack_image, colorscale=dcolorsc, x=x, y=y, hoverinfo='text', colorbar={'tickvals':tickvals, 'ticktext':ticktext, 'title':{'text':'# times flagged as cr', 'side':'right', 'font':{'size':18}}}, name='')) + + # add extraction box + fig.add_trace(go.Scatter(x=np.arange(len(box_upper)),y=box_upper,mode="lines",line=go.scatter.Line(color='white', dash='dash'),showlegend=False, opacity=0.75, line_shape='hv', name='extraction box')) + fig.add_trace(go.Scatter(x=np.arange(len(box_lower)),y=box_lower,mode="lines",line=go.scatter.Line(color='white', dash='dash'),showlegend=False, opacity=0.75, line_shape='hv', name='extraction box')) + + # y-axis zoom ranges + zoom_options = [{'label':'Full Detector', 'yaxis_range':[0, stack_shape[0]]}, + {'label':'Extraction Box', 'yaxis_range':[(min(box_lower)-20), (max(box_upper)+20)]}] + + # Add the toggle buttons using `updatemenus` + button_options = [{'label':zoom_options[0]['label'], 'method':'relayout', 'args':[{'yaxis.range':zoom_options[0]['yaxis_range']}]}, + {'label':zoom_options[1]['label'], 'method':'relayout', 'args':[{'yaxis.range': zoom_options[1]['yaxis_range']}]}] - cb = fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, label='# times flagged as cr', ticks=np.arange(split_num, split_num+2)-0.5) - cb.set_ticklabels(np.arange(split_num, split_num+2)-1) + fig.update_layout(updatemenus=[{'type':'dropdown', + 'direction':'down', + 'buttons':button_options, + 'pad':{'r':0, 't':0}, + 'showactive':True, + 'x': 1.07, # Position of the buttons- also might require some more tweaking + 'xanchor':'right', + 'y': 1.07, + 'yanchor':'top'}]) - fig.suptitle('CR flagged pixels in stacked image: '+obs_id+'\n Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') - fig.tight_layout() + # Set the initial y-axis range (Full View) + fig.update_yaxes(range=[0, stack_shape[0]]) + fig.update_xaxes(range=[0, stack_shape[1]]) - plot_name = obs_id + '_stacked.png' - file_path = plot_dir + plot_name - plt.savefig(file_path, dpi=150) - plt.close() + fig.update_layout(width=stack_shape[1]+ 50, height=int(stack_shape[1] * stack_shape[1] / stack_shape[0]) ) # adds space for colorbar to not squeeze the x axis -def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_times, texpt, obs_id, propid, plot_dir): + fig.update_layout(title={'text':title_text, 'x':0.5}, font={'family':'Arial, sans-serif', 'size':16}) + + fig.write_html(file_path) + +def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_times, texpt, obs_id, propid, plot_dir, interactive): """Creates a visualization of where cr pixels are in each subexposure Parameters @@ -246,67 +375,55 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time plot_dir : str Directory to save plot in. Requires trailing / + + interactive : bool + If True, uses plotly to create an interactive zoomable html plot """ + if not interactive: + # Define grid, dependent on number of splits: + if ((len(splits))%2) == 0: + nrows = (len(splits))/2 + else: + nrows = ((len(splits))+1)/2 - # Define grid, dependent on number of splits: - if ((len(splits))%2) == 0: - nrows = (len(splits))/2 - else: - nrows = ((len(splits))+1)/2 + row_value = int(nrows) - row_value = int(nrows) + fig, ax = plt.subplots(nrows=row_value, ncols=2, figsize=(9, nrows*2)) + ax = ax.flatten() - fig, ax = plt.subplots(nrows=row_value, ncols=2, figsize=(9, nrows*2)) - ax = ax.flatten() + cmap = colors.ListedColormap(gen_color('autumn', 3)) + bounds = np.arange(4) + norm = colors.BoundaryNorm(bounds, cmap.N) - cmap = colors.ListedColormap(gen_color('autumn', 3)) - bounds = np.arange(4) - norm = colors.BoundaryNorm(bounds, cmap.N) + # Plot each subexposure with cr pixels a different color + for num, axis in enumerate(ax): + if num 20) and (min(box_lower) >20): + axis.set_ylim([(min(box_lower)-20),(max(box_upper)+20)]) + axis.set_title('zoomed subexposure '+str(num+1)+', exposure time '+str(individual_exposure_times[num])) - if ((splits[num].shape[0] - max(box_upper)) > 20) and (min(box_lower) >20): - axis.set_ylim([(min(box_lower)-20),(max(box_upper)+20)]) - axis.set_title('zoomed subexposure '+str(num+1)+', exposure time '+str(individual_exposure_times[num])) + else: + axis.set_title('subexposure '+str(num+1)+', exposure time '+str(individual_exposure_times[num])) else: - axis.set_title('subexposure '+str(num+1)+', exposure time '+str(individual_exposure_times[num])) - - else: - axis.set_axis_off() - - fig.suptitle('CR flagged pixels in individual splits for: '+obs_id+ '\n Proposal '+str(propid)+', total exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') - fig.tight_layout() + axis.set_axis_off() - plot_name = obs_id + '_splits.png' - file_path = plot_dir + plot_name - plt.savefig(file_path, dpi=150) - plt.close() + fig.suptitle('CR flagged pixels in individual splits for: '+obs_id+ '\n Proposal '+str(propid)+', total exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') + fig.tight_layout() -def gen_color(cmap, n): - """Generates n distinct colors from a given colormap. - - Based on mycolorpy's gen_color() from https://github.com/binodbhttr/mycolorpy""" - - c_map = colormaps[cmap] - colorlist = [] + plot_name = obs_id + '_splits.png' + file_path = plot_dir + plot_name + plt.savefig(file_path, dpi=150, bbox_inches='tight') + plt.close() - for c in np.linspace(0,1,n): - rgba=c_map(c) # select the rgba value of the cmap at point c which is a number between 0 to 1 - clr=colors.rgb2hex(rgba) # convert to hex - colorlist.append(str(clr)) # create a list of these colors - - colorlist.pop(0) # Make it dark grey rather than black at the beginning (I think it's easier on the eyes) - colorlist.insert(0, '#A9A9A9') - - return colorlist + else: + print('have not figured this out yet!') def call_ocrreject_exam(): """Command line usage of ocrreject_exam""" @@ -315,16 +432,17 @@ def call_ocrreject_exam(): epilog=f'v{__version__}; Written by {__author__}') parser.add_argument(dest='obsids', nargs='*', help='observation ids in ipppssoots format') - parser.add_argument('-d', dest='dir', default=None, help="directory containing observation flt and sx1 files. Defaults to pwd and requires trailing /") + parser.add_argument('-d', dest='data_dir', default=None, help="directory containing observation flt and sx1 files. Defaults to pwd and requires trailing /") parser.add_argument('-p', dest='plot', help="option to create diagnostic plots", action='store_true') - parser.add_argument('-pd', dest='plot_dir', default=None, help="directory to store diagnostic plots if plot=True. Defaults to dir argument and requires trailing /") - + parser.add_argument('-pd', dest='plot_dir', default=None, help="directory to store diagnostic plots if plot=True. Defaults to data_dir argument and requires trailing /") + parser.add_argument('-i', dest='interactive', default=False, help="option to create zoomable html plots instead of static pngs. Defaults to False and requires plotly if True") + args = parser.parse_args() #TODO add check that obsid arg actually has stuff stored in it for obsid in args.obsids: print(f'\nAnalyzing {obsid}:') - result = ocrreject_exam(obsid, dir=args.dir, plot=args.plot, plot_dir=args.plot_dir) + result = ocrreject_exam(obsid, data_dir=args.data_dir, plot=args.plot, plot_dir=args.plot_dir, interactive=args.interactive) print('Fraction of Pixels Rejected as CRs') print(f" Average across all extraction boxes: {result['avg_extr_frac']:.1%}") From 6775bb46476617d651bd6971b6e164f097934493 Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Tue, 15 Oct 2024 16:08:17 -0400 Subject: [PATCH 08/27] Added interactive split plot functionality --- stistools/ocrreject_exam.py | 96 ++++++++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 27 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index cfa41cb..4885f29 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -252,12 +252,15 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop If True, uses plotly to create an interactive zoomable html plot """ + stack_shape = stack_image.shape + max_stack_value = int(np.max(stack_image)) # This is usually equal to stack_shape, + # in the case where a cr pixel is not in all splits at the same location this value should be used + cmap = colors.ListedColormap(gen_color('turbo', max_stack_value+1)) + bounds = np.arange(max_stack_value+2) + norm = colors.BoundaryNorm(bounds, cmap.N) + if not interactive: - stack_shape = stack_image.shape - cmap = colors.ListedColormap(gen_color('turbo', split_num+1)) - bounds = np.arange(split_num+2) - norm = colors.BoundaryNorm(bounds, cmap.N) - + # create matplotlib image fig, (ax1,ax2,ax3) = plt.subplots(nrows=1, ncols=3, figsize=(9,20*(9/41)), gridspec_kw={'width_ratios': [1, 1, 0.05], 'height_ratios': [1]}) for axis in [ax1,ax2]: @@ -276,8 +279,8 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop else: ax2.set_title('full image already 20 pixels above/below extraction box') - cb = fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, label='# times flagged as cr', ticks=np.arange(split_num, split_num+2)-0.5) - cb.set_ticklabels(np.arange(split_num, split_num+2)-1) + cb = fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, label='# times flagged as cr', ticks=np.arange(max_stack_value, max_stack_value+2)-0.5) + cb.set_ticklabels(np.arange(max_stack_value, max_stack_value+2)-1) fig.suptitle('CR flagged pixels in stacked image: '+obs_id+'\n Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') fig.tight_layout() @@ -288,11 +291,6 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop plt.close() else: - stack_shape = stack_image.shape - cmap = colors.ListedColormap(gen_color('turbo', split_num+1)) - bounds = np.arange(split_num+2) - norm = colors.BoundaryNorm(bounds, cmap.N) - # Create plotly image fig = go.Figure() @@ -320,7 +318,7 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop zoom_options = [{'label':'Full Detector', 'yaxis_range':[0, stack_shape[0]]}, {'label':'Extraction Box', 'yaxis_range':[(min(box_lower)-20), (max(box_upper)+20)]}] - # Add the toggle buttons using `updatemenus` + # Add the toggle buttons button_options = [{'label':zoom_options[0]['label'], 'method':'relayout', 'args':[{'yaxis.range':zoom_options[0]['yaxis_range']}]}, {'label':zoom_options[1]['label'], 'method':'relayout', 'args':[{'yaxis.range': zoom_options[1]['yaxis_range']}]}] @@ -339,9 +337,7 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop fig.update_xaxes(range=[0, stack_shape[1]]) fig.update_layout(width=stack_shape[1]+ 50, height=int(stack_shape[1] * stack_shape[1] / stack_shape[0]) ) # adds space for colorbar to not squeeze the x axis - fig.update_layout(title={'text':title_text, 'x':0.5}, font={'family':'Arial, sans-serif', 'size':16}) - fig.write_html(file_path) def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_times, texpt, obs_id, propid, plot_dir, interactive): @@ -379,22 +375,22 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time interactive : bool If True, uses plotly to create an interactive zoomable html plot """ - if not interactive: - # Define grid, dependent on number of splits: - if ((len(splits))%2) == 0: - nrows = (len(splits))/2 - else: - nrows = ((len(splits))+1)/2 + cmap = colors.ListedColormap(gen_color('autumn', 3)) + bounds = np.arange(4) + norm = colors.BoundaryNorm(bounds, cmap.N) + + # Define grid, dependent on number of splits: + if ((len(splits))%2) == 0: + nrows = (len(splits))/2 + else: + nrows = ((len(splits))+1)/2 - row_value = int(nrows) + row_value = int(nrows) + if not interactive: fig, ax = plt.subplots(nrows=row_value, ncols=2, figsize=(9, nrows*2)) ax = ax.flatten() - cmap = colors.ListedColormap(gen_color('autumn', 3)) - bounds = np.arange(4) - norm = colors.BoundaryNorm(bounds, cmap.N) - # Plot each subexposure with cr pixels a different color for num, axis in enumerate(ax): if num'+'Proposal '+str(propid)+', total exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures' + plot_name = obs_id + '_splits.html' + file_path = plot_dir + plot_name + + # Make plotly figure + fig = make_subplots(row_value, 2, horizontal_spacing=0.15, subplot_titles=subplot_titles) + + # Set up discrete color values + dcolorsc = discrete_colorscale(bvals=list(bounds[:-1]), colors=cmap.colors[:-1]) + + # Add plots in each subplot + row_iterator = 1 + for num, split in enumerate(splits): + # calculate required x and y range to not center the pixels at 0,0 + x = np.arange(start=0, stop=split.shape[1]+1, step=1) + y = np.arange(start=0, stop=split.shape[0]+1, step=1) + + # determine correct row, column to put the plot in + if (num+1)%2 != 0: + current_row = row_iterator + row_iterator+=1 + else: + current_row = current_row + + if (num+1)%2 == 0: + current_column = 2 + else: + current_column = 1 + + # plot the pixel of each split and the extraction box values + fig.add_trace(go.Heatmap(z=split, colorscale=dcolorsc, showscale=False, x=x, y=y, hoverinfo='text'), current_row, current_column) + fig.add_trace(go.Scatter(x=np.arange(len(box_upper)),y=box_upper,mode="lines",line=go.scatter.Line(color='white', dash='dash'),showlegend=False, opacity=0.75, line_shape='hv', name='extraction box'), current_row, current_column) + fig.add_trace(go.Scatter(x=np.arange(len(box_lower)),y=box_lower,mode="lines",line=go.scatter.Line(color='white', dash='dash'),showlegend=False, opacity=0.75, line_shape='hv', name='extraction box'), current_row, current_column) + + # zoom the plot to near the extraction region + fig.update_yaxes(range=[min(box_lower)-20,max(box_upper)+20]) + fig.update_xaxes(range=[0, split.shape[1]]) + + # make plots zoom at the same time + fig.update_xaxes(matches='x') + fig.update_yaxes(matches='y') + + fig.update_layout(width=split.shape[1]*1.75, height=((((max(box_upper)+20)- (min(box_lower)-20))*3*len(splits)))) + fig.update_layout(title={'text':title_text, 'x':0.5, 'y':1.0-(0.15/len(splits))}, font={'family':'Arial, sans-serif', 'size':16}, title_pad={'b': 20*len(splits)}) + fig.write_html(file_path) def call_ocrreject_exam(): """Command line usage of ocrreject_exam""" From 86d2f15f3636ab2dfb225ee0ac931bb1d43bcf7f Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Tue, 15 Oct 2024 18:17:31 -0400 Subject: [PATCH 09/27] Added flt and sx1 arguments instead of just obsid --- stistools/ocrreject_exam.py | 46 ++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 4885f29..b7a6024 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -21,7 +21,7 @@ __author__ = 'Joleen K. Carlberg & Matt Dallas' __version__ = 1.0 -def ocrreject_exam(obs_id, data_dir=None, plot=False, plot_dir=None, interactive=False): +def ocrreject_exam(obs_id=None, data_dir=None, flt=None, sx1=None, plot=False, plot_dir=None, interactive=False): """Compares the rate of cosmic rays in the extraction box and everywhere else in a CCD spectroscopic image. Based on crrej_exam from STIS ISR 2019-02. @@ -38,6 +38,12 @@ def ocrreject_exam(obs_id, data_dir=None, plot=False, plot_dir=None, interactive spectrum (_sx1.fits or _x1d.fits) files of the observation. Defaults to pwd and requires trailing / + flt : str + Path to flt file. Useful if flt and sx1 are in different locations or have custom names. + + sx1 : str + Path to sx1 file. Useful if flt and sx1 are in different locations or have custom names. + plot : bool Option to generate diagnostic plots, default=False @@ -70,22 +76,34 @@ def ocrreject_exam(obs_id, data_dir=None, plot=False, plot_dir=None, interactive if not plot_dir: plot_dir = data_dir - # Get flt and sx1/x1d filepaths - flt_file = os.path.join(data_dir, obs_id+'_flt.fits') + if obs_id is None: + if flt is None or sx1 is None: + raise ValueError("If 'obs_id' is not provided, both 'flt' and 'sx1' must be specified.") + else: + flt_file = flt + sx1_file = sx1 + + elif obs_id is not None: + if flt is not None or sx1 is not None: + raise ValueError("If 'obs_id' is provided, both 'flt' and 'sx1' must not be provided.") + else: + # Get flt and sx1/x1d filepaths + flt_file = os.path.join(data_dir, obs_id+'_flt.fits') - if not os.path.exists(flt_file): - raise IOError(f"No _flt file in {data_dir} for {obs_id}") + if not os.path.exists(flt_file): + raise IOError(f"No _flt file in {data_dir} for {obs_id}") - sx1_file = os.path.join(data_dir, obs_id+'_sx1.fits') + sx1_file = os.path.join(data_dir, obs_id+'_sx1.fits') - if not os.path.exists(sx1_file): - sx1_file = os.path.join(data_dir, obs_id+'_x1d.fits') # if sx1 doesn't exist check for custom made x1d - if not os.path.exists(sx1_file): - raise IOError(f"No _sx1 file in {data_dir} for {obs_id}") + if not os.path.exists(sx1_file): + sx1_file = os.path.join(data_dir, obs_id+'_x1d.fits') # if sx1 doesn't exist check for custom made x1d + if not os.path.exists(sx1_file): + raise IOError(f"No _sx1 file in {data_dir} for {obs_id}") # Check that the number of sci extensions matches the number of crsplits with fits.open(flt_file) as flt_hdul: propid = flt_hdul[0].header['PROPOSID'] + rootname = flt_hdul[0].header['ROOTNAME'] nrptexp_num = flt_hdul[0].header['NRPTEXP'] crsplit_num = flt_hdul[0].header['CRSPLIT'] sci_num = len([hdu.name for hdu in flt_hdul if "SCI" in hdu.name]) # Counts the number of sci extensions @@ -159,14 +177,14 @@ def ocrreject_exam(obs_id, data_dir=None, plot=False, plot_dir=None, interactive if plot and not interactive: # case with interactive = false cr_rejected_stack = np.sum(cr_rejected_locs, axis=0) # stack all located crs on top of eachother stacked_exposure_time = sum(exposure_times) - stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, obs_id, propid, plot_dir, interactive=interactive) - split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, obs_id, propid, plot_dir, interactive=interactive) + stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) + split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) elif plot and interactive and HAS_PLOTLY: # case with interactive = True and plotly is installed cr_rejected_stack = np.sum(cr_rejected_locs, axis=0) # stack all located crs on top of eachother stacked_exposure_time = sum(exposure_times) - stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, obs_id, propid, plot_dir, interactive=interactive) - split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, obs_id, propid, plot_dir, interactive=interactive) + stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) + split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) elif plot and interactive and not USER_WARNED: # case with interactive = True and plotly is not installed warnings.warn('Plotly required for intercative plotting') From 8bb2f72bebf942c03f84a50a9672f872c2828610 Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Wed, 16 Oct 2024 13:27:56 -0400 Subject: [PATCH 10/27] Updated plotting colors --- stistools/ocrreject_exam.py | 79 +++++++++++++++++++++++-------------- 1 file changed, 49 insertions(+), 30 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index b7a6024..a37c3e7 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -4,7 +4,6 @@ import numpy as np import astropy.io.fits as fits import argparse -from matplotlib import colormaps import matplotlib.cm as colormap import matplotlib.colors as colors import matplotlib.pyplot as plt @@ -198,16 +197,14 @@ def gen_color(cmap, n): Based on mycolorpy's gen_color() from https://github.com/binodbhttr/mycolorpy""" - c_map = colormaps[cmap] colorlist = [] - - for c in np.linspace(0,1,n): - rgba=c_map(c) # select the rgba value of the cmap at point c which is a number between 0 to 1 - clr=colors.rgb2hex(rgba) # convert to hex - colorlist.append(str(clr)) # create a list of these colors - colorlist.pop(0) # Make it dark grey rather than black at the beginning (I think it's easier on the eyes) - colorlist.insert(0, '#A9A9A9') + for c in cmap.colors[0:n]: + clr = colors.rgb2hex(c) # convert to hex + colorlist.append(str(clr)) # create a list of these colors + + colorlist.pop(0) # Make it light grey rather than black at the beginning (I think it's easier on the eyes) + colorlist.insert(0, '#F5F5F5') return colorlist @@ -273,7 +270,8 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop stack_shape = stack_image.shape max_stack_value = int(np.max(stack_image)) # This is usually equal to stack_shape, # in the case where a cr pixel is not in all splits at the same location this value should be used - cmap = colors.ListedColormap(gen_color('turbo', max_stack_value+1)) + custom_cmap = colors.ListedColormap(['k', 'tab:orange', 'tab:blue', 'tab:green', 'tab:red', 'tab:cyan', 'tab:olive', 'tab:purple', 'tab:pink', 'tab:brown', 'tab:grey']) + cmap = colors.ListedColormap(gen_color(custom_cmap, max_stack_value+1)) bounds = np.arange(max_stack_value+2) norm = colors.BoundaryNorm(bounds, cmap.N) @@ -283,8 +281,8 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop for axis in [ax1,ax2]: axis.imshow(stack_image, interpolation='none', origin="lower", extent=(0, stack_shape[1], 0, stack_shape[0]), cmap=cmap, norm=norm, aspect='auto') - axis.step(np.arange(len(box_upper)), box_upper, color='w', where='post', lw=0.5, alpha=0.5, ls='--') - axis.step(np.arange(len(box_lower)), box_lower, color='w', where='post', lw=0.5, alpha=0.5, ls='--') + axis.step(np.arange(len(box_upper)), box_upper, color='#222222', where='post', lw=0.7, alpha=0.7, ls='--') + axis.step(np.arange(len(box_lower)), box_lower, color='#222222', where='post', lw=0.7, alpha=0.7, ls='--') ax1.set_title('Full image') @@ -329,8 +327,8 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop fig.add_trace(go.Heatmap(z=stack_image, colorscale=dcolorsc, x=x, y=y, hoverinfo='text', colorbar={'tickvals':tickvals, 'ticktext':ticktext, 'title':{'text':'# times flagged as cr', 'side':'right', 'font':{'size':18}}}, name='')) # add extraction box - fig.add_trace(go.Scatter(x=np.arange(len(box_upper)),y=box_upper,mode="lines",line=go.scatter.Line(color='white', dash='dash'),showlegend=False, opacity=0.75, line_shape='hv', name='extraction box')) - fig.add_trace(go.Scatter(x=np.arange(len(box_lower)),y=box_lower,mode="lines",line=go.scatter.Line(color='white', dash='dash'),showlegend=False, opacity=0.75, line_shape='hv', name='extraction box')) + fig.add_trace(go.Scatter(x=np.arange(len(box_upper)),y=box_upper,mode="lines",line=go.scatter.Line(color='#222222', dash='dash'),showlegend=False, opacity=0.7, line_shape='hv', name='extraction box')) + fig.add_trace(go.Scatter(x=np.arange(len(box_lower)),y=box_lower,mode="lines",line=go.scatter.Line(color='#222222', dash='dash'),showlegend=False, opacity=0.7, line_shape='hv', name='extraction box')) # y-axis zoom ranges zoom_options = [{'label':'Full Detector', 'yaxis_range':[0, stack_shape[0]]}, @@ -393,7 +391,10 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time interactive : bool If True, uses plotly to create an interactive zoomable html plot """ - cmap = colors.ListedColormap(gen_color('autumn', 3)) + + custom_cmap = colors.ListedColormap(['k', 'tab:orange', 'tab:blue', 'tab:green', 'tab:red', 'tab:cyan', 'tab:olive', 'tab:purple', 'tab:pink', 'tab:brown', 'tab:grey']) + cmap = colors.ListedColormap(gen_color(custom_cmap, 3)) + bounds = np.arange(4) norm = colors.BoundaryNorm(bounds, cmap.N) @@ -415,8 +416,8 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time axis.imshow(splits[num], interpolation='none', origin='lower', extent=(0, splits[num].shape[1], 0, splits[num].shape[0]), cmap=cmap, norm=norm, aspect='auto') - axis.step(np.arange(len(box_upper)), box_upper, color='w', where='post', lw=0.5, alpha=0.5, ls='--') - axis.step(np.arange(len(box_lower)), box_lower, color='w', where='post', lw=0.5, alpha=0.5, ls='--') + axis.step(np.arange(len(box_upper)), box_upper, color='#222222', where='post', lw=0.7, alpha=0.7, ls='--') + axis.step(np.arange(len(box_lower)), box_lower, color='#222222', where='post', lw=0.7, alpha=0.7, ls='--') if ((splits[num].shape[0] - max(box_upper)) > 20) and (min(box_lower) >20): axis.set_ylim([(min(box_lower)-20),(max(box_upper)+20)]) @@ -470,8 +471,8 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time # plot the pixel of each split and the extraction box values fig.add_trace(go.Heatmap(z=split, colorscale=dcolorsc, showscale=False, x=x, y=y, hoverinfo='text'), current_row, current_column) - fig.add_trace(go.Scatter(x=np.arange(len(box_upper)),y=box_upper,mode="lines",line=go.scatter.Line(color='white', dash='dash'),showlegend=False, opacity=0.75, line_shape='hv', name='extraction box'), current_row, current_column) - fig.add_trace(go.Scatter(x=np.arange(len(box_lower)),y=box_lower,mode="lines",line=go.scatter.Line(color='white', dash='dash'),showlegend=False, opacity=0.75, line_shape='hv', name='extraction box'), current_row, current_column) + fig.add_trace(go.Scatter(x=np.arange(len(box_upper)),y=box_upper,mode="lines",line=go.scatter.Line(color='#222222', dash='dash'),showlegend=False, opacity=0.7, line_shape='hv', name='extraction box'), current_row, current_column) + fig.add_trace(go.Scatter(x=np.arange(len(box_lower)),y=box_lower,mode="lines",line=go.scatter.Line(color='#222222', dash='dash'),showlegend=False, opacity=0.7, line_shape='hv', name='extraction box'), current_row, current_column) # zoom the plot to near the extraction region fig.update_yaxes(range=[min(box_lower)-20,max(box_upper)+20]) @@ -491,23 +492,41 @@ def call_ocrreject_exam(): parser = argparse.ArgumentParser(description='Calculate fractions of cosmic ray rejected pixels inside and outside of an extraction box to test for cr algorithm failures.', epilog=f'v{__version__}; Written by {__author__}') - parser.add_argument(dest='obsids', nargs='*', help='observation ids in ipppssoots format') - parser.add_argument('-d', dest='data_dir', default=None, help="directory containing observation flt and sx1 files. Defaults to pwd and requires trailing /") + parser.add_argument('--obs', dest='obs_ids', nargs='*', default=None, help='observation ids in ipppssoots format') + parser.add_argument('--flt', dest='flt', default=None, help='path to flt file') + parser.add_argument('--sx1', dest='sx1', default=None, help='path to sx1 file') + parser.add_argument('--d', dest='data_dir', default=None, help="directory containing observation flt and sx1 files. Defaults to pwd and requires trailing /") parser.add_argument('-p', dest='plot', help="option to create diagnostic plots", action='store_true') - parser.add_argument('-pd', dest='plot_dir', default=None, help="directory to store diagnostic plots if plot=True. Defaults to data_dir argument and requires trailing /") + parser.add_argument('--pd', dest='plot_dir', default=None, help="directory to store diagnostic plots if plot=True. Defaults to data_dir argument and requires trailing /") parser.add_argument('-i', dest='interactive', default=False, help="option to create zoomable html plots instead of static pngs. Defaults to False and requires plotly if True") args = parser.parse_args() - #TODO add check that obsid arg actually has stuff stored in it - for obsid in args.obsids: - print(f'\nAnalyzing {obsid}:') - result = ocrreject_exam(obsid, data_dir=args.data_dir, plot=args.plot, plot_dir=args.plot_dir, interactive=args.interactive) + if args.obs_ids is not None: + if args.flt is not None or args.sx1 is not None: + raise ValueError("If 'obs_id' is provided, both 'flt' and 'sx1' must not be provided.") + else: + for obsid in args.obs_ids: + print(f'\nAnalyzing {obsid}:') + result = ocrreject_exam(obsid=args.obs_ids, data_dir=args.data_dir, flt=args.flt, sx1=args.sx1, plot=args.plot, plot_dir=args.plot_dir, interactive=args.interactive) + + print('Fraction of Pixels Rejected as CRs') + print(f" Average across all extraction boxes: {result['avg_extr_frac']:.1%}") + print(f" Average across all external regions: {result['avg_outside_frac']:.1%}") + print(f" Average ratio between the two: {result['avg_ratio']:.2f}") + + elif args.flt is None or args.flt is None: + raise ValueError("If 'obs_id' is not provided, both 'flt' and 'sx1' must be specified.") + + else: + result = ocrreject_exam(obsid=args.obs_ids, data_dir=args.data_dir, flt=args.flt, sx1=args.sx1, plot=args.plot, plot_dir=args.plot_dir, interactive=args.interactive) + + print('Fraction of Pixels Rejected as CRs') + print(f" Average across all extraction boxes: {result['avg_extr_frac']:.1%}") + print(f" Average across all external regions: {result['avg_outside_frac']:.1%}") + print(f" Average ratio between the two: {result['avg_ratio']:.2f}") + - print('Fraction of Pixels Rejected as CRs') - print(f" Average across all extraction boxes: {result['avg_extr_frac']:.1%}") - print(f" Average across all external regions: {result['avg_outside_frac']:.1%}") - print(f" Average ratio between the two: {result['avg_ratio']:.2f}") if __name__ == '__main__': From 32c6fd4e9f25ba216b6c36dc4e43f4466034cfef Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Wed, 16 Oct 2024 14:03:05 -0400 Subject: [PATCH 11/27] removed unnecessary imports --- stistools/ocrreject_exam.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index a37c3e7..69c25b2 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -34,7 +34,7 @@ def ocrreject_exam(obs_id=None, data_dir=None, flt=None, sx1=None, plot=False, p data_dir : str Directory containing both the flat fielded (_flt.fits) and extracted - spectrum (_sx1.fits or _x1d.fits) files of the observation. + spectrum (_sx1.fits or _x1d.fits) files of the observation if using obs_id argument. Defaults to pwd and requires trailing / flt : str @@ -79,6 +79,7 @@ def ocrreject_exam(obs_id=None, data_dir=None, flt=None, sx1=None, plot=False, p if flt is None or sx1 is None: raise ValueError("If 'obs_id' is not provided, both 'flt' and 'sx1' must be specified.") else: + # potentially include check for data_dir here? The code doesn't use data_dir if flt and sx1 paths are specified flt_file = flt sx1_file = sx1 From 7d0ae68aae2cdf9f6bb00f353c97231c10f5bd89 Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Wed, 16 Oct 2024 14:47:10 -0400 Subject: [PATCH 12/27] Added custom exception for BoxExtended --- stistools/ocrreject_exam.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 69c25b2..1868354 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -20,6 +20,12 @@ __author__ = 'Joleen K. Carlberg & Matt Dallas' __version__ = 1.0 +class BoxExtended(Exception): + def __init__(self, message='Extraction box extends beyond frame'): + # Call the base class constructor with the parameters it needs + super(BoxExtended, self).__init__(message) + + def ocrreject_exam(obs_id=None, data_dir=None, flt=None, sx1=None, plot=False, plot_dir=None, interactive=False): """Compares the rate of cosmic rays in the extraction box and everywhere else in a CCD spectroscopic image. Based on crrej_exam from STIS ISR 2019-02. @@ -132,7 +138,7 @@ def ocrreject_exam(obs_id=None, data_dir=None, flt=None, sx1=None, plot=False, p # Check that the extraction box doesn't extend beyond the image: this breaks the method if np.any(box_lower < 0) or np.any(box_upper-1 > flt_shape[0]): # Subtract 1 because the box extends to the value of the pixel before - raise ValueError(f"Extraction box coords extend above or below the cr subexposures for {obs_id}") + raise BoxExtended(f"Extraction box coords extend above or below the cr subexposures for {propid}") extr_mask = np.zeros(flt_shape) outside_mask = np.ones(flt_shape) From d6761bb8ae435db1ec62b92628d6192192365ba7 Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Wed, 16 Oct 2024 15:04:51 -0400 Subject: [PATCH 13/27] Added custom exception for BoxExtended --- stistools/ocrreject_exam.py | 1 - 1 file changed, 1 deletion(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 1868354..caa26b9 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -22,7 +22,6 @@ class BoxExtended(Exception): def __init__(self, message='Extraction box extends beyond frame'): - # Call the base class constructor with the parameters it needs super(BoxExtended, self).__init__(message) From 46904f9351971e528f0cf5b848d16310e18241ba Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Wed, 16 Oct 2024 15:13:22 -0400 Subject: [PATCH 14/27] Made plot_dir a string in figure creation --- stistools/ocrreject_exam.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index caa26b9..0cbf063 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -308,7 +308,7 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop fig.tight_layout() plot_name = obs_id + '_stacked.png' - file_path = plot_dir + plot_name + file_path = str(plot_dir) + plot_name plt.savefig(file_path, dpi=150, bbox_inches='tight') plt.close() @@ -327,7 +327,7 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop title_text = 'CR flagged pixels in stacked image: '+obs_id+'
'+'Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures' plot_name = obs_id + '_stacked.html' - file_path = plot_dir + plot_name + file_path = str(plot_dir) + plot_name # add image of detector fig.add_trace(go.Heatmap(z=stack_image, colorscale=dcolorsc, x=x, y=y, hoverinfo='text', colorbar={'tickvals':tickvals, 'ticktext':ticktext, 'title':{'text':'# times flagged as cr', 'side':'right', 'font':{'size':18}}}, name='')) @@ -439,7 +439,7 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time fig.tight_layout() plot_name = obs_id + '_splits.png' - file_path = plot_dir + plot_name + file_path = str(plot_dir) + plot_name plt.savefig(file_path, dpi=150, bbox_inches='tight') plt.close() @@ -448,7 +448,7 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time title_text = 'CR flagged pixels in individual splits for: '+obs_id+ '
'+'Proposal '+str(propid)+', total exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures' plot_name = obs_id + '_splits.html' - file_path = plot_dir + plot_name + file_path = str(plot_dir) + plot_name # Make plotly figure fig = make_subplots(row_value, 2, horizontal_spacing=0.15, subplot_titles=subplot_titles) From 85ebfe3fd7a36f0be81c38d65be433c4803153aa Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Wed, 16 Oct 2024 17:14:06 -0400 Subject: [PATCH 15/27] Added more logic for mutually exclusive arguments --- stistools/ocrreject_exam.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 0cbf063..64ea035 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -35,18 +35,20 @@ def ocrreject_exam(obs_id=None, data_dir=None, flt=None, sx1=None, plot=False, p Parameters ---------- obsid : str - A STIS observation ID rootname in ipppssoot format (ie odvkl1040) + A STIS observation ID rootname in ipppssoot format (ie odvkl1040). + Mutually exclusive with the "flt" and "sx1" arguments. data_dir : str Directory containing both the flat fielded (_flt.fits) and extracted spectrum (_sx1.fits or _x1d.fits) files of the observation if using obs_id argument. Defaults to pwd and requires trailing / + Mutually exclusive with the "flt" and "sx1" arguments. flt : str - Path to flt file. Useful if flt and sx1 are in different locations or have custom names. + Path to flt file to use instead of "osbid" argument. Useful if flt and sx1 are in different locations or have custom names. sx1 : str - Path to sx1 file. Useful if flt and sx1 are in different locations or have custom names. + Path to sx1 file to use instead of "osbid" argument. Useful if flt and sx1 are in different locations or have custom names. plot : bool Option to generate diagnostic plots, default=False @@ -74,25 +76,33 @@ def ocrreject_exam(obs_id=None, data_dir=None, flt=None, sx1=None, plot=False, p """ global USER_WARNED - if not data_dir: - data_dir = os.getcwd()+'/' - - if not plot_dir: - plot_dir = data_dir + # Check for different combinations of obs_id, data_dir, flt, and sx1: + # If obs_id is not provided, must provide flt and sx1, and cannot have data_dir + # If obs_id is provided, cannot have flt or sx1 and data_dir can be provided, but defaults to ./ if obs_id is None: if flt is None or sx1 is None: - raise ValueError("If 'obs_id' is not provided, both 'flt' and 'sx1' must be specified.") + raise ValueError("If 'obs_id' is not provided, both 'flt' and 'sx1' must be provided.") else: - # potentially include check for data_dir here? The code doesn't use data_dir if flt and sx1 paths are specified - flt_file = flt - sx1_file = sx1 + if data_dir is not None: + raise ValueError("If 'flt' and 'sx1' are provided, 'data_dir' must not be provided") + else: + flt_file = flt + sx1_file = sx1 + if not plot_dir: + plot_dir = os.getcwd()+'/' elif obs_id is not None: if flt is not None or sx1 is not None: - raise ValueError("If 'obs_id' is provided, both 'flt' and 'sx1' must not be provided.") + raise ValueError("If 'obs_id' is provided both 'flt' and 'sx1' must not be provided.") else: # Get flt and sx1/x1d filepaths + if data_dir is None: + data_dir = os.getcwd()+'/' + + if plot_dir is None: + plot_dir = data_dir + flt_file = os.path.join(data_dir, obs_id+'_flt.fits') if not os.path.exists(flt_file): From 93aa0c0693429126f7d8a88057aed4c1bac58dad Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Thu, 17 Oct 2024 19:35:32 -0400 Subject: [PATCH 16/27] Added more colors to colorlist to cover all crsplit ranges --- stistools/ocrreject_exam.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 64ea035..071a7ca 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -286,7 +286,13 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop stack_shape = stack_image.shape max_stack_value = int(np.max(stack_image)) # This is usually equal to stack_shape, # in the case where a cr pixel is not in all splits at the same location this value should be used - custom_cmap = colors.ListedColormap(['k', 'tab:orange', 'tab:blue', 'tab:green', 'tab:red', 'tab:cyan', 'tab:olive', 'tab:purple', 'tab:pink', 'tab:brown', 'tab:grey']) + + color_list = ['k', 'tab:orange', 'tab:blue', 'tab:green', 'tab:red', 'tab:cyan', 'tab:olive', 'tab:purple', 'tab:pink', 'tab:brown', 'tab:grey', + 'darkkhaki', 'gold', 'lightskyblue', 'peru', 'slateblue', 'darkolivegreen', 'mediumseagreen', 'tomato', 'paleturquoise', 'lightgreen', 'chocolate', + 'yellowgreen', 'darksalmon', 'olive', 'darkgoldenrod', 'firebrick', 'teal', 'magenta', 'mediumaquamarine', 'darkslategrey', 'blueviolet', 'peachpuff'] + # hardcoded to 32 values, this should cover all cr split numbers + + custom_cmap = colors.ListedColormap(color_list) cmap = colors.ListedColormap(gen_color(custom_cmap, max_stack_value+1)) bounds = np.arange(max_stack_value+2) norm = colors.BoundaryNorm(bounds, cmap.N) From 0caf3dfb68af72d165efeb60574a2e39f56e20a8 Mon Sep 17 00:00:00 2001 From: Matt Dallas <110625433+m-dallas@users.noreply.github.com> Date: Tue, 3 Dec 2024 12:49:22 -0500 Subject: [PATCH 17/27] fixed command line call syntax --- stistools/ocrreject_exam.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 071a7ca..098dad6 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -541,7 +541,7 @@ def call_ocrreject_exam(): raise ValueError("If 'obs_id' is not provided, both 'flt' and 'sx1' must be specified.") else: - result = ocrreject_exam(obsid=args.obs_ids, data_dir=args.data_dir, flt=args.flt, sx1=args.sx1, plot=args.plot, plot_dir=args.plot_dir, interactive=args.interactive) + result = ocrreject_exam(obs_id=args.obs_ids, data_dir=args.data_dir, flt=args.flt, sx1=args.sx1, plot=args.plot, plot_dir=args.plot_dir, interactive=args.interactive) print('Fraction of Pixels Rejected as CRs') print(f" Average across all extraction boxes: {result['avg_extr_frac']:.1%}") @@ -552,4 +552,4 @@ def call_ocrreject_exam(): if __name__ == '__main__': - call_ocrreject_exam() \ No newline at end of file + call_ocrreject_exam() From 42537cd010d31c455593a4ce782c2920465ce8fc Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Fri, 6 Dec 2024 15:49:12 -0500 Subject: [PATCH 18/27] Allowed multiple obsids, changed call_ocrreject_exam syntax, updated project.scripts --- pyproject.toml | 2 + stistools/ocrreject_exam.py | 359 +++++++++++++++++------------------- 2 files changed, 175 insertions(+), 186 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 563b0a9..1cd7a6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "stsci.tools", "pysiaf", "astroquery", + "matplotlib", ] dynamic = [ "version", @@ -48,6 +49,7 @@ docs = [ [project.scripts] add_stis_s_region = "stistools.add_stis_s_region:call_main" +ocrreject_exam = "stistools.ocrreject_exam:call_ocrreject_exam" [build-system] requires = [ diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 098dad6..28c967e 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -1,9 +1,12 @@ #! /usr/bin/env python + import os import warnings import numpy as np import astropy.io.fits as fits import argparse +import matplotlib +matplotlib.use('Agg') import matplotlib.cm as colormap import matplotlib.colors as colors import matplotlib.pyplot as plt @@ -15,17 +18,40 @@ except ImportError: HAS_PLOTLY = False -USER_WARNED = False +__doc__ = """ +Checks STIS CCD 1D spectroscpopic data for cosmic ray overflagging. + +Examples +-------- + +In Python without TEAL: + +>>> import stistools +>>> stistools.ocrreject_exam.ocrreject_exam("odvkl1040") + +In Python with TEAL: -__author__ = 'Joleen K. Carlberg & Matt Dallas' -__version__ = 1.0 +>>> from stistools import ocrreject_exam +>>> from stsci.tools import teal +>>> teal.teal("ocrreject_exam") + +From command line:: + +% ./basic2d.py -v -s odvkl1040_flt.fits odvkl1040_flt.fits +% ./basic2d.py -r +""" + +__taskname__ = "ocrreject_exam" +__version__ = "1.0" +__vdate__ = "06-December-2024" +__author__ = "Matt Dallas, Joleen Carlberg, Sean Lockwood, STScI, December 2024." class BoxExtended(Exception): def __init__(self, message='Extraction box extends beyond frame'): super(BoxExtended, self).__init__(message) -def ocrreject_exam(obs_id=None, data_dir=None, flt=None, sx1=None, plot=False, plot_dir=None, interactive=False): +def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive=False, verbose=False): """Compares the rate of cosmic rays in the extraction box and everywhere else in a CCD spectroscopic image. Based on crrej_exam from STIS ISR 2019-02. @@ -34,36 +60,32 @@ def ocrreject_exam(obs_id=None, data_dir=None, flt=None, sx1=None, plot=False, p Parameters ---------- - obsid : str - A STIS observation ID rootname in ipppssoot format (ie odvkl1040). - Mutually exclusive with the "flt" and "sx1" arguments. + obs_ids : iter of str or str + One of more STIS observation ID rootnames in ipppssoot format (ie odvkl1040). data_dir : str Directory containing both the flat fielded (_flt.fits) and extracted - spectrum (_sx1.fits or _x1d.fits) files of the observation if using obs_id argument. - Defaults to pwd and requires trailing / - Mutually exclusive with the "flt" and "sx1" arguments. - - flt : str - Path to flt file to use instead of "osbid" argument. Useful if flt and sx1 are in different locations or have custom names. - - sx1 : str - Path to sx1 file to use instead of "osbid" argument. Useful if flt and sx1 are in different locations or have custom names. + spectrum (_sx1.fits or _x1d.fits) files of the observation if using obs_ids argument. + Defaults to current working directory. plot : bool Option to generate diagnostic plots, default=False - plot_dir : str + plot_dir : str or None Directory to save diagnostic plots in if plot=True. - Defaults to dir parameter and requires trailing / + Defaults to data_dir parameter interactive : bool Option to generate zoomable html plots using plotly, default=False + verbose : bool + Option to print some results + Returns ------- results : dict Dictionary containing + rootname : obs_id extr_fracs : cr rejection rates in the extraction boxes for each crsplit outside_fracs : cr rejection rates outside the extraction boxes for each crsplit ratios : extr_fracs/outside_fracs @@ -74,141 +96,131 @@ def ocrreject_exam(obs_id=None, data_dir=None, flt=None, sx1=None, plot=False, p If called from the command line, prints the avg extraction, outside, and ratio values for quick verification. """ - global USER_WARNED - # Check for different combinations of obs_id, data_dir, flt, and sx1: - # If obs_id is not provided, must provide flt and sx1, and cannot have data_dir - # If obs_id is provided, cannot have flt or sx1 and data_dir can be provided, but defaults to ./ + if isinstance(obs_ids, (str,)): + obs_ids = [obs_ids] - if obs_id is None: - if flt is None or sx1 is None: - raise ValueError("If 'obs_id' is not provided, both 'flt' and 'sx1' must be provided.") - else: - if data_dir is not None: - raise ValueError("If 'flt' and 'sx1' are provided, 'data_dir' must not be provided") - else: - flt_file = flt - sx1_file = sx1 - if not plot_dir: - plot_dir = os.getcwd()+'/' - - elif obs_id is not None: - if flt is not None or sx1 is not None: - raise ValueError("If 'obs_id' is provided both 'flt' and 'sx1' must not be provided.") - else: - # Get flt and sx1/x1d filepaths - if data_dir is None: - data_dir = os.getcwd()+'/' - - if plot_dir is None: - plot_dir = data_dir - - flt_file = os.path.join(data_dir, obs_id+'_flt.fits') - - if not os.path.exists(flt_file): - raise IOError(f"No _flt file in {data_dir} for {obs_id}") - - sx1_file = os.path.join(data_dir, obs_id+'_sx1.fits') - - if not os.path.exists(sx1_file): - sx1_file = os.path.join(data_dir, obs_id+'_x1d.fits') # if sx1 doesn't exist check for custom made x1d - if not os.path.exists(sx1_file): - raise IOError(f"No _sx1 file in {data_dir} for {obs_id}") - - # Check that the number of sci extensions matches the number of crsplits - with fits.open(flt_file) as flt_hdul: - propid = flt_hdul[0].header['PROPOSID'] - rootname = flt_hdul[0].header['ROOTNAME'] - nrptexp_num = flt_hdul[0].header['NRPTEXP'] - crsplit_num = flt_hdul[0].header['CRSPLIT'] - sci_num = len([hdu.name for hdu in flt_hdul if "SCI" in hdu.name]) # Counts the number of sci extensions - - if ((crsplit_num)*(nrptexp_num))-(sci_num)!= 0: - raise ValueError(f"cr-split or nrptexp value in flt header does not match the number of sci extentsions for {obs_id}") - - # Calculate cr fraction in and out of extraction box - with fits.open(sx1_file) as sx1_hdul: - spec = sx1_hdul[1].data[0] - shdr = sx1_hdul[0].header - - extrlocy = spec['EXTRLOCY']-1 # y coords of the middle of the extraction box - del_pix = spec['EXTRSIZE']/2. # value the extraction box extends above or below extrlocy - box_upper=np.ceil(extrlocy+del_pix).astype(int) # Ints of pixel values above end of trace bc python is upper bound exclusive - box_lower=np.floor(extrlocy-del_pix).astype(int) # Ints of pixel values below end of trace - - # Fill each of these lists with values for each cr split - extr_fracs = [] # fraction of pixels flagged as cr inside the extraction box for each split - outside_fracs = [] # fraction of pixels flagged as cr outside the extraction box for each split - cr_rejected_locs = [] # 2d array of 1s where a cr exists and 0 elsewhere - exposure_times = [] - - with fits.open(flt_file) as flt_hdul: - flt_shape = flt_hdul['sci', 1].data.shape # shape of the data + result_list = [] + for obs_id in obs_ids: + flt_file = os.path.join(data_dir, obs_id.lower()+'_flt.fits') + if not os.access(flt_file, os.R_OK): + raise FileNotFoundError(f"FLT file for {obs_id} not found in '{data_dir}'") + + sx1_file = os.path.join(data_dir, obs_id.lower()+'_sx1.fits') + x1d_file = os.path.join(data_dir, obs_id.lower()+'_x1d.fits') + if os.access(sx1_file, os.F_OK) and os.access(x1d_file, os.F_OK): + warnings.warn(f"Found both SX1 and X1D files for {obs_id} in '{data_dir}', defaulting to use SX1") + + if not os.access(sx1_file, os.R_OK): + sx1_file = x1d_file + if not os.access(sx1_file, os.R_OK): + raise FileNotFoundError(f"SX1/X1D file for {obs_id} not found in '{data_dir}'") + + if plot and plot_dir is None: + plot_dir = data_dir - # Check that the extraction box doesn't extend beyond the image: this breaks the method - if np.any(box_lower < 0) or np.any(box_upper-1 > flt_shape[0]): # Subtract 1 because the box extends to the value of the pixel before - raise BoxExtended(f"Extraction box coords extend above or below the cr subexposures for {propid}") + # Check that the number of sci extensions matches the number of crsplits + with fits.open(flt_file) as flt_hdul: + propid = flt_hdul[0].header['PROPOSID'] + rootname = flt_hdul[0].header['ROOTNAME'] + nrptexp_num = flt_hdul[0].header['NRPTEXP'] + crsplit_num = flt_hdul[0].header['CRSPLIT'] + sci_num = len([hdu.name for hdu in flt_hdul if "SCI" in hdu.name]) # Counts the number of sci extensions + + if ((crsplit_num)*(nrptexp_num))-(sci_num)!= 0: + raise ValueError(f"cr-split or nrptexp value in flt header does not match the number of sci extentsions for {obs_id}") + + # Calculate cr fraction in and out of extraction box + with fits.open(sx1_file) as sx1_hdul: + spec = sx1_hdul[1].data[0] + shdr = sx1_hdul[0].header + + extrlocy = spec['EXTRLOCY']-1 # y coords of the middle of the extraction box + del_pix = spec['EXTRSIZE']/2. # value the extraction box extends above or below extrlocy + box_upper=np.ceil(extrlocy+del_pix).astype(int) # Ints of pixel values above end of trace bc python is upper bound exclusive + box_lower=np.floor(extrlocy-del_pix).astype(int) # Ints of pixel values below end of trace + + # Fill each of these lists with values for each cr split + extr_fracs = [] # fraction of pixels flagged as cr inside the extraction box for each split + outside_fracs = [] # fraction of pixels flagged as cr outside the extraction box for each split + cr_rejected_locs = [] # 2d array of 1s where a cr exists and 0 elsewhere + exposure_times = [] + + with fits.open(flt_file) as flt_hdul: + flt_shape = flt_hdul['sci', 1].data.shape # shape of the data + + # Check that the extraction box doesn't extend beyond the image: this breaks the method + if np.any(box_lower < 0) or np.any(box_upper-1 > flt_shape[0]): # Subtract 1 because the box extends to the value of the pixel before + raise BoxExtended(f"Extraction box coords extend above or below the cr subexposures for {propid}") - extr_mask = np.zeros(flt_shape) - outside_mask = np.ones(flt_shape) + extr_mask = np.zeros(flt_shape) + outside_mask = np.ones(flt_shape) - for column in range(0,flt_shape[1]): - extr_mask[box_lower[column]:box_upper[column], column] = 1 # 1s inside the extraction box, 0s outside - outside_mask[box_lower[column]:box_upper[column], column] = 0 # 0s inside the extraction box, 1s outside + for column in range(0,flt_shape[1]): + extr_mask[box_lower[column]:box_upper[column], column] = 1 # 1s inside the extraction box, 0s outside + outside_mask[box_lower[column]:box_upper[column], column] = 0 # 0s inside the extraction box, 1s outside - n_extr = np.count_nonzero(extr_mask) # number of pixels inside the extraction box - n_outside = np.count_nonzero(outside_mask) # number of pixels outside the extraction box + n_extr = np.count_nonzero(extr_mask) # number of pixels inside the extraction box + n_outside = np.count_nonzero(outside_mask) # number of pixels outside the extraction box - for i, hdu in enumerate(flt_hdul): - if hdu.name == 'SCI': - exposure_times.append(hdu.header['EXPTIME']) - dq_array = flt_hdul[i+2].data # dq array corresponding to each sci extentsion + for i, hdu in enumerate(flt_hdul): + if hdu.name == 'SCI': + exposure_times.append(hdu.header['EXPTIME']) + dq_array = flt_hdul[i+2].data # dq array corresponding to each sci extentsion - extr_rej_pix = np.zeros(flt_shape) # 2d array where there is a 1 if a pixel inside the extraction box is marked as a cr - np.place(extr_rej_pix, ((extr_mask == 1) & (dq_array & 2**13 != 0)), 1) + extr_rej_pix = np.zeros(flt_shape) # 2d array where there is a 1 if a pixel inside the extraction box is marked as a cr + np.place(extr_rej_pix, ((extr_mask == 1) & (dq_array & 2**13 != 0)), 1) - outside_rej_pix = np.zeros(flt_shape) # 2d array where there is a 1 if a pixel outside the extraction box is marked as a cr - np.place(outside_rej_pix, ((outside_mask == 1) & (dq_array & 2**13 != 0)), 1) + outside_rej_pix = np.zeros(flt_shape) # 2d array where there is a 1 if a pixel outside the extraction box is marked as a cr + np.place(outside_rej_pix, ((outside_mask == 1) & (dq_array & 2**13 != 0)), 1) - extr_cr_count = np.count_nonzero(extr_rej_pix) - outside_cr_count = np.count_nonzero(outside_rej_pix) + extr_cr_count = np.count_nonzero(extr_rej_pix) + outside_cr_count = np.count_nonzero(outside_rej_pix) - extr_fracs.append(extr_cr_count/n_extr) - outside_fracs.append(outside_cr_count/n_outside) + extr_fracs.append(extr_cr_count/n_extr) + outside_fracs.append(outside_cr_count/n_outside) - cr_rejected_pix = extr_rej_pix+outside_rej_pix - cr_rejected_locs.append(cr_rejected_pix) + cr_rejected_pix = extr_rej_pix+outside_rej_pix + cr_rejected_locs.append(cr_rejected_pix) - extr_fracs = np.asarray(extr_fracs) - outside_fracs = np.asarray(outside_fracs) - ratios = extr_fracs/outside_fracs # ratio of extraction to outside the box in each image + extr_fracs = np.asarray(extr_fracs) + outside_fracs = np.asarray(outside_fracs) + ratios = extr_fracs/outside_fracs # ratio of extraction to outside the box in each image - avg_extr_frac = (np.sum(extr_fracs))/(len(extr_fracs)) # Average fraction of crs inside extraction box - avg_outside_frac = (np.sum(outside_fracs))/(len(outside_fracs)) # Average fraction of crs outside extraction box - avg_ratio = avg_extr_frac/avg_outside_frac # Average ratio of the stack + avg_extr_frac = (np.sum(extr_fracs))/(len(extr_fracs)) # Average fraction of crs inside extraction box + avg_outside_frac = (np.sum(outside_fracs))/(len(outside_fracs)) # Average fraction of crs outside extraction box + avg_ratio = avg_extr_frac/avg_outside_frac # Average ratio of the stack - results ={'extr_fracs':extr_fracs, 'outside_fracs':outside_fracs, 'ratios':ratios, 'avg_extr_frac':avg_extr_frac, 'avg_outside_frac':avg_outside_frac, 'avg_ratio':avg_ratio} + results ={'rootname':obs_id, 'extr_fracs':extr_fracs, 'outside_fracs':outside_fracs, 'ratios':ratios, 'avg_extr_frac':avg_extr_frac, 'avg_outside_frac':avg_outside_frac, 'avg_ratio':avg_ratio} - if plot and not interactive: # case with interactive = false - cr_rejected_stack = np.sum(cr_rejected_locs, axis=0) # stack all located crs on top of eachother - stacked_exposure_time = sum(exposure_times) - stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) - split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) - - elif plot and interactive and HAS_PLOTLY: # case with interactive = True and plotly is installed - cr_rejected_stack = np.sum(cr_rejected_locs, axis=0) # stack all located crs on top of eachother - stacked_exposure_time = sum(exposure_times) - stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) - split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) + if plot and (not interactive or not HAS_PLOTLY): # case with interactive = false + if not HAS_PLOTLY and interactive: + warnings.warn('Plotly required for interactive plotting, using matplotlib and static pngs.') + interactive = False + + cr_rejected_stack = np.sum(cr_rejected_locs, axis=0) # stack all located crs on top of eachother + stacked_exposure_time = sum(exposure_times) + stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) + split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) + + elif plot and interactive and HAS_PLOTLY: # case with interactive = True and plotly is installed + cr_rejected_stack = np.sum(cr_rejected_locs, axis=0) # stack all located crs on top of eachother + stacked_exposure_time = sum(exposure_times) + stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) + split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) + + if verbose: + print(f"\nFor {obs_id}") + print(f"Average across all extraction boxes: {results['avg_extr_frac']:.1%}") + print(f"Average across all external regions: {results['avg_outside_frac']:.1%}") + print(f"Average ratio between the two: {results['avg_ratio']:.2f}") + + result_list.append(results) - elif plot and interactive and not USER_WARNED: # case with interactive = True and plotly is not installed - warnings.warn('Plotly required for intercative plotting') - USER_WARNED = True - - return results + return result_list # Plotting specific functions: -def gen_color(cmap, n): +def _gen_color(cmap, n): """Generates n distinct colors from a given colormap. Based on mycolorpy's gen_color() from https://github.com/binodbhttr/mycolorpy""" @@ -224,7 +236,7 @@ def gen_color(cmap, n): return colorlist -def discrete_colorscale(bvals, colors): +def _discrete_colorscale(bvals, colors): """Takes desired boundary values and colors from a matplotlib colorplot and makes a plotly colorscale. Based on discrete_colorscale() from https://community.plot.ly/t/colors-for-discrete-ranges-in-heatmaps/7780""" @@ -256,19 +268,19 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop Parameters ---------- stack_image : array - 2d array to plot. + 2d array to plot box_lower : array - 1d array of ints of the bottom of the extraction box 0 indexed. + 1d array of ints of the bottom of the extraction box 0 indexed box_upper : array - 1d array of ints of the top of the extraction box 0 indexed. + 1d array of ints of the top of the extraction box 0 indexed split_num : int - Number of splits in the stack. + Number of splits in the stack texpt : float - Value of total exposure time. + Value of total exposure time obs_id : str ipppssoot of observation @@ -277,7 +289,7 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop proposal id of observation plot_dir : str - Directory to save plot in. Requires trailing / + Directory to save plot in interactive : bool If True, uses plotly to create an interactive zoomable html plot @@ -293,7 +305,7 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop # hardcoded to 32 values, this should cover all cr split numbers custom_cmap = colors.ListedColormap(color_list) - cmap = colors.ListedColormap(gen_color(custom_cmap, max_stack_value+1)) + cmap = colors.ListedColormap(_gen_color(custom_cmap, max_stack_value+1)) bounds = np.arange(max_stack_value+2) norm = colors.BoundaryNorm(bounds, cmap.N) @@ -324,7 +336,7 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop fig.tight_layout() plot_name = obs_id + '_stacked.png' - file_path = str(plot_dir) + plot_name + file_path = os.path.join(plot_dir, plot_name) plt.savefig(file_path, dpi=150, bbox_inches='tight') plt.close() @@ -336,14 +348,14 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop x = np.arange(start=0, stop=stack_shape[1]+1, step=1) y = np.arange(start=0, stop=stack_shape[0]+1, step=1) - dcolorsc = discrete_colorscale(bvals=list(bounds), colors=cmap.colors) + dcolorsc = _discrete_colorscale(bvals=list(bounds), colors=cmap.colors) ticktext = [str(x) for x in list(bounds)[:len(list(bounds))-1]] tickvals = generate_intervals(len(ticktext)-1, len(ticktext)) title_text = 'CR flagged pixels in stacked image: '+obs_id+'
'+'Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures' plot_name = obs_id + '_stacked.html' - file_path = str(plot_dir) + plot_name + file_path = os.path.join(plot_dir, plot_name) # add image of detector fig.add_trace(go.Heatmap(z=stack_image, colorscale=dcolorsc, x=x, y=y, hoverinfo='text', colorbar={'tickvals':tickvals, 'ticktext':ticktext, 'title':{'text':'# times flagged as cr', 'side':'right', 'font':{'size':18}}}, name='')) @@ -387,13 +399,13 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time list of cr placements in each subexposure (ie the cr_rejected_locs output of ocrreject_exam) box_lower : array - 1d array of ints of the bottom of the extraction box 0 indexed. + 1d array of ints of the bottom of the extraction box 0 indexed box_upper : array - 1d array of ints of the top of the extraction box 0 indexed. + 1d array of ints of the top of the extraction box 0 indexed split_num : int - Number of splits in the stack, (ie len(cr_rejected_locs)). + Number of splits in the stack, (ie len(cr_rejected_locs)) individual_exposure_times: list List of exposure times for each subexposure @@ -408,14 +420,14 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time proposal id of observation plot_dir : str - Directory to save plot in. Requires trailing / + Directory to save plot in interactive : bool If True, uses plotly to create an interactive zoomable html plot """ custom_cmap = colors.ListedColormap(['k', 'tab:orange', 'tab:blue', 'tab:green', 'tab:red', 'tab:cyan', 'tab:olive', 'tab:purple', 'tab:pink', 'tab:brown', 'tab:grey']) - cmap = colors.ListedColormap(gen_color(custom_cmap, 3)) + cmap = colors.ListedColormap(_gen_color(custom_cmap, 3)) bounds = np.arange(4) norm = colors.BoundaryNorm(bounds, cmap.N) @@ -455,7 +467,7 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time fig.tight_layout() plot_name = obs_id + '_splits.png' - file_path = str(plot_dir) + plot_name + file_path = os.path.join(plot_dir, plot_name) plt.savefig(file_path, dpi=150, bbox_inches='tight') plt.close() @@ -464,13 +476,13 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time title_text = 'CR flagged pixels in individual splits for: '+obs_id+ '
'+'Proposal '+str(propid)+', total exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures' plot_name = obs_id + '_splits.html' - file_path = str(plot_dir) + plot_name + file_path = os.path.join(plot_dir, plot_name) # Make plotly figure fig = make_subplots(row_value, 2, horizontal_spacing=0.15, subplot_titles=subplot_titles) # Set up discrete color values - dcolorsc = discrete_colorscale(bvals=list(bounds[:-1]), colors=cmap.colors[:-1]) + dcolorsc = _discrete_colorscale(bvals=list(bounds[:-1]), colors=cmap.colors[:-1]) # Add plots in each subplot row_iterator = 1 @@ -514,41 +526,16 @@ def call_ocrreject_exam(): parser = argparse.ArgumentParser(description='Calculate fractions of cosmic ray rejected pixels inside and outside of an extraction box to test for cr algorithm failures.', epilog=f'v{__version__}; Written by {__author__}') - parser.add_argument('--obs', dest='obs_ids', nargs='*', default=None, help='observation ids in ipppssoots format') - parser.add_argument('--flt', dest='flt', default=None, help='path to flt file') - parser.add_argument('--sx1', dest='sx1', default=None, help='path to sx1 file') - parser.add_argument('--d', dest='data_dir', default=None, help="directory containing observation flt and sx1 files. Defaults to pwd and requires trailing /") + parser.add_argument(dest='obs_ids', metavar='obs_id', type=str, nargs='+', help='observation id(s) in ipppssoot format') + parser.add_argument('-d', dest='data_dir', type=str, default=None, help="directory containing observation flt and sx1/x1d files. Defaults to current working directory.") parser.add_argument('-p', dest='plot', help="option to create diagnostic plots", action='store_true') - parser.add_argument('--pd', dest='plot_dir', default=None, help="directory to store diagnostic plots if plot=True. Defaults to data_dir argument and requires trailing /") - parser.add_argument('-i', dest='interactive', default=False, help="option to create zoomable html plots instead of static pngs. Defaults to False and requires plotly if True") - - args = parser.parse_args() - - if args.obs_ids is not None: - if args.flt is not None or args.sx1 is not None: - raise ValueError("If 'obs_id' is provided, both 'flt' and 'sx1' must not be provided.") - else: - for obsid in args.obs_ids: - print(f'\nAnalyzing {obsid}:') - result = ocrreject_exam(obsid=args.obs_ids, data_dir=args.data_dir, flt=args.flt, sx1=args.sx1, plot=args.plot, plot_dir=args.plot_dir, interactive=args.interactive) - - print('Fraction of Pixels Rejected as CRs') - print(f" Average across all extraction boxes: {result['avg_extr_frac']:.1%}") - print(f" Average across all external regions: {result['avg_outside_frac']:.1%}") - print(f" Average ratio between the two: {result['avg_ratio']:.2f}") - - elif args.flt is None or args.flt is None: - raise ValueError("If 'obs_id' is not provided, both 'flt' and 'sx1' must be specified.") + parser.add_argument('-o', dest='plot_dir', type=str, default=None, help="output directory to store diagnostic plots if plot=True. Defaults to data_dir.") + parser.add_argument('-i', dest='interactive', help="option to create zoomable html plots instead of static pngs. Defaults to False and requires plotly if True", action='store_true') - else: - result = ocrreject_exam(obs_id=args.obs_ids, data_dir=args.data_dir, flt=args.flt, sx1=args.sx1, plot=args.plot, plot_dir=args.plot_dir, interactive=args.interactive) - - print('Fraction of Pixels Rejected as CRs') - print(f" Average across all extraction boxes: {result['avg_extr_frac']:.1%}") - print(f" Average across all external regions: {result['avg_outside_frac']:.1%}") - print(f" Average ratio between the two: {result['avg_ratio']:.2f}") - - + kwargs = vars(parser.parse_args()) + kwargs['verbose']=True + print(kwargs) + ocrreject_exam(**kwargs) if __name__ == '__main__': From 584bf468ee0a7e6b13aaa54859d8b72f87c62825 Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Mon, 9 Dec 2024 15:43:24 -0500 Subject: [PATCH 19/27] Removed debugging line and clarified docstrings --- stistools/ocrreject_exam.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 28c967e..8b21072 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -37,13 +37,12 @@ From command line:: -% ./basic2d.py -v -s odvkl1040_flt.fits odvkl1040_flt.fits -% ./basic2d.py -r +% ocrreject_exam -p odvkl1040 """ __taskname__ = "ocrreject_exam" __version__ = "1.0" -__vdate__ = "06-December-2024" +__vdate__ = "09-December-2024" __author__ = "Matt Dallas, Joleen Carlberg, Sean Lockwood, STScI, December 2024." class BoxExtended(Exception): @@ -83,7 +82,7 @@ def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive Returns ------- - results : dict + results : list of dict Dictionary containing rootname : obs_id extr_fracs : cr rejection rates in the extraction boxes for each crsplit @@ -534,7 +533,6 @@ def call_ocrreject_exam(): kwargs = vars(parser.parse_args()) kwargs['verbose']=True - print(kwargs) ocrreject_exam(**kwargs) From c7cc536672638a171e0537a5e3a20d82dc4dc9f7 Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Mon, 9 Dec 2024 15:53:01 -0500 Subject: [PATCH 20/27] Fixed data_dir default from None to '.' --- stistools/ocrreject_exam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 8b21072..6bca8f7 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -526,7 +526,7 @@ def call_ocrreject_exam(): epilog=f'v{__version__}; Written by {__author__}') parser.add_argument(dest='obs_ids', metavar='obs_id', type=str, nargs='+', help='observation id(s) in ipppssoot format') - parser.add_argument('-d', dest='data_dir', type=str, default=None, help="directory containing observation flt and sx1/x1d files. Defaults to current working directory.") + parser.add_argument('-d', dest='data_dir', type=str, default='.', help="directory containing observation flt and sx1/x1d files. Defaults to current working directory.") parser.add_argument('-p', dest='plot', help="option to create diagnostic plots", action='store_true') parser.add_argument('-o', dest='plot_dir', type=str, default=None, help="output directory to store diagnostic plots if plot=True. Defaults to data_dir.") parser.add_argument('-i', dest='interactive', help="option to create zoomable html plots instead of static pngs. Defaults to False and requires plotly if True", action='store_true') From ebb166b03aa7d7f3bee4d521bd0300a8ab1c3c83 Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Mon, 9 Dec 2024 15:53:43 -0500 Subject: [PATCH 21/27] Added beginning of testing infrastructure --- tests/test_ocrreject_exam.py | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/test_ocrreject_exam.py diff --git a/tests/test_ocrreject_exam.py b/tests/test_ocrreject_exam.py new file mode 100644 index 0000000..37b0745 --- /dev/null +++ b/tests/test_ocrreject_exam.py @@ -0,0 +1,56 @@ +from stistools.ocrreject_exam import ocrreject_exam +from .resources import BaseSTIS +import pytest + + +@pytest.mark.bigdata +@pytest.mark.slow +class TestOcrrejectExam(BaseSTIS): + + input_loc = 'ocrreject_exam' + ref_loc = 'ocrreject_exam/ref' + + input_list = ["o58i01q7q_flt.fits", "o58i01q8q_flt.fits"] + + # Make input file string + input_file_string = ", ".join(input_list) + + def test_ocrrject_lev2(self): + """ + This regression test for this level of this task is different than + level three in two ways. Two parameters are set to give an initial + guess to the sky value and define a sky subraction method. It also + removes cosmic rays from 14 STIS/CCD images and creates a single + 'clean' image which is compared to a reference file using 'FITSDIFF'. + """ + + # Prepare input files. + for filename in self.input_list: + self.get_input_file("input", filename) + + # Run ocrreject + ocrreject(self.input_file_string, output="ocrreject_lev2_crj.fits", + initgues="med", skysub="mode") + + # Compare results + outputs = [("ocrreject_lev2_crj.fits", "ocrreject_lev2_crj_ref.fits")] + self.compare_outputs(outputs) + + def test_ocrrject_lev3(self): + """ + This regression test for this level on this task is a simple default + parameter execution of the task. It attempts to remove cosmic rays + from 14 STIS/CCD images. The resulting calibration is compared to a + reference file using 'FITSDIFF'. + """ + + # Prepare input files. + for filename in self.input_list: + self.get_input_file("input", filename) + + # Run ocrreject + ocrreject(self.input_file_string, output="ocrreject_lev3_crj.fits") + + # Compare results + outputs = [("ocrreject_lev3_crj.fits", "ocrreject_lev3_crj_ref.fits")] + self.compare_outputs(outputs) \ No newline at end of file From 002a4fbb1a82854843d24e81ee57f617a9476569 Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Mon, 9 Dec 2024 17:00:02 -0500 Subject: [PATCH 22/27] Documentation update --- doc/source/index.rst | 1 + doc/source/ocrreject_exam.rst | 11 +++ doc/source/odvkl1040_splits.png | Bin 0 -> 53086 bytes doc/source/odvkl1040_stacked.png | Bin 0 -> 92504 bytes stistools/ocrreject_exam.py | 148 +++++++++++++++++++------------ 5 files changed, 102 insertions(+), 58 deletions(-) create mode 100644 doc/source/ocrreject_exam.rst create mode 100644 doc/source/odvkl1040_splits.png create mode 100644 doc/source/odvkl1040_stacked.png diff --git a/doc/source/index.rst b/doc/source/index.rst index de52960..eb3e133 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -49,6 +49,7 @@ for getting started. inttag mktrace ocrreject + ocrreject_exam radialvel r_util sshift diff --git a/doc/source/ocrreject_exam.rst b/doc/source/ocrreject_exam.rst new file mode 100644 index 0000000..2badc7e --- /dev/null +++ b/doc/source/ocrreject_exam.rst @@ -0,0 +1,11 @@ +.. _ocrreject_exam: + +************************** +ocrreject_exam +************************** + +.. currentmodule:: stistools.ocrreject_exam + +.. automodule:: stistools.ocrreject_exam + :members: + :undoc-members: diff --git a/doc/source/odvkl1040_splits.png b/doc/source/odvkl1040_splits.png new file mode 100644 index 0000000000000000000000000000000000000000..8873d75a81a487cf88914c625ccbf81f50fe5130 GIT binary patch literal 53086 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

J)+qg>y1(SFf#KF;0#&-8Hn& zC+Qqsz4R9@Z}OW@yPZm(`SlD$hd}mm9j;C2Dhi@Pp8vQCZwwDjBB8Z>)#P)BkN@IO zj%b0*u#8^n2?^rnDJy5e9#Qbgb>8I)zn-FKGfT@-DO=+?br5H)4{Q_wEz!P$cE0d`S z!!6ngM!tyr!Pn0s_q+c|9{rg8-K;Y;n%u23DKs43+$?3EV7Gtl{4T&9a=xIzmQ##W zh&(~tr9=SOPM<{+UYgK)45=Sg)7O9b{=(MO&HC>`o3vwn&%BA5 z9po;UokZ)3tn~AaT8&f1dhd%pr9Exx?>B6YKtM)p9(48WJn-8p1Sa$Z6qHbK*rvL| zs_$tgqSBjAaBMVT7EdjGA-pX?4NwSJ=$txQBedC5S!n`;r7Oi*yqk3$*Z>e9ML~v7 zCm+N&_e(+ANLv|-2ZMSV1ju3rBi~$w!GOaUp2m5;s!KpDKdAw-CLyiGbuHD)MOU`oQ+i_F(&@(brIiX-LQ27&-gzW6HoWJ@Ma; zsX00b5GbQER39Fcc_f2O857uUpO0ReGZ$c2})^oe(@}!;J8T zTc&Rhv4{u;-`DKnRr;bp%_+dt5nMs_IT8Jf42Agmp#MxEh?v{6lKLLr7dTROFZFB#CikZGSxDnItxTqLJ+g9RbRqUDYE<9S+G)H3pfK7 z!(k|iH!MGB^f5mD>`>c-?u?Kmus9y_0-T_dMMum8;TVu^9%p?Ta9sBe&o8H&K0Y@< z-+O_*u=t-~4&G0Am5i^!+$B)?F^$ZSh@tI&F()VI*-p591FZtw`hNI*Q^g0&>VZ+Z z3Q_>*M|f^YP@f=`SWIHzqGp@r47w6}*waZ1oN#}x_YV0PTOGIr1GP9Qz#SB25$YRq z0=N41tUkP3Bqsn5=;^RJA?colg+>!67G$8trJr!AVH&78=92nia8UX3hZPkjX!}ed z)Kt3S!@YZk)b2yTfI8QA20gWS+jw(55hEo~P$(#mLeK{p%pD?!NAOAdJdqY$L?+V$ zLI|r9Wh1~7m;hqeBm`<-s><^s{KJX)6ee)k;80C@1$K?(tKddsYG+{vn$cP zS?ihMo#D8{09BExG<&~9ZRaam>v48;Q`0k6)=;wzm9|rqnV-h5*x$0e zaT1`gU43S|IyL8P!2WMHV=S8b@C$*Y&Fg-id5P+snAi_>ShU9%42WIm5{1!;e z=Ah(p55{tIybBbXrV!efHf|e`LSA>nVWrn6Ixfny5gBRBIq%!1SW#T(st_devD@AJ z!{Blz0c1gwC47&Xt)MM%@4M%#tF|Vd-y$@WemN&EPaG2|4rQuCQE!4l;-1`Y;CkZ? z8)8xL6ho19p`xP67004y?Ll7auoj;EOR92`k_YhB~h*kWOJ)lWSWbwca()&9Rn@} zi|9;jkof5~rJ06_si`S5sHqj1ni>$t@%{-c3Gl(j{e66$ki5ZPWsghZGUG;nS3xeR ueN`lR{jU<$|8*7QFJm>@So{AhxQx#ndCT9+)H0JN_%b!I1pjo$;r{}-0_EQT literal 0 HcmV?d00001 diff --git a/doc/source/odvkl1040_stacked.png b/doc/source/odvkl1040_stacked.png new file mode 100644 index 0000000000000000000000000000000000000000..6932fddd8ae779c280f12163fe5d66d30306ba65 GIT binary patch literal 92504 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ͻ{G; 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 xU*+(0>u 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 literal 0 HcmV?d00001 diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 6bca8f7..a01582a 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -24,20 +24,51 @@ Examples -------- -In Python without TEAL: +In Python: >>> import stistools ->>> stistools.ocrreject_exam.ocrreject_exam("odvkl1040") +>>> stistools.ocrreject_exam.ocrreject_exam("odvkl1040", plot=True) -In Python with TEAL: +.. code-block:: python ->>> from stistools import ocrreject_exam ->>> from stsci.tools import teal ->>> teal.teal("ocrreject_exam") + [{'rootname': 'odvkl1040', + 'extr_fracs': array([0.31530762, 0.32006836]), + 'outside_fracs': array([0.00884673, 0.00810278]), + 'ratios': array([35.64113429, 39.50106762]), + 'avg_extr_frac': 0.31768798828125, + 'avg_outside_frac': 0.008474755474901575, + 'avg_ratio': 37.486389928547126}] -From command line:: +.. image:: odvkl1040_stacked.png + :width: 600 + :alt: Stacked example ocrreject_exam plot output -% ocrreject_exam -p odvkl1040 +| + +.. image:: odvkl1040_splits.png + :width: 600 + :alt: Split example ocrreject_exam plot output + +From command line: + +.. code-block:: none + + ocrreject_exam -h + usage: ocrreject_exam [-h] [-d DATA_DIR] [-p] [-o PLOT_DIR] [-i] obs_id [obs_id ...] + + Calculate fractions of cosmic ray rejected pixels inside and outside of an extraction box to test for CR algorithm failures. + + positional arguments: + obs_id observation id(s) in ipppssoot format + + options: + -h, --help show this help message and exit + -d DATA_DIR directory containing observation flt and sx1/x1d files. Defaults to current working directory. + -p option to create diagnostic plots + -o PLOT_DIR output directory to store diagnostic plots if plot=True. Defaults to data_dir. + -i option to create zoomable html plots instead of static pngs. Defaults to False and requires plotly if True + + v1.0; Written by Matt Dallas, Joleen Carlberg, Sean Lockwood, STScI, December 2024. """ __taskname__ = "ocrreject_exam" @@ -52,45 +83,46 @@ def __init__(self, message='Extraction box extends beyond frame'): def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive=False, verbose=False): """Compares the rate of cosmic rays in the extraction box and everywhere else - in a CCD spectroscopic image. Based on crrej_exam from STIS ISR 2019-02. + in a CCD spectroscopic image. Based on crrej_exam from `STIS ISR 2019-02 + `_. Higher ratios of cosmic ray rates in the extraction box to the rest of the image may indicate the need to rerun stistools.ocrreject() with different parameters. Parameters ---------- - obs_ids : iter of str or str - One of more STIS observation ID rootnames in ipppssoot format (ie odvkl1040). + obs_ids: iter of str or str + One of more STIS observation ID rootnames in ipppssoot format (e.g. odvkl1040). - data_dir : str + data_dir: str Directory containing both the flat fielded (_flt.fits) and extracted spectrum (_sx1.fits or _x1d.fits) files of the observation if using obs_ids argument. Defaults to current working directory. - plot : bool + plot: bool Option to generate diagnostic plots, default=False - plot_dir : str or None + plot_dir: str or None Directory to save diagnostic plots in if plot=True. Defaults to data_dir parameter - interactive : bool + interactive: bool Option to generate zoomable html plots using plotly, default=False - verbose : bool + verbose: bool Option to print some results Returns ------- - results : list of dict - Dictionary containing - rootname : obs_id - extr_fracs : cr rejection rates in the extraction boxes for each crsplit - outside_fracs : cr rejection rates outside the extraction boxes for each crsplit - ratios : extr_fracs/outside_fracs - avg_extr_frac : The average of extr_fracs - avg_outside_frac : The average of outside_fracs - avg_ratio : avg_extr_frac/avg_outside_frac + results: list of dict + + - ``rootname``: obs_id + - ``extr_fracs``: cosmic ray rejection rates in the extraction boxes for each CR-SPLIT + - ``outside_fracs``: cosmic ray rejection rates outside the extraction boxes for each CR-SPLIT + - ``ratios``: ``extr_fracs``/``outside_fracs`` + - ``avg_extr_frac``: The average of ``extr_fracs`` + - ``avg_outside_frac``: The average of ``outside_fracs`` + - ``avg_ratio``: ``avg_extr_frac``/``avg_outside_frac`` If called from the command line, prints the avg extraction, outside, and ratio values for quick verification. @@ -150,7 +182,7 @@ def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive # Check that the extraction box doesn't extend beyond the image: this breaks the method if np.any(box_lower < 0) or np.any(box_upper-1 > flt_shape[0]): # Subtract 1 because the box extends to the value of the pixel before - raise BoxExtended(f"Extraction box coords extend above or below the cr subexposures for {propid}") + raise BoxExtended(f"Extraction box coords extend above or below the cosmic ray subexposures for {propid}") extr_mask = np.zeros(flt_shape) outside_mask = np.ones(flt_shape) @@ -251,7 +283,7 @@ def _discrete_colorscale(bvals, colors): return dcolorscale -def generate_intervals(n, divisions): +def _generate_intervals(n, divisions): """Creates a list of strings that are the positions requred for centering an evenly spaced colorbar in plotly""" result = np.linspace(0, n, divisions, endpoint=False) @@ -262,35 +294,35 @@ def generate_intervals(n, divisions): return result def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, propid, plot_dir, interactive): - """Creates a visualization of where cr pixels are in a stacked image + """Creates a visualization of where CR pixels are in a stacked image Parameters ---------- - stack_image : array + stack_image: array 2d array to plot - box_lower : array - 1d array of ints of the bottom of the extraction box 0 indexed + box_lower: array + 1d array of ints of the bottom of the extraction box (0 indexed) - box_upper : array - 1d array of ints of the top of the extraction box 0 indexed + box_upper: array + 1d array of ints of the top of the extraction box (0 indexed) - split_num : int + split_num: int Number of splits in the stack - texpt : float + texpt: float Value of total exposure time - obs_id : str + obs_id: str ipppssoot of observation - propid : int + propid: int proposal id of observation - plot_dir : str + plot_dir: str Directory to save plot in - interactive : bool + interactive: bool If True, uses plotly to create an interactive zoomable html plot """ @@ -328,7 +360,7 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop else: ax2.set_title('full image already 20 pixels above/below extraction box') - cb = fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, label='# times flagged as cr', ticks=np.arange(max_stack_value, max_stack_value+2)-0.5) + cb = fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, label='# times flagged as CR', ticks=np.arange(max_stack_value, max_stack_value+2)-0.5) cb.set_ticklabels(np.arange(max_stack_value, max_stack_value+2)-1) fig.suptitle('CR flagged pixels in stacked image: '+obs_id+'\n Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') @@ -350,14 +382,14 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop dcolorsc = _discrete_colorscale(bvals=list(bounds), colors=cmap.colors) ticktext = [str(x) for x in list(bounds)[:len(list(bounds))-1]] - tickvals = generate_intervals(len(ticktext)-1, len(ticktext)) + tickvals = _generate_intervals(len(ticktext)-1, len(ticktext)) title_text = 'CR flagged pixels in stacked image: '+obs_id+'
'+'Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures' plot_name = obs_id + '_stacked.html' file_path = os.path.join(plot_dir, plot_name) # add image of detector - fig.add_trace(go.Heatmap(z=stack_image, colorscale=dcolorsc, x=x, y=y, hoverinfo='text', colorbar={'tickvals':tickvals, 'ticktext':ticktext, 'title':{'text':'# times flagged as cr', 'side':'right', 'font':{'size':18}}}, name='')) + fig.add_trace(go.Heatmap(z=stack_image, colorscale=dcolorsc, x=x, y=y, hoverinfo='text', colorbar={'tickvals':tickvals, 'ticktext':ticktext, 'title':{'text':'# times flagged as CR', 'side':'right', 'font':{'size':18}}}, name='')) # add extraction box fig.add_trace(go.Scatter(x=np.arange(len(box_upper)),y=box_upper,mode="lines",line=go.scatter.Line(color='#222222', dash='dash'),showlegend=False, opacity=0.7, line_shape='hv', name='extraction box')) @@ -390,38 +422,38 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop fig.write_html(file_path) def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_times, texpt, obs_id, propid, plot_dir, interactive): - """Creates a visualization of where cr pixels are in each subexposure + """Creates a visualization of where CR pixels are in each subexposure Parameters ---------- - splits : list - list of cr placements in each subexposure (ie the cr_rejected_locs output of ocrreject_exam) + splits: list + list of CR flagged pixels in each subexposure - box_lower : array - 1d array of ints of the bottom of the extraction box 0 indexed + box_lower: array + 1d array of ints of the bottom of the extraction box (0 indexed) - box_upper : array - 1d array of ints of the top of the extraction box 0 indexed + box_upper: array + 1d array of ints of the top of the extraction box (0 indexed) - split_num : int - Number of splits in the stack, (ie len(cr_rejected_locs)) + split_num: int + Number of splits in the stack individual_exposure_times: list List of exposure times for each subexposure - texpt : float + texpt: float Value of total exposure time - obs_id : str + obs_id: str ipppssoot of observation - propid : int + propid: int proposal id of observation - plot_dir : str + plot_dir: str Directory to save plot in - interactive : bool + interactive: bool If True, uses plotly to create an interactive zoomable html plot """ @@ -443,7 +475,7 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time fig, ax = plt.subplots(nrows=row_value, ncols=2, figsize=(9, nrows*2)) ax = ax.flatten() - # Plot each subexposure with cr pixels a different color + # Plot each subexposure with CR pixels a different color for num, axis in enumerate(ax): if num Date: Mon, 9 Dec 2024 17:01:23 -0500 Subject: [PATCH 23/27] Started adding tests --- tests/test_ocrreject_exam.py | 34 ++++++---------------------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/tests/test_ocrreject_exam.py b/tests/test_ocrreject_exam.py index 37b0745..0d78e1e 100644 --- a/tests/test_ocrreject_exam.py +++ b/tests/test_ocrreject_exam.py @@ -10,45 +10,23 @@ class TestOcrrejectExam(BaseSTIS): input_loc = 'ocrreject_exam' ref_loc = 'ocrreject_exam/ref' - input_list = ["o58i01q7q_flt.fits", "o58i01q8q_flt.fits"] + input_list = ["odvkl1040_flt.fits", "odvkl1040_sx1.fits"] # Make input file string input_file_string = ", ".join(input_list) - def test_ocrrject_lev2(self): + def test_ocrrject_exam(self): """ - This regression test for this level of this task is different than - level three in two ways. Two parameters are set to give an initial - guess to the sky value and define a sky subraction method. It also - removes cosmic rays from 14 STIS/CCD images and creates a single - 'clean' image which is compared to a reference file using 'FITSDIFF'. + This regression test runs the task on the known problematic dataset odvkl1040. + The resulting output dictionary is compared to a + reference file using 'FITSDIFF'. COULD I JUST PULL OUT THE KEY/VAL PAIRS AND CHECK IF THEY'RE SIMILAR? """ # Prepare input files. for filename in self.input_list: self.get_input_file("input", filename) - # Run ocrreject - ocrreject(self.input_file_string, output="ocrreject_lev2_crj.fits", - initgues="med", skysub="mode") - - # Compare results - outputs = [("ocrreject_lev2_crj.fits", "ocrreject_lev2_crj_ref.fits")] - self.compare_outputs(outputs) - - def test_ocrrject_lev3(self): - """ - This regression test for this level on this task is a simple default - parameter execution of the task. It attempts to remove cosmic rays - from 14 STIS/CCD images. The resulting calibration is compared to a - reference file using 'FITSDIFF'. - """ - - # Prepare input files. - for filename in self.input_list: - self.get_input_file("input", filename) - - # Run ocrreject + # Run ocrreject_exam ocrreject(self.input_file_string, output="ocrreject_lev3_crj.fits") # Compare results From 72609421d8f3c6e61d10b2c9beee60202f71a3d9 Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Tue, 10 Dec 2024 16:48:19 -0500 Subject: [PATCH 24/27] Added test for occreject_exam --- tests/test_ocrreject_exam.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/test_ocrreject_exam.py b/tests/test_ocrreject_exam.py index 0d78e1e..0028f4f 100644 --- a/tests/test_ocrreject_exam.py +++ b/tests/test_ocrreject_exam.py @@ -1,5 +1,7 @@ from stistools.ocrreject_exam import ocrreject_exam from .resources import BaseSTIS +import numpy as np +import os import pytest @@ -8,7 +10,6 @@ class TestOcrrejectExam(BaseSTIS): input_loc = 'ocrreject_exam' - ref_loc = 'ocrreject_exam/ref' input_list = ["odvkl1040_flt.fits", "odvkl1040_sx1.fits"] @@ -18,17 +19,28 @@ class TestOcrrejectExam(BaseSTIS): def test_ocrrject_exam(self): """ This regression test runs the task on the known problematic dataset odvkl1040. - The resulting output dictionary is compared to a - reference file using 'FITSDIFF'. COULD I JUST PULL OUT THE KEY/VAL PAIRS AND CHECK IF THEY'RE SIMILAR? + The resulting output dictionary is compared to the known values for odvkl1040. """ # Prepare input files. for filename in self.input_list: - self.get_input_file("input", filename) + local_file = self.get_data("input", filename) + + expected_output = {'rootname': 'odvkl1040', + 'extr_fracs': np.array([0.31530762, 0.32006836]), + 'outside_fracs': np.array([0.00884673, 0.00810278]), + 'ratios': np.array([35.64113429, 39.50106762]), + 'avg_extr_frac': 0.31768798828125, + 'avg_outside_frac': 0.008474755474901575, + 'avg_ratio': 37.486389928547126} - # Run ocrreject_exam - ocrreject(self.input_file_string, output="ocrreject_lev3_crj.fits") + resulting_output = ocrreject_exam('odvkl1040', data_dir=os.path.dirname(local_file)) - # Compare results - outputs = [("ocrreject_lev3_crj.fits", "ocrreject_lev3_crj_ref.fits")] - self.compare_outputs(outputs) \ No newline at end of file + assert len(resulting_output) == 1, "Output is not a list of only one dictionary" + + resulting_output = resulting_output[0] + + assert set(resulting_output) == set(expected_output), "Not all created keys match" + + for key in expected_output: + assert resulting_output[key] == pytest.approx(expected_output[key]), f"{key} failed" From 7d86b64c1bec9752fa28a7a81072bac8d987a6ce Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Thu, 9 Jan 2025 14:58:29 -0500 Subject: [PATCH 25/27] Changed np.float results to floats for consistency --- stistools/ocrreject_exam.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index a01582a..0fdcd91 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -218,9 +218,9 @@ def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive outside_fracs = np.asarray(outside_fracs) ratios = extr_fracs/outside_fracs # ratio of extraction to outside the box in each image - avg_extr_frac = (np.sum(extr_fracs))/(len(extr_fracs)) # Average fraction of crs inside extraction box - avg_outside_frac = (np.sum(outside_fracs))/(len(outside_fracs)) # Average fraction of crs outside extraction box - avg_ratio = avg_extr_frac/avg_outside_frac # Average ratio of the stack + avg_extr_frac = float((np.sum(extr_fracs))/(len(extr_fracs))) # Average fraction of crs inside extraction box + avg_outside_frac = float((np.sum(outside_fracs))/(len(outside_fracs))) # Average fraction of crs outside extraction box + avg_ratio = float(avg_extr_frac/avg_outside_frac) # Average ratio of the stack results ={'rootname':obs_id, 'extr_fracs':extr_fracs, 'outside_fracs':outside_fracs, 'ratios':ratios, 'avg_extr_frac':avg_extr_frac, 'avg_outside_frac':avg_outside_frac, 'avg_ratio':avg_ratio} From 68c7b67c2910d20aa4f4fecde324d7eadc8fbbae Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Thu, 9 Jan 2025 15:12:08 -0500 Subject: [PATCH 26/27] Added check for non CCD/ACCUM/CRSPLIT or NRPTEXP datasets --- stistools/ocrreject_exam.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index 0fdcd91..bf24801 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -150,8 +150,23 @@ def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive if plot and plot_dir is None: plot_dir = data_dir - # Check that the number of sci extensions matches the number of crsplits with fits.open(flt_file) as flt_hdul: + # When first opening the flt file check that it is even of a CCD exposure + try: + instrument = flt_hdul[0].header['INSTRUME'] + detector = flt_hdul[0].header['DETECTOR'] + obsmode = flt_hdul[0].header['OBSMODE'] + nextend = flt_hdul[0].header['NEXTEND'] + + if (instrument.strip() != 'STIS') or \ + (detector.strip() != 'CCD') or \ + (obsmode.strip() != 'ACCUM') or \ + (nextend < 6): + raise ValueError + except (KeyError, ValueError,): + raise ValueError(f"{flt_file}: Not a STIS/CCD/ACCUM file with ≥2 SCI extensions.") + + # If it is, proceed to check that the number of sci extensions matches the number of crsplits propid = flt_hdul[0].header['PROPOSID'] rootname = flt_hdul[0].header['ROOTNAME'] nrptexp_num = flt_hdul[0].header['NRPTEXP'] @@ -161,7 +176,7 @@ def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive if ((crsplit_num)*(nrptexp_num))-(sci_num)!= 0: raise ValueError(f"cr-split or nrptexp value in flt header does not match the number of sci extentsions for {obs_id}") - # Calculate cr fraction in and out of extraction box + # If all checks above passed, calculate cr fraction in and out of the extraction box with fits.open(sx1_file) as sx1_hdul: spec = sx1_hdul[1].data[0] shdr = sx1_hdul[0].header From 635420b8ea04d06785189234bf169f4f5397edc6 Mon Sep 17 00:00:00 2001 From: Matt Dallas Date: Mon, 27 Jan 2025 12:38:35 -0500 Subject: [PATCH 27/27] Formatted using pylint --- stistools/ocrreject_exam.py | 466 ++++++++++++++++++++---------------- 1 file changed, 261 insertions(+), 205 deletions(-) diff --git a/stistools/ocrreject_exam.py b/stistools/ocrreject_exam.py index bf24801..311ee51 100644 --- a/stistools/ocrreject_exam.py +++ b/stistools/ocrreject_exam.py @@ -1,16 +1,16 @@ -#! /usr/bin/env python +#!/usr/bin/env python import os import warnings -import numpy as np -import astropy.io.fits as fits import argparse + +import numpy as np +from astropy.io import fits import matplotlib matplotlib.use('Agg') -import matplotlib.cm as colormap -import matplotlib.colors as colors -import matplotlib.pyplot as plt - +from matplotlib import cm as colormap +from matplotlib import colors +from matplotlib import pyplot as plt try: import plotly.graph_objects as go from plotly.subplots import make_subplots @@ -19,69 +19,71 @@ HAS_PLOTLY = False __doc__ = """ -Checks STIS CCD 1D spectroscpopic data for cosmic ray overflagging. + Checks STIS CCD 1D spectroscpopic data for cosmic ray overflagging. -Examples --------- + Examples + -------- -In Python: + In Python: ->>> import stistools ->>> stistools.ocrreject_exam.ocrreject_exam("odvkl1040", plot=True) + >>> import stistools + >>> stistools.ocrreject_exam.ocrreject_exam("odvkl1040", plot=True) -.. code-block:: python + .. code-block:: python - [{'rootname': 'odvkl1040', - 'extr_fracs': array([0.31530762, 0.32006836]), - 'outside_fracs': array([0.00884673, 0.00810278]), - 'ratios': array([35.64113429, 39.50106762]), - 'avg_extr_frac': 0.31768798828125, - 'avg_outside_frac': 0.008474755474901575, - 'avg_ratio': 37.486389928547126}] + [{'rootname': 'odvkl1040', + 'extr_fracs': array([0.31530762, 0.32006836]), + 'outside_fracs': array([0.00884673, 0.00810278]), + 'ratios': array([35.64113429, 39.50106762]), + 'avg_extr_frac': 0.31768798828125, + 'avg_outside_frac': 0.008474755474901575, + 'avg_ratio': 37.486389928547126}] -.. image:: odvkl1040_stacked.png - :width: 600 - :alt: Stacked example ocrreject_exam plot output + .. image:: odvkl1040_stacked.png + :width: 600 + :alt: Stacked example ocrreject_exam plot output -| + | -.. image:: odvkl1040_splits.png - :width: 600 - :alt: Split example ocrreject_exam plot output + .. image:: odvkl1040_splits.png + :width: 600 + :alt: Split example ocrreject_exam plot output -From command line: + From command line: -.. code-block:: none + .. code-block:: none - ocrreject_exam -h - usage: ocrreject_exam [-h] [-d DATA_DIR] [-p] [-o PLOT_DIR] [-i] obs_id [obs_id ...] + ocrreject_exam -h + usage: ocrreject_exam [-h] [-d DATA_DIR] [-p] [-o PLOT_DIR] [-i] obs_id [obs_id ...] - Calculate fractions of cosmic ray rejected pixels inside and outside of an extraction box to test for CR algorithm failures. + Calculate fractions of cosmic ray rejected pixels inside and outside of an extraction box to test for CR algorithm failures. - positional arguments: - obs_id observation id(s) in ipppssoot format + positional arguments: + obs_id observation id(s) in ipppssoot format - options: - -h, --help show this help message and exit - -d DATA_DIR directory containing observation flt and sx1/x1d files. Defaults to current working directory. - -p option to create diagnostic plots - -o PLOT_DIR output directory to store diagnostic plots if plot=True. Defaults to data_dir. - -i option to create zoomable html plots instead of static pngs. Defaults to False and requires plotly if True + options: + -h, --help show this help message and exit + -d DATA_DIR directory containing observation flt and sx1/x1d files. Defaults to current working directory. + -p option to create diagnostic plots + -o PLOT_DIR output directory to store diagnostic plots if plot=True. Defaults to data_dir. + -i option to create zoomable html plots instead of static pngs. Defaults to False and requires plotly if True - v1.0; Written by Matt Dallas, Joleen Carlberg, Sean Lockwood, STScI, December 2024. -""" + v1.0; Written by Matt Dallas, Joleen Carlberg, Sean Lockwood, STScI, December 2024. + """ __taskname__ = "ocrreject_exam" -__version__ = "1.0" -__vdate__ = "09-December-2024" -__author__ = "Matt Dallas, Joleen Carlberg, Sean Lockwood, STScI, December 2024." +__version__ = "1.0" +__vdate__ = "09-December-2024" +__author__ = "Matt Dallas, Joleen Carlberg, Sean Lockwood, STScI, December 2024." + class BoxExtended(Exception): def __init__(self, message='Extraction box extends beyond frame'): - super(BoxExtended, self).__init__(message) + super().__init__(message) -def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive=False, verbose=False): +def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive=False, + verbose=False): """Compares the rate of cosmic rays in the extraction box and everywhere else in a CCD spectroscopic image. Based on crrej_exam from `STIS ISR 2019-02 `_. @@ -125,20 +127,18 @@ def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive - ``avg_ratio``: ``avg_extr_frac``/``avg_outside_frac`` If called from the command line, prints the avg extraction, outside, and ratio values for quick verification. - """ - if isinstance(obs_ids, (str,)): obs_ids = [obs_ids] result_list = [] for obs_id in obs_ids: - flt_file = os.path.join(data_dir, obs_id.lower()+'_flt.fits') + flt_file = os.path.join(data_dir, obs_id.lower() + '_flt.fits') if not os.access(flt_file, os.R_OK): raise FileNotFoundError(f"FLT file for {obs_id} not found in '{data_dir}'") - sx1_file = os.path.join(data_dir, obs_id.lower()+'_sx1.fits') - x1d_file = os.path.join(data_dir, obs_id.lower()+'_x1d.fits') + sx1_file = os.path.join(data_dir, obs_id.lower() + '_sx1.fits') + x1d_file = os.path.join(data_dir, obs_id.lower() + '_x1d.fits') if os.access(sx1_file, os.F_OK) and os.access(x1d_file, os.F_OK): warnings.warn(f"Found both SX1 and X1D files for {obs_id} in '{data_dir}', defaulting to use SX1") @@ -149,7 +149,7 @@ def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive if plot and plot_dir is None: plot_dir = data_dir - + with fits.open(flt_file) as flt_hdul: # When first opening the flt file check that it is even of a CCD exposure try: @@ -159,12 +159,12 @@ def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive nextend = flt_hdul[0].header['NEXTEND'] if (instrument.strip() != 'STIS') or \ - (detector.strip() != 'CCD') or \ - (obsmode.strip() != 'ACCUM') or \ - (nextend < 6): + (detector.strip() != 'CCD') or \ + (obsmode.strip() != 'ACCUM') or \ + (nextend < 6): raise ValueError - except (KeyError, ValueError,): - raise ValueError(f"{flt_file}: Not a STIS/CCD/ACCUM file with ≥2 SCI extensions.") + except (KeyError, ValueError,) as e: + raise ValueError(f"{flt_file}: Not a STIS/CCD/ACCUM file with ≥2 SCI extensions.") from e # If it is, proceed to check that the number of sci extensions matches the number of crsplits propid = flt_hdul[0].header['PROPOSID'] @@ -173,18 +173,16 @@ def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive crsplit_num = flt_hdul[0].header['CRSPLIT'] sci_num = len([hdu.name for hdu in flt_hdul if "SCI" in hdu.name]) # Counts the number of sci extensions - if ((crsplit_num)*(nrptexp_num))-(sci_num)!= 0: + if (crsplit_num * nrptexp_num - sci_num) != 0: raise ValueError(f"cr-split or nrptexp value in flt header does not match the number of sci extentsions for {obs_id}") # If all checks above passed, calculate cr fraction in and out of the extraction box - with fits.open(sx1_file) as sx1_hdul: - spec = sx1_hdul[1].data[0] - shdr = sx1_hdul[0].header + spec = fits.getdata(sx1_file, ext=1)[0] - extrlocy = spec['EXTRLOCY']-1 # y coords of the middle of the extraction box - del_pix = spec['EXTRSIZE']/2. # value the extraction box extends above or below extrlocy - box_upper=np.ceil(extrlocy+del_pix).astype(int) # Ints of pixel values above end of trace bc python is upper bound exclusive - box_lower=np.floor(extrlocy-del_pix).astype(int) # Ints of pixel values below end of trace + extrlocy = spec['EXTRLOCY'] - 1 # y coords of the middle of the extraction box + del_pix = spec['EXTRSIZE'] / 2. # value the extraction box extends above or below extrlocy + box_upper = np.ceil(extrlocy + del_pix).astype(int) # Ints of pixel values above end of trace bc python is upper bound exclusive + box_lower = np.floor(extrlocy - del_pix).astype(int) # Ints of pixel values below end of trace # Fill each of these lists with values for each cr split extr_fracs = [] # fraction of pixels flagged as cr inside the extraction box for each split @@ -193,16 +191,16 @@ def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive exposure_times = [] with fits.open(flt_file) as flt_hdul: - flt_shape = flt_hdul['sci', 1].data.shape # shape of the data - + flt_shape = flt_hdul['sci', 1].data.shape # shape of the data + # Check that the extraction box doesn't extend beyond the image: this breaks the method - if np.any(box_lower < 0) or np.any(box_upper-1 > flt_shape[0]): # Subtract 1 because the box extends to the value of the pixel before + if np.any(box_lower < 0) or np.any(box_upper - 1 > flt_shape[0]): # Subtract 1 because the box extends to the value of the pixel before raise BoxExtended(f"Extraction box coords extend above or below the cosmic ray subexposures for {propid}") extr_mask = np.zeros(flt_shape) outside_mask = np.ones(flt_shape) - for column in range(0,flt_shape[1]): + for column in range(0, flt_shape[1]): extr_mask[box_lower[column]:box_upper[column], column] = 1 # 1s inside the extraction box, 0s outside outside_mask[box_lower[column]:box_upper[column], column] = 0 # 0s inside the extraction box, 1s outside @@ -212,104 +210,120 @@ def ocrreject_exam(obs_ids, data_dir='.', plot=False, plot_dir=None, interactive for i, hdu in enumerate(flt_hdul): if hdu.name == 'SCI': exposure_times.append(hdu.header['EXPTIME']) - dq_array = flt_hdul[i+2].data # dq array corresponding to each sci extentsion + dq_array = flt_hdul[i + 2].data # dq array corresponding to each sci extentsion extr_rej_pix = np.zeros(flt_shape) # 2d array where there is a 1 if a pixel inside the extraction box is marked as a cr - np.place(extr_rej_pix, ((extr_mask == 1) & (dq_array & 2**13 != 0)), 1) + np.place(extr_rej_pix, (extr_mask == 1) & (dq_array & 2**13 != 0), 1) outside_rej_pix = np.zeros(flt_shape) # 2d array where there is a 1 if a pixel outside the extraction box is marked as a cr - np.place(outside_rej_pix, ((outside_mask == 1) & (dq_array & 2**13 != 0)), 1) + np.place(outside_rej_pix, (outside_mask == 1) & (dq_array & 2**13 != 0), 1) extr_cr_count = np.count_nonzero(extr_rej_pix) outside_cr_count = np.count_nonzero(outside_rej_pix) - extr_fracs.append(extr_cr_count/n_extr) - outside_fracs.append(outside_cr_count/n_outside) + extr_fracs.append(extr_cr_count / n_extr) + outside_fracs.append(outside_cr_count / n_outside) - cr_rejected_pix = extr_rej_pix+outside_rej_pix + cr_rejected_pix = extr_rej_pix + outside_rej_pix cr_rejected_locs.append(cr_rejected_pix) extr_fracs = np.asarray(extr_fracs) outside_fracs = np.asarray(outside_fracs) - ratios = extr_fracs/outside_fracs # ratio of extraction to outside the box in each image - - avg_extr_frac = float((np.sum(extr_fracs))/(len(extr_fracs))) # Average fraction of crs inside extraction box - avg_outside_frac = float((np.sum(outside_fracs))/(len(outside_fracs))) # Average fraction of crs outside extraction box - avg_ratio = float(avg_extr_frac/avg_outside_frac) # Average ratio of the stack - - results ={'rootname':obs_id, 'extr_fracs':extr_fracs, 'outside_fracs':outside_fracs, 'ratios':ratios, 'avg_extr_frac':avg_extr_frac, 'avg_outside_frac':avg_outside_frac, 'avg_ratio':avg_ratio} - - if plot and (not interactive or not HAS_PLOTLY): # case with interactive = false + ratios = extr_fracs / outside_fracs # ratio of extraction to outside the box in each image + + avg_extr_frac = float(np.sum(extr_fracs) / len(extr_fracs)) # Average fraction of crs inside extraction box + avg_outside_frac = float(np.sum(outside_fracs) / len(outside_fracs)) # Average fraction of crs outside extraction box + avg_ratio = float(avg_extr_frac / avg_outside_frac) # Average ratio of the stack + + results = { + 'rootname' : obs_id, + 'extr_fracs' : extr_fracs, + 'outside_fracs' : outside_fracs, + 'ratios' : ratios, + 'avg_extr_frac' : avg_extr_frac, + 'avg_outside_frac' : avg_outside_frac, + 'avg_ratio' : avg_ratio,} + + if plot and (not interactive or not HAS_PLOTLY): # case with interactive == False if not HAS_PLOTLY and interactive: warnings.warn('Plotly required for interactive plotting, using matplotlib and static pngs.') interactive = False - + cr_rejected_stack = np.sum(cr_rejected_locs, axis=0) # stack all located crs on top of eachother stacked_exposure_time = sum(exposure_times) - stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) - split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) - - elif plot and interactive and HAS_PLOTLY: # case with interactive = True and plotly is installed - cr_rejected_stack = np.sum(cr_rejected_locs, axis=0) # stack all located crs on top of eachother + stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, + rootname, propid, plot_dir, interactive=interactive) + split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, + stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) + + elif plot and interactive and HAS_PLOTLY: # case with interactive == True and plotly is installed + cr_rejected_stack = np.sum(cr_rejected_locs, axis=0) # stack all located crs on top of each other stacked_exposure_time = sum(exposure_times) - stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) - split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) - + stack_plot(cr_rejected_stack, box_lower, box_upper, len(cr_rejected_locs), stacked_exposure_time, + rootname, propid, plot_dir, interactive=interactive) + split_plot(cr_rejected_locs, box_lower, box_upper, len(cr_rejected_locs), exposure_times, + stacked_exposure_time, rootname, propid, plot_dir, interactive=interactive) + if verbose: print(f"\nFor {obs_id}") print(f"Average across all extraction boxes: {results['avg_extr_frac']:.1%}") print(f"Average across all external regions: {results['avg_outside_frac']:.1%}") print(f"Average ratio between the two: {results['avg_ratio']:.2f}") - + result_list.append(results) - + return result_list -# Plotting specific functions: + +# Plotting-specific functions: def _gen_color(cmap, n): """Generates n distinct colors from a given colormap. - - Based on mycolorpy's gen_color() from https://github.com/binodbhttr/mycolorpy""" + Based on mycolorpy's gen_color() from https://github.com/binodbhttr/mycolorpy + """ colorlist = [] for c in cmap.colors[0:n]: clr = colors.rgb2hex(c) # convert to hex colorlist.append(str(clr)) # create a list of these colors - + colorlist.pop(0) # Make it light grey rather than black at the beginning (I think it's easier on the eyes) colorlist.insert(0, '#F5F5F5') - + return colorlist + def _discrete_colorscale(bvals, colors): """Takes desired boundary values and colors from a matplotlib colorplot and makes a plotly colorscale. - - Based on discrete_colorscale() from https://community.plot.ly/t/colors-for-discrete-ranges-in-heatmaps/7780""" - - if len(bvals) != len(colors)+1: - raise ValueError('len(boundary values) should be equal to len(colors)+1') - bvals = sorted(bvals) - nvals = [(v-bvals[0])/(bvals[-1]-bvals[0]) for v in bvals] # normalized values - + + Based on discrete_colorscale() from https://community.plotly.com/t/colors-for-discrete-ranges-in-heatmaps/7780 + """ + if len(bvals) != len(colors) + 1: + raise ValueError('len(boundary values) should be equal to len(colors) + 1') + bvals = sorted(bvals) + nvals = [(v - bvals[0]) / (bvals[-1] - bvals[0]) for v in bvals] # normalized values + dcolorscale = [] # discrete colorscale - for k in range(len(colors)): - dcolorscale.extend([[nvals[k], colors[k]], [nvals[k+1], colors[k]]]) - - return dcolorscale + for k, color in enumerate(colors): + dcolorscale.extend([[nvals[k], color], [nvals[k+1], color]]) + + return dcolorscale -def _generate_intervals(n, divisions): - """Creates a list of strings that are the positions requred for centering an evenly spaced colorbar in plotly""" +def _generate_intervals(n, divisions): + """Creates a list of strings that are the positions requred for centering an evenly spaced colorbar in plotly + """ result = np.linspace(0, n, divisions, endpoint=False) - offset = (result[1]-result[0])/2 + offset = (result[1] - result[0]) / 2 result = result + offset result = [str(x) for x in list(result)[:len(list(result))]] - + return result -def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, propid, plot_dir, interactive): - """Creates a visualization of where CR pixels are in a stacked image + +def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, propid, plot_dir, + interactive): + """Creates a visualization of where CR pixels are in a stacked image Parameters ---------- @@ -340,27 +354,31 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop interactive: bool If True, uses plotly to create an interactive zoomable html plot """ - stack_shape = stack_image.shape max_stack_value = int(np.max(stack_image)) # This is usually equal to stack_shape, # in the case where a cr pixel is not in all splits at the same location this value should be used - - color_list = ['k', 'tab:orange', 'tab:blue', 'tab:green', 'tab:red', 'tab:cyan', 'tab:olive', 'tab:purple', 'tab:pink', 'tab:brown', 'tab:grey', - 'darkkhaki', 'gold', 'lightskyblue', 'peru', 'slateblue', 'darkolivegreen', 'mediumseagreen', 'tomato', 'paleturquoise', 'lightgreen', 'chocolate', - 'yellowgreen', 'darksalmon', 'olive', 'darkgoldenrod', 'firebrick', 'teal', 'magenta', 'mediumaquamarine', 'darkslategrey', 'blueviolet', 'peachpuff'] - # hardcoded to 32 values, this should cover all cr split numbers - + + color_list = [ + 'k', 'tab:orange', 'tab:blue', 'tab:green', 'tab:red', 'tab:cyan', 'tab:olive', 'tab:purple', + 'tab:pink', 'tab:brown', 'tab:grey', 'darkkhaki', 'gold', 'lightskyblue', 'peru', 'slateblue', + 'darkolivegreen', 'mediumseagreen', 'tomato', 'paleturquoise', 'lightgreen', 'chocolate', + 'yellowgreen', 'darksalmon', 'olive', 'darkgoldenrod', 'firebrick', 'teal', 'magenta', + 'mediumaquamarine', 'darkslategrey', 'blueviolet', 'peachpuff',] + # hard-coded to 32 values, this should cover all cr split numbers + custom_cmap = colors.ListedColormap(color_list) - cmap = colors.ListedColormap(_gen_color(custom_cmap, max_stack_value+1)) - bounds = np.arange(max_stack_value+2) + cmap = colors.ListedColormap(_gen_color(custom_cmap, max_stack_value + 1)) + bounds = np.arange(max_stack_value + 2) norm = colors.BoundaryNorm(bounds, cmap.N) - + if not interactive: # create matplotlib image - fig, (ax1,ax2,ax3) = plt.subplots(nrows=1, ncols=3, figsize=(9,20*(9/41)), gridspec_kw={'width_ratios': [1, 1, 0.05], 'height_ratios': [1]}) + fig, (ax1, ax2, ax3) = plt.subplots(nrows=1, ncols=3, figsize=(9, 20*(9/41)), + gridspec_kw={'width_ratios': [1, 1, 0.05], 'height_ratios': [1]}) - for axis in [ax1,ax2]: - axis.imshow(stack_image, interpolation='none', origin="lower", extent=(0, stack_shape[1], 0, stack_shape[0]), cmap=cmap, norm=norm, aspect='auto') + for axis in [ax1, ax2]: + axis.imshow(stack_image, interpolation='none', origin='lower', + extent=(0, stack_shape[1], 0, stack_shape[0]), cmap=cmap, norm=norm, aspect='auto') axis.step(np.arange(len(box_upper)), box_upper, color='#222222', where='post', lw=0.7, alpha=0.7, ls='--') axis.step(np.arange(len(box_lower)), box_lower, color='#222222', where='post', lw=0.7, alpha=0.7, ls='--') @@ -375,69 +393,84 @@ def stack_plot(stack_image, box_lower, box_upper, split_num, texpt, obs_id, prop else: ax2.set_title('full image already 20 pixels above/below extraction box') - cb = fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, label='# times flagged as CR', ticks=np.arange(max_stack_value, max_stack_value+2)-0.5) + cb = fig.colorbar(colormap.ScalarMappable(norm=norm, cmap=cmap), cax=ax3, + label='# times flagged as CR', ticks=np.arange(max_stack_value, max_stack_value + 2) - 0.5) cb.set_ticklabels(np.arange(max_stack_value, max_stack_value+2)-1) - fig.suptitle('CR flagged pixels in stacked image: '+obs_id+'\n Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') + fig.suptitle(f"CR flagged pixels in stacked image: {obs_id}\n Proposal {propid!s}, " \ + f"exposure time {texpt:.2f}, {split_num!s} subexposures") fig.tight_layout() plot_name = obs_id + '_stacked.png' file_path = os.path.join(plot_dir, plot_name) plt.savefig(file_path, dpi=150, bbox_inches='tight') plt.close() - + else: # Create plotly image fig = go.Figure() # calculate required x and y range, colorbar info, and figure titles - x = np.arange(start=0, stop=stack_shape[1]+1, step=1) - y = np.arange(start=0, stop=stack_shape[0]+1, step=1) + x = np.arange(start=0, stop=stack_shape[1] + 1, step=1) + y = np.arange(start=0, stop=stack_shape[0] + 1, step=1) dcolorsc = _discrete_colorscale(bvals=list(bounds), colors=cmap.colors) - ticktext = [str(x) for x in list(bounds)[:len(list(bounds))-1]] - tickvals = _generate_intervals(len(ticktext)-1, len(ticktext)) + ticktext = [str(x) for x in list(bounds)[:len(list(bounds)) - 1]] + tickvals = _generate_intervals(len(ticktext) - 1, len(ticktext)) - title_text = 'CR flagged pixels in stacked image: '+obs_id+'
'+'Proposal '+str(propid)+', exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures' + title_text = f"CR flagged pixels in stacked image: {obs_id}
Proposal {propid!s}, " \ + f"exposure time {texpt:.2f}, {split_num!s} subexposures" plot_name = obs_id + '_stacked.html' file_path = os.path.join(plot_dir, plot_name) # add image of detector - fig.add_trace(go.Heatmap(z=stack_image, colorscale=dcolorsc, x=x, y=y, hoverinfo='text', colorbar={'tickvals':tickvals, 'ticktext':ticktext, 'title':{'text':'# times flagged as CR', 'side':'right', 'font':{'size':18}}}, name='')) + fig.add_trace(go.Heatmap(z=stack_image, colorscale=dcolorsc, x=x, y=y, hoverinfo='text', + colorbar={'tickvals':tickvals, 'ticktext':ticktext, + 'title':{'text':'# times flagged as CR', 'side':'right', 'font':{'size':18}}}, + name='')) # add extraction box - fig.add_trace(go.Scatter(x=np.arange(len(box_upper)),y=box_upper,mode="lines",line=go.scatter.Line(color='#222222', dash='dash'),showlegend=False, opacity=0.7, line_shape='hv', name='extraction box')) - fig.add_trace(go.Scatter(x=np.arange(len(box_lower)),y=box_lower,mode="lines",line=go.scatter.Line(color='#222222', dash='dash'),showlegend=False, opacity=0.7, line_shape='hv', name='extraction box')) + fig.add_trace(go.Scatter(x=np.arange(len(box_upper)), y=box_upper, mode="lines", + line=go.scatter.Line(color='#222222', dash='dash'), showlegend=False, + opacity=0.7, line_shape='hv', name='extraction box')) + fig.add_trace(go.Scatter(x=np.arange(len(box_lower)), y=box_lower, mode="lines", + line=go.scatter.Line(color='#222222', dash='dash'), showlegend=False, + opacity=0.7, line_shape='hv', name='extraction box')) # y-axis zoom ranges - zoom_options = [{'label':'Full Detector', 'yaxis_range':[0, stack_shape[0]]}, + zoom_options = [{'label':'Full Detector', 'yaxis_range':[0, stack_shape[0]]}, {'label':'Extraction Box', 'yaxis_range':[(min(box_lower)-20), (max(box_upper)+20)]}] # Add the toggle buttons - button_options = [{'label':zoom_options[0]['label'], 'method':'relayout', 'args':[{'yaxis.range':zoom_options[0]['yaxis_range']}]}, - {'label':zoom_options[1]['label'], 'method':'relayout', 'args':[{'yaxis.range': zoom_options[1]['yaxis_range']}]}] - - fig.update_layout(updatemenus=[{'type':'dropdown', - 'direction':'down', - 'buttons':button_options, - 'pad':{'r':0, 't':0}, - 'showactive':True, - 'x': 1.07, # Position of the buttons- also might require some more tweaking - 'xanchor':'right', - 'y': 1.07, - 'yanchor':'top'}]) + button_options = [{'label':zoom_options[0]['label'], 'method':'relayout', + 'args':[{'yaxis.range': zoom_options[0]['yaxis_range']}]}, + {'label':zoom_options[1]['label'], 'method':'relayout', + 'args':[{'yaxis.range': zoom_options[1]['yaxis_range']}]}] + + fig.update_layout(updatemenus=[{'type' : 'dropdown', + 'direction' : 'down', + 'buttons' : button_options, + 'pad' : {'r':0, 't':0}, + 'showactive' : True, + 'x' : 1.07, # Position of the buttons; might require some more tweaking + 'xanchor' : 'right', + 'y' : 1.07, + 'yanchor' : 'top'}]) # Set the initial y-axis range (Full View) fig.update_yaxes(range=[0, stack_shape[0]]) fig.update_xaxes(range=[0, stack_shape[1]]) - fig.update_layout(width=stack_shape[1]+ 50, height=int(stack_shape[1] * stack_shape[1] / stack_shape[0]) ) # adds space for colorbar to not squeeze the x axis + fig.update_layout(width=stack_shape[1] + 50, + height=int(stack_shape[1] * stack_shape[1] / stack_shape[0]) ) # adds space for colorbar to not squeeze the x-axis fig.update_layout(title={'text':title_text, 'x':0.5}, font={'family':'Arial, sans-serif', 'size':16}) fig.write_html(file_path) -def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_times, texpt, obs_id, propid, plot_dir, interactive): - """Creates a visualization of where CR pixels are in each subexposure + +def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_times, texpt, + obs_id, propid, plot_dir, interactive): + """Creates a visualization of where CR pixels are in each subexposure Parameters ---------- @@ -471,57 +504,63 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time interactive: bool If True, uses plotly to create an interactive zoomable html plot """ - - custom_cmap = colors.ListedColormap(['k', 'tab:orange', 'tab:blue', 'tab:green', 'tab:red', 'tab:cyan', 'tab:olive', 'tab:purple', 'tab:pink', 'tab:brown', 'tab:grey']) + custom_cmap = colors.ListedColormap([ + 'k', 'tab:orange', 'tab:blue', 'tab:green', 'tab:red', 'tab:cyan', 'tab:olive', + 'tab:purple', 'tab:pink', 'tab:brown', 'tab:grey',]) cmap = colors.ListedColormap(_gen_color(custom_cmap, 3)) bounds = np.arange(4) norm = colors.BoundaryNorm(bounds, cmap.N) # Define grid, dependent on number of splits: - if ((len(splits))%2) == 0: - nrows = (len(splits))/2 + if ((len(splits)) % 2) == 0: + nrows = len(splits) / 2 else: - nrows = ((len(splits))+1)/2 + nrows = (len(splits) + 1) / 2 row_value = int(nrows) if not interactive: - fig, ax = plt.subplots(nrows=row_value, ncols=2, figsize=(9, nrows*2)) + fig, ax = plt.subplots(nrows=row_value, ncols=2, figsize=(9, nrows * 2)) ax = ax.flatten() # Plot each subexposure with CR pixels a different color for num, axis in enumerate(ax): - if num 20) and (min(box_lower) >20): - axis.set_ylim([(min(box_lower)-20),(max(box_upper)+20)]) - axis.set_title('zoomed subexposure '+str(num+1)+', exposure time '+str(individual_exposure_times[num])) + if num < len(splits): + axis.imshow(splits[num], interpolation='none', origin='lower', + extent=(0, splits[num].shape[1], 0, splits[num].shape[0]), + cmap=cmap, norm=norm, aspect='auto') + axis.step(np.arange(len(box_upper)), box_upper, color='#222222', where='post', + lw=0.7, alpha=0.7, ls='--') + axis.step(np.arange(len(box_lower)), box_lower, color='#222222', where='post', + lw=0.7, alpha=0.7, ls='--') + + if ((splits[num].shape[0] - max(box_upper)) > 20) and (min(box_lower) > 20): + axis.set_ylim([min(box_lower) - 20, max(box_upper) + 20]) + axis.set_title(f"zoomed subexposure {(num+1)!s}, exposure time {individual_exposure_times[num]!s}") else: - axis.set_title('subexposure '+str(num+1)+', exposure time '+str(individual_exposure_times[num])) + axis.set_title(f"subexposure {(num + 1)!s}, exposure time {individual_exposure_times[num]!s}") else: axis.set_axis_off() - fig.suptitle('CR flagged pixels in individual splits for: '+obs_id+ '\n Proposal '+str(propid)+', total exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures') + fig.suptitle(f"CR flagged pixels in individual splits for: {obs_id}\n Proposal {propid!s}, " \ + f"total exposure time {texpt:.2f}, {split_num!s} subexposures") fig.tight_layout() plot_name = obs_id + '_splits.png' file_path = os.path.join(plot_dir, plot_name) plt.savefig(file_path, dpi=150, bbox_inches='tight') plt.close() - + else: - subplot_titles = [f'zoomed subexposure {i+1}, exposure time {individual_exposure_times[i]}' for i in range(len(splits))] + subplot_titles = [f'zoomed subexposure {i+1}, exposure time {individual_exposure_times[i]}' + for i in range(len(splits))] - title_text = 'CR flagged pixels in individual splits for: '+obs_id+ '
'+'Proposal '+str(propid)+', total exposure time '+f'{texpt:.2f}'+', '+str(split_num)+' subexposures' - plot_name = obs_id + '_splits.html' + title_text = f"CR flagged pixels in individual splits for: {obs_id}
Proposal {propid!s}, " \ + f"total exposure time {texpt:.2f}, {split_num!s} subexposures" + plot_name = f"{obs_id}_splits.html" file_path = os.path.join(plot_dir, plot_name) # Make plotly figure @@ -536,47 +575,64 @@ def split_plot(splits, box_lower, box_upper, split_num, individual_exposure_time # calculate required x and y range to not center the pixels at 0,0 x = np.arange(start=0, stop=split.shape[1]+1, step=1) y = np.arange(start=0, stop=split.shape[0]+1, step=1) - + # determine correct row, column to put the plot in - if (num+1)%2 != 0: + if (num + 1) % 2 != 0: current_row = row_iterator - row_iterator+=1 + row_iterator += 1 + current_column = 1 else: - current_row = current_row - - if (num+1)%2 == 0: + #current_row = current_row current_column = 2 - else: - current_column = 1 # plot the pixel of each split and the extraction box values - fig.add_trace(go.Heatmap(z=split, colorscale=dcolorsc, showscale=False, x=x, y=y, hoverinfo='text'), current_row, current_column) - fig.add_trace(go.Scatter(x=np.arange(len(box_upper)),y=box_upper,mode="lines",line=go.scatter.Line(color='#222222', dash='dash'),showlegend=False, opacity=0.7, line_shape='hv', name='extraction box'), current_row, current_column) - fig.add_trace(go.Scatter(x=np.arange(len(box_lower)),y=box_lower,mode="lines",line=go.scatter.Line(color='#222222', dash='dash'),showlegend=False, opacity=0.7, line_shape='hv', name='extraction box'), current_row, current_column) + fig.add_trace(go.Heatmap(z=split, colorscale=dcolorsc, showscale=False, x=x, y=y, hoverinfo='text'), + current_row, current_column) + fig.add_trace(go.Scatter(x=np.arange(len(box_upper)), y=box_upper, mode="lines", + line=go.scatter.Line(color='#222222', dash='dash'), + showlegend=False, opacity=0.7, line_shape='hv', name='extraction box'), + current_row, current_column) + fig.add_trace(go.Scatter(x=np.arange(len(box_lower)), y=box_lower, mode="lines", + line=go.scatter.Line(color='#222222', dash='dash'), showlegend=False, + opacity=0.7, line_shape='hv', name='extraction box'), + current_row, current_column) # zoom the plot to near the extraction region - fig.update_yaxes(range=[min(box_lower)-20,max(box_upper)+20]) + fig.update_yaxes(range=[min(box_lower) - 20, max(box_upper) + 20]) fig.update_xaxes(range=[0, split.shape[1]]) # make plots zoom at the same time fig.update_xaxes(matches='x') fig.update_yaxes(matches='y') - - fig.update_layout(width=split.shape[1]*1.75, height=((((max(box_upper)+20)- (min(box_lower)-20))*3*len(splits)))) - fig.update_layout(title={'text':title_text, 'x':0.5, 'y':1.0-(0.15/len(splits))}, font={'family':'Arial, sans-serif', 'size':16}, title_pad={'b': 20*len(splits)}) + + fig.update_layout(width=splits[-1].shape[1] * 1.75, + height=(((max(box_upper) + 20) - (min(box_lower) - 20)) * 3 * len(splits))) + fig.update_layout(title={'text': title_text, 'x': 0.5, 'y': 1. - (0.15 / len(splits))}, + font={'family': 'Arial, sans-serif', 'size': 16}, + title_pad={'b': 20*len(splits)}) fig.write_html(file_path) + def call_ocrreject_exam(): - """Command line usage of ocrreject_exam""" - - parser = argparse.ArgumentParser(description='Calculate fractions of cosmic ray rejected pixels inside and outside of an extraction box to test for CR algorithm failures.', + """Command line usage of ocrreject_exam + """ + parser = argparse.ArgumentParser( + description="Calculate fractions of cosmic ray rejected pixels inside and outside " \ + "of an extraction box to test for CR algorithm failures.", epilog=f'v{__version__}; Written by {__author__}') - parser.add_argument(dest='obs_ids', metavar='obs_id', type=str, nargs='+', help='observation id(s) in ipppssoot format') - parser.add_argument('-d', dest='data_dir', type=str, default='.', help="directory containing observation flt and sx1/x1d files. Defaults to current working directory.") - parser.add_argument('-p', dest='plot', help="option to create diagnostic plots", action='store_true') - parser.add_argument('-o', dest='plot_dir', type=str, default=None, help="output directory to store diagnostic plots if plot=True. Defaults to data_dir.") - parser.add_argument('-i', dest='interactive', help="option to create zoomable html plots instead of static pngs. Defaults to False and requires plotly if True", action='store_true') + parser.add_argument(dest='obs_ids', metavar='obs_id', type=str, nargs='+', + help='observation id(s) in ipppssoot format') + parser.add_argument('-d', dest='data_dir', type=str, default='.', + help="directory containing observation flt and sx1/x1d files. Defaults to current " \ + "working directory.") + parser.add_argument('-p', dest='plot', action='store_true', + help="option to create diagnostic plots") + parser.add_argument('-o', dest='plot_dir', type=str, default=None, + help="output directory to store diagnostic plots if plot=True. Defaults to data_dir.") + parser.add_argument('-i', dest='interactive', action='store_true', + help="option to create zoomable html plots instead of static pngs. Defaults to False " + "and requires plotly if True") kwargs = vars(parser.parse_args()) kwargs['verbose']=True