def get_finding_chart(source_ra, source_dec, source_name, image_source='desi', output_format='pdf', imsize=3.0, tick_offset=0.02, tick_length=0.03, fallback_image_source='dss', **offset_star_kwargs): """Create a finder chart suitable for spectroscopic observations of the source Parameters ---------- source_ra : float Right ascension (J2000) of the source source_dec : float Declination (J2000) of the source source_name : str Name of the source image_source : {'desi', 'dss', 'ztfref'}, optional Survey where the image comes from "desi", "dss", "ztfref" (more to be added) output_format : str, optional "pdf" of "png" -- determines the format of the returned finder imsize : float, optional Requested image size (on a size) in arcmin. Should be between 2-15. tick_offset : float, optional How far off the each source should the tick mark be made? (in arcsec) tick_length : float, optional How long should the tick mark be made? (in arcsec) fallback_image_source : str, optional Where what `image_source` should we fall back to if the one requested fails **offset_star_kwargs : dict, optional Other parameters passed to `get_nearby_offset_stars` Returns ------- dict success : bool Whether the request was successful or not, returning a sensible error in 'reason' name : str suggested filename based on `source_name` and `output_format` data : str binary encoded data for the image (to be streamed) reason : str If not successful, a reason is returned. """ if (imsize < 2.0) or (imsize > 15): return { 'success': False, 'reason': 'Requested `imsize` out of range', 'data': '', 'name': '' } if image_source not in source_image_parameters: return { 'success': False, 'reason': f'image source {image_source} not in list', 'data': '', 'name': '' } fig = plt.figure(figsize=(11, 8.5), constrained_layout=False) widths = [2.6, 1] heights = [2.6, 1] spec = fig.add_gridspec(ncols=2, nrows=2, width_ratios=widths, height_ratios=heights, left=0.05, right=0.95) # how wide on the side will the image be? 256 as default npixels = source_image_parameters[image_source].get("npixels", 256) # set the pixelscale in arcsec (typically about 1 arcsec/pixel) pixscale = 60 * imsize / npixels hdu = fits_image(source_ra, source_dec, imsize=imsize, image_source=image_source) # skeleton WCS - this is the field that the user requested wcs = WCS(naxis=2) # set the headers of the WCS. # The center of the image is the reference point (source_ra, source_dec): wcs.wcs.crpix = [npixels / 2, npixels / 2] wcs.wcs.crval = [source_ra, source_dec] # create the pixel scale and orientation North up, East left # pixelscale is in degrees, established in the tangent plane # to the reference point wcs.wcs.cd = np.array([[-pixscale / 3600, 0], [0, pixscale / 3600]]) wcs.wcs.ctype = ["RA---TAN", "DEC--TAN"] if hdu is not None: im = hdu.data # replace the nans with medians im[np.isnan(im)] = np.nanmedian(im) if source_image_parameters[image_source].get("reproject", False): # project image to the skeleton WCS solution print("Reprojecting image to requested position and orientation") im, _ = reproject_adaptive(hdu, wcs, shape_out=(npixels, npixels)) else: wcs = WCS(hdu.header) if source_image_parameters[image_source].get("smooth", False): im = gaussian_filter( hdu.data, source_image_parameters[image_source]["smooth"] / pixscale) norm = ImageNormalize(im, interval=ZScaleInterval()) watermark = source_image_parameters[image_source]["str"] else: # if we got back a blank image, try to fallback on another survey # and return the results from that call if (fallback_image_source is not None): if (fallback_image_source != image_source): print(f"Falling back on image source {fallback_image_source}") return get_finding_chart(source_ra, source_dec, source_name, image_source=fallback_image_source, output_format=output_format, imsize=imsize, tick_offset=tick_offset, tick_length=tick_length, fallback_image_source=None, **offset_star_kwargs) # we dont have an image here, so let's create a dummy one # so we can still plot im = np.zeros((npixels, npixels)) norm = None watermark = None # add the images in the top left corner ax = fig.add_subplot(spec[0, 0], projection=wcs) ax_text = fig.add_subplot(spec[0, 1]) ax_text.axis('off') ax_starlist = fig.add_subplot(spec[1, 0:]) ax_starlist.axis('off') ax.imshow(im, origin='lower', norm=norm, cmap='gray_r') ax.set_autoscale_on(False) ax.grid(color='white', ls='dotted') ax.set_xlabel(r'$\alpha$ (J2000)', fontsize='large') ax.set_ylabel(r'$\delta$ (J2000)', fontsize='large') obstime = offset_star_kwargs.get("obstime", datetime.datetime.utcnow().isoformat()) ax.set_title(f'{source_name} Finder ({obstime})', fontsize='large', fontweight='bold') star_list, _, _, _ = get_nearby_offset_stars(source_ra, source_dec, source_name, **offset_star_kwargs) if not isinstance(star_list, list) or len(star_list) == 0: return { 'success': False, 'reason': 'failure to get star list', 'data': '', 'name': '' } ncolors = len(star_list) colors = sns.color_palette("colorblind", ncolors) start_text = [-0.35, 0.99] starlist_str = ( "# Note: spacing in starlist many not copy/paste correctly in PDF\n" + f"# you can get starlist directly from" + f" /api/sources/{source_name}/offsets?" + f"facility={offset_star_kwargs.get('facility', 'Keck')}\n" + "\n".join([x["str"] for x in star_list])) # add the starlist ax_starlist.text(0, 0.50, starlist_str, fontsize="x-small", family='monospace', transform=ax_starlist.transAxes) # add the watermark for the survey props = dict(boxstyle='round', facecolor='gray', alpha=0.5) if watermark is not None: ax.text(0.035, 0.035, watermark, horizontalalignment='left', verticalalignment='center', transform=ax.transAxes, fontsize='medium', fontweight='bold', color="yellow", alpha=0.5, bbox=props) ax.text( 0.95, 0.035, f"{imsize}\u2032 \u00D7 {imsize}\u2032", # size'x size' horizontalalignment='right', verticalalignment='center', transform=ax.transAxes, fontsize='medium', fontweight='bold', color="yellow", alpha=0.5, bbox=props) # compass rose # rose_center_pixel = ax.transAxes.transform((0.04, 0.95)) rose_center = pixel_to_skycoord(int(npixels * 0.1), int(npixels * 0.9), wcs) props = dict(boxstyle='round', facecolor='gray', alpha=0.5) for ang, label, off in [(0, "N", 0.01), (90, "E", 0.03)]: position_angle = ang * u.deg separation = (0.05 * imsize * 60) * u.arcsec # 5% p2 = rose_center.directional_offset_by(position_angle, separation) ax.plot([rose_center.ra.value, p2.ra.value], [rose_center.dec.value, p2.dec.value], transform=ax.get_transform('world'), color="gold", linewidth=2) # label N and E position_angle = (ang + 15) * u.deg separation = ((0.05 + off) * imsize * 60) * u.arcsec p2 = rose_center.directional_offset_by(position_angle, separation) ax.text(p2.ra.value, p2.dec.value, label, color="gold", transform=ax.get_transform('world'), fontsize='large', fontweight='bold') for i, star in enumerate(star_list): c1 = SkyCoord(star["ra"] * u.deg, star["dec"] * u.deg, frame='icrs') # mark up the right side of the page with position and offset info name_title = star["name"] if star.get("mag") is not None: name_title += f", mag={star.get('mag'):.2f}" ax_text.text(start_text[0], start_text[1] - i / ncolors, name_title, ha='left', va='top', fontsize='large', fontweight='bold', transform=ax_text.transAxes, color=colors[i]) source_text = f" {star['ra']:.5f} {star['dec']:.5f}\n" source_text += f" {c1.to_string('hmsdms')}\n" if (star.get("dras") is not None) and (star.get("ddecs") is not None): source_text += \ f' {star.get("dras")} {star.get("ddecs")} to {source_name}' ax_text.text(start_text[0], start_text[1] - i / ncolors - 0.06, source_text, ha='left', va='top', fontsize='large', transform=ax_text.transAxes, color=colors[i]) # work on making marks where the stars are for ang in [0, 90]: position_angle = ang * u.deg separation = (tick_offset * imsize * 60) * u.arcsec p1 = c1.directional_offset_by(position_angle, separation) separation = (tick_offset + tick_length) * imsize * 60 * u.arcsec p2 = c1.directional_offset_by(position_angle, separation) ax.plot([p1.ra.value, p2.ra.value], [p1.dec.value, p2.dec.value], transform=ax.get_transform('world'), color=colors[i], linewidth=3 if imsize <= 4 else 2) if star["name"].find("_off") != -1: # this is an offset star text = star["name"].split("_off")[-1] position_angle = 14 * u.deg separation = \ (tick_offset + tick_length*1.6) * imsize * 60 * u.arcsec p1 = c1.directional_offset_by(position_angle, separation) ax.text(p1.ra.value, p1.dec.value, text, color=colors[i], transform=ax.get_transform('world'), fontsize='large', fontweight='bold') buf = io.BytesIO() fig.savefig(buf, format=output_format) buf.seek(0) return { "success": True, "name": f"finder_{source_name}.{output_format}", "data": buf.read(), "reason": "" }
def get_finding_chart( source_ra, source_dec, source_name, image_source='ps1', output_format='pdf', imsize=3.0, tick_offset=0.02, tick_length=0.03, fallback_image_source='dss', zscale_contrast=0.045, zscale_krej=2.5, extra_display_string="", **offset_star_kwargs, ): """Create a finder chart suitable for spectroscopic observations of the source Parameters ---------- source_ra : float Right ascension (J2000) of the source source_dec : float Declination (J2000) of the source source_name : str Name of the source image_source : {'desi', 'dss', 'ztfref', 'ps1'}, optional Survey where the image comes from "desi", "dss", "ztfref", "ps1" defaults to "ps1" output_format : str, optional "pdf" of "png" -- determines the format of the returned finder imsize : float, optional Requested image size (on a size) in arcmin. Should be between 2-15. tick_offset : float, optional How far off the each source should the tick mark be made? (in arcsec) tick_length : float, optional How long should the tick mark be made? (in arcsec) fallback_image_source : str, optional Where what `image_source` should we fall back to if the one requested fails zscale_contrast : float, optional Contrast parameter for the ZScale interval zscale_krej : float, optional Krej parameter for the Zscale interval extra_display_string : str, optional What else to show for the source itself in the chart (e.g. proper motion) **offset_star_kwargs : dict, optional Other parameters passed to `get_nearby_offset_stars` Returns ------- dict success : bool Whether the request was successful or not, returning a sensible error in 'reason' name : str suggested filename based on `source_name` and `output_format` data : str binary encoded data for the image (to be streamed) reason : str If not successful, a reason is returned. """ if (imsize < 2.0) or (imsize > 15): return { 'success': False, 'reason': 'Requested `imsize` out of range', 'data': '', 'name': '', } if image_source not in source_image_parameters: return { 'success': False, 'reason': f'image source {image_source} not in list', 'data': '', 'name': '', } matplotlib.use("Agg") fig = plt.figure(figsize=(11, 8.5), constrained_layout=False) widths = [2.6, 1] heights = [2.6, 1] spec = fig.add_gridspec( ncols=2, nrows=2, width_ratios=widths, height_ratios=heights, left=0.05, right=0.95, ) # how wide on the side will the image be? 256 as default npixels = source_image_parameters[image_source].get("npixels", 256) # set the pixelscale in arcsec (typically about 1 arcsec/pixel) pixscale = 60 * imsize / npixels hdu = fits_image(source_ra, source_dec, imsize=imsize, image_source=image_source) # skeleton WCS - this is the field that the user requested wcs = WCS(naxis=2) # set the headers of the WCS. # The center of the image is the reference point (source_ra, source_dec): wcs.wcs.crpix = [npixels / 2, npixels / 2] wcs.wcs.crval = [source_ra, source_dec] # create the pixel scale and orientation North up, East left # pixelscale is in degrees, established in the tangent plane # to the reference point wcs.wcs.cd = np.array([[-pixscale / 3600, 0], [0, pixscale / 3600]]) wcs.wcs.ctype = ["RA---TAN", "DEC--TAN"] fallback = True if hdu is not None: im = hdu.data # replace the nans with medians im[np.isnan(im)] = np.nanmedian(im) # Fix the header keyword for the input system, if needed hdr = hdu.header if 'RADECSYS' in hdr: hdr.set('RADESYSa', hdr['RADECSYS'], before='RADECSYS') del hdr['RADECSYS'] if source_image_parameters[image_source].get("reproject", False): # project image to the skeleton WCS solution log("Reprojecting image to requested position and orientation") im, _ = reproject_adaptive(hdu, wcs, shape_out=(npixels, npixels)) else: wcs = WCS(hdu.header) if source_image_parameters[image_source].get("smooth", False): im = gaussian_filter( hdu.data, source_image_parameters[image_source]["smooth"] / pixscale) cent = int(npixels / 2) width = int(0.05 * npixels) test_slice = slice(cent - width, cent + width) all_nans = np.isnan(im[test_slice, test_slice].flatten()).all() all_zeros = (im[test_slice, test_slice].flatten() == 0).all() if not (all_zeros or all_nans): percents = np.nanpercentile(im.flatten(), [10, 99.0]) vmin = percents[0] vmax = percents[1] interval = ZScaleInterval( nsamples=int(0.1 * (im.shape[0] * im.shape[1])), contrast=zscale_contrast, krej=zscale_krej, ) norm = ImageNormalize(im, vmin=vmin, vmax=vmax, interval=interval) watermark = source_image_parameters[image_source]["str"] fallback = False if hdu is None or fallback: # if we got back a blank image, try to fallback on another survey # and return the results from that call if fallback_image_source is not None: if fallback_image_source != image_source: log(f"Falling back on image source {fallback_image_source}") return get_finding_chart( source_ra, source_dec, source_name, image_source=fallback_image_source, output_format=output_format, imsize=imsize, tick_offset=tick_offset, tick_length=tick_length, fallback_image_source=None, **offset_star_kwargs, ) # we dont have an image here, so let's create a dummy one # so we can still plot im = np.zeros((npixels, npixels)) watermark = None vmin = 0 vmax = 0 norm = ImageNormalize(im, vmin=vmin, vmax=vmax) # add the images in the top left corner ax = fig.add_subplot(spec[0, 0], projection=wcs) ax_text = fig.add_subplot(spec[0, 1]) ax_text.axis('off') ax_starlist = fig.add_subplot(spec[1, 0:]) ax_starlist.axis('off') ax.imshow(im, origin='lower', norm=norm, cmap='gray_r') ax.set_autoscale_on(False) ax.grid(color='white', ls='dotted') ax.set_xlabel(r'$\alpha$ (J2000)', fontsize='large') ax.set_ylabel(r'$\delta$ (J2000)', fontsize='large') obstime = offset_star_kwargs.get("obstime", datetime.datetime.utcnow().isoformat()) ax.set_title( f'{source_name} Finder (for {obstime.split("T")[0]})', fontsize='large', fontweight='bold', ) star_list, _, _, _, used_ztfref = get_nearby_offset_stars( source_ra, source_dec, source_name, **offset_star_kwargs) if not isinstance(star_list, list) or len(star_list) == 0: return { 'success': False, 'reason': 'failure to get star list', 'data': '', 'name': '', } ncolors = len(star_list) if star_list[0]['str'].startswith("!Data"): ncolors -= 1 colors = sns.color_palette("colorblind", ncolors) start_text = [-0.45, 0.99] origin = "GaiaEDR3" if not used_ztfref else "ZTFref" starlist_str = ( f"# Note: {origin} used for offset star positions\n" "# Note: spacing in starlist many not copy/paste correctly in PDF\n" + "# you can get starlist directly from" + f" /api/sources/{source_name}/offsets?" + f"facility={offset_star_kwargs.get('facility', 'Keck')}\n" + "\n".join([x["str"] for x in star_list])) # add the starlist ax_starlist.text( 0, 0.50, starlist_str, fontsize="x-small", family='monospace', transform=ax_starlist.transAxes, ) # add the watermark for the survey props = dict(boxstyle='round', facecolor='gray', alpha=0.7) if watermark is not None: ax.text( 0.035, 0.035, watermark, horizontalalignment='left', verticalalignment='center', transform=ax.transAxes, fontsize='medium', fontweight='bold', color="yellow", alpha=0.5, bbox=props, ) date_obs = hdr.get('DATE-OBS') if not date_obs: mjd_obs = hdr.get('MJD-OBS') if mjd_obs: date_obs = Time(f"{mjd_obs}", format='mjd').to_value('fits', subfmt='date_hms') if date_obs: ax.text( 0.95, 0.95, f'image date {date_obs.split("T")[0]}', horizontalalignment='right', verticalalignment='center', transform=ax.transAxes, fontsize='small', color="yellow", alpha=0.5, bbox=props, ) ax.text( 0.95, 0.035, f"{imsize}\u2032 \u00D7 {imsize}\u2032", # size'x size' horizontalalignment='right', verticalalignment='center', transform=ax.transAxes, fontsize='medium', fontweight='bold', color="yellow", alpha=0.5, bbox=props, ) # compass rose # rose_center_pixel = ax.transAxes.transform((0.04, 0.95)) rose_center = pixel_to_skycoord(int(npixels * 0.1), int(npixels * 0.9), wcs) props = dict(boxstyle='round', facecolor='gray', alpha=0.5) for ang, label, off in [(0, "N", 0.01), (90, "E", 0.03)]: position_angle = ang * u.deg separation = (0.05 * imsize * 60) * u.arcsec # 5% p2 = rose_center.directional_offset_by(position_angle, separation) ax.plot( [rose_center.ra.value, p2.ra.value], [rose_center.dec.value, p2.dec.value], transform=ax.get_transform('world'), color="gold", linewidth=2, ) # label N and E position_angle = (ang + 15) * u.deg separation = ((0.05 + off) * imsize * 60) * u.arcsec p2 = rose_center.directional_offset_by(position_angle, separation) ax.text( p2.ra.value, p2.dec.value, label, color="gold", transform=ax.get_transform('world'), fontsize='large', fontweight='bold', ) # account for Shane header if star_list[0]['str'].startswith("!Data"): star_list = star_list[1:] for i, star in enumerate(star_list): c1 = SkyCoord(star["ra"] * u.deg, star["dec"] * u.deg, frame='icrs') # mark up the right side of the page with position and offset info name_title = star["name"] if star.get("mag") is not None: name_title += f" {star.get('mag'):.2f} mag" ax_text.text( start_text[0], start_text[1] - (i * 1.1) / ncolors, name_title, ha='left', va='top', fontsize='large', fontweight='bold', transform=ax_text.transAxes, color=colors[i], ) source_text = f" {star['ra']:.5f} {star['dec']:.5f}\n" source_text += f" {c1.to_string('hmsdms', precision=2)}\n" if i == 0 and extra_display_string != "": source_text += f" {extra_display_string}\n" if ((star.get("dras") is not None) and (star.get("ddecs") is not None) and (star.get("pa") is not None)): source_text += f' {star.get("dras")} {star.get("ddecs")} (PA={star.get("pa"):<0.02f}°)' ax_text.text( start_text[0], start_text[1] - (i * 1.1) / ncolors - 0.06, source_text, ha='left', va='top', fontsize='large', transform=ax_text.transAxes, color=colors[i], ) # work on making marks where the stars are for ang in [0, 90]: # for the source itself (i=0), change the angle of the lines in # case the offset star is the same as the source itself position_angle = ang * u.deg if i != 0 else (ang + 225) * u.deg separation = (tick_offset * imsize * 60) * u.arcsec p1 = c1.directional_offset_by(position_angle, separation) separation = (tick_offset + tick_length) * imsize * 60 * u.arcsec p2 = c1.directional_offset_by(position_angle, separation) ax.plot( [p1.ra.value, p2.ra.value], [p1.dec.value, p2.dec.value], transform=ax.get_transform('world'), color=colors[i], linewidth=3 if imsize <= 4 else 2, alpha=0.8, ) if star["name"].find("_o") != -1: # this is an offset star text = star["name"].split("_o")[-1] position_angle = 14 * u.deg separation = (tick_offset + tick_length * 1.6) * imsize * 60 * u.arcsec p1 = c1.directional_offset_by(position_angle, separation) ax.text( p1.ra.value, p1.dec.value, text, color=colors[i], transform=ax.get_transform('world'), fontsize='large', fontweight='bold', ) buf = io.BytesIO() fig.savefig(buf, format=output_format) plt.close(fig) buf.seek(0) return { "success": True, "name": f"finder_{source_name}.{output_format}", "data": buf.read(), "reason": "", }
def reproject_to(self, target_wcs, algorithm='interpolation', shape_out=None, order='bilinear', output_array=None, parallel=False, return_footprint=False): """ Reprojects this NDCube to the coordinates described by another WCS object. Parameters ---------- algorithm: `str` The algorithm to use for reprojecting. This can be any of: 'interpolation', 'adaptive', and 'exact'. target_wcs : `astropy.wcs.wcsapi.BaseHighLevelWCS`, `astropy.wcs.wcsapi.BaseLowLevelWCS`, or `astropy.io.fits.Header` The WCS object to which the ``NDCube`` is to be reprojected. shape_out: `tuple`, optional The shape of the output data array. The ordering of the dimensions must follow NumPy ordering and not the WCS pixel shape. If not specified, `~astropy.wcs.wcsapi.BaseLowLevelWCS.array_shape` attribute (if available) from the low level API of the ``target_wcs`` is used. order: `int` or `str` The order of the interpolation (used only when the 'interpolation' or 'adaptive' algorithm is selected). For 'interpolation' algorithm, this can be any of: 'nearest-neighbor', 'bilinear', 'biquadratic', and 'bicubic'. For 'adaptive' algorithm, this can be either 'nearest-neighbor' or 'bilinear'. output_array: `numpy.ndarray`, optional An array in which to store the reprojected data. This can be any numpy array including a memory map, which may be helpful when dealing with extremely large files. parallel: `bool` or `int` Flag for parallel implementation (used only when the 'exact' algorithm is selected). If ``True``, a parallel implementation is chosen and the number of processes is selected automatically as the number of logical CPUs detected on the machine. If ``False``, a serial implementation is chosen. If the flag is a positive integer n greater than one, a parallel implementation using n processes is chosen. return_footprint: `bool` Whether to return the footprint in addition to the output NDCube. Returns ------- resampled_cube : `ndcube.NDCube` A new resultant NDCube object, the supplied ``target_wcs`` will be the ``.wcs`` attribute of the output ``NDCube``. footprint: `numpy.ndarray` Footprint of the input array in the output array. Values of 0 indicate no coverage or valid values in the input image, while values of 1 indicate valid values. Notes ----- This method doesn't support handling of the ``mask``, ``extra_coords``, and ``uncertainty`` attributes yet. However, ``meta`` and ``global_coords`` are copied to the output ``NDCube``. """ try: from reproject import reproject_adaptive, reproject_exact, reproject_interp from reproject.wcs_utils import has_celestial except ModuleNotFoundError: raise ImportError( "The NDCube.reproject_to method requires the optional package `reproject`." ) if isinstance(target_wcs, Mapping): target_wcs = WCS(header=target_wcs) low_level_target_wcs = utils.wcs.get_low_level_wcs( target_wcs, 'target_wcs') # 'adaptive' and 'exact' algorithms work only on 2D celestial WCS. if algorithm == 'adaptive' or algorithm == 'exact': if low_level_target_wcs.pixel_n_dim != 2 or low_level_target_wcs.world_n_dim != 2: raise ValueError( 'For adaptive and exact algorithms, target_wcs must be 2D.' ) if not has_celestial(target_wcs): raise ValueError( 'For adaptive and exact algorithms, ' 'target_wcs must contain celestial axes only.') if not utils.wcs.compare_wcs_physical_types(self.wcs, target_wcs): raise ValueError( 'Given target_wcs is not compatible with this NDCube, the physical types do not match.' ) # If shape_out is not specified explicity, try to extract it from the low level WCS if not shape_out: if hasattr(low_level_target_wcs, 'array_shape' ) and low_level_target_wcs.array_shape is not None: shape_out = low_level_target_wcs.array_shape else: raise ValueError( "shape_out must be specified if target_wcs does not have the array_shape attribute." ) self._validate_algorithm_and_order(algorithm, order) if algorithm == 'interpolation': data = reproject_interp(self, output_projection=target_wcs, shape_out=shape_out, order=order, output_array=output_array, return_footprint=return_footprint) elif algorithm == 'adaptive': data = reproject_adaptive(self, output_projection=target_wcs, shape_out=shape_out, order=order, return_footprint=return_footprint) elif algorithm == 'exact': data = reproject_exact(self, output_projection=target_wcs, shape_out=shape_out, parallel=parallel, return_footprint=return_footprint) if return_footprint: data, footprint = data resampled_cube = type(self)(data, wcs=target_wcs, meta=deepcopy(self.meta)) resampled_cube._global_coords = deepcopy(self.global_coords) if return_footprint: return resampled_cube, footprint return resampled_cube