Source code for prospect.viewer.plots

# Licensed under a 3-clause BSD style license - see LICENSE.rst
# -*- coding: utf-8 -*-
"""
=====================
prospect.viewer.plots
=====================

Class containing bokeh plots needed for the viewer

"""

import numpy as np
_specutils_imported = True
try:
    from specutils import Spectrum1D, SpectrumList
except ImportError:
    _specutils_imported = False

import bokeh.plotting as bk
from bokeh.models import CustomJS, ColumnDataSource, BoxAnnotation, Legend, Span, Label
import bokeh.layouts as bl
import bokeh.events


[docs]def _cross_hair_points(x_cross, y_cross): ''' Points to make a cross-hair centered on x_cross,y_cross Return (xs,ys) to be given to bokeh.plotting.figure.multi_line() ''' xs = [[x_cross-15, x_cross-5], [x_cross+15, x_cross+5], [x_cross, x_cross],[x_cross, x_cross]] ys = [[y_cross, y_cross],[y_cross, y_cross], [y_cross-15, y_cross-5],[y_cross+5, y_cross+15]] return (xs, ys)
[docs]def _viewer_urls(spectra, pixscale=0.262, zoom=13, layer='ls-dr9'): """Return legacysurvey.org viewer URLs + other infos for the imaging cutout, for all spectra. Notes: `layer` does not apply to the JPEG cutout service. `pixscale` is in arcsec/pixel (default 0.262 is the native DECam scale) """ #- Template for jpeg-cutout url u = "https://www.legacysurvey.org/viewer/jpeg-cutout?ra={0:f}&dec={1:f}&pixscale={2:f}" #- Template for on-click link to full viewer v = "https://www.legacysurvey.org/viewer/?ra={0:f}&dec={1:f}&zoom={2:d}&layer={3}&mark={0:f},{1:f}" if hasattr(spectra, 'fibermap'): try: ra = spectra.fibermap['RA_TARGET'] dec = spectra.fibermap['DEC_TARGET'] except KeyError: ra = spectra.fibermap['TARGET_RA'] dec = spectra.fibermap['TARGET_DEC'] else: ra = spectra.meta['plugmap']['RA'] dec = spectra.meta['plugmap']['DEC'] #- Compute PM corrections default_ref_epoch = 2015.5 # PM correction is set to zero at that epoch if hasattr(spectra, 'fibermap'): pmcor_ra = (default_ref_epoch-spectra.fibermap['REF_EPOCH'])*spectra.fibermap['PMRA']/1e3 # PM in mas/yr pmcor_dec = (default_ref_epoch-spectra.fibermap['REF_EPOCH'])*spectra.fibermap['PMDEC']/1e3 # avoid adding a second cross-hair when PM correction is smaller than 1 arcsec (ie. in most cases): mask = ((np.abs(pmcor_ra)<1) & (np.abs(pmcor_dec)<1)) | (spectra.fibermap['REF_EPOCH']==0) pmcor_ra[mask] = 0 pmcor_dec[mask] = 0 else: pmcor_ra = spectra.meta['plugmap']['RA'] * 0.0 pmcor_dec = spectra.meta['plugmap']['DEC'] * 0.0 # convert PM correction to pixels in the jpeg cutout npix_cutout = 256 x_pm = npix_cutout//2 - pmcor_ra/pixscale # minus sign: RA decreases with x-coord in image y_pm = npix_cutout//2 + pmcor_dec/pixscale # return points to make a second cross-hair if PM correction is large list_crosshairs = [ _cross_hair_points(x_pm[i], y_pm[i]) for i in range(len(ra)) ] return [(u.format(ra[i], dec[i], pixscale), v.format(ra[i], dec[i], zoom, layer), 'RA, Dec = {0:.4f}, {1:+.4f}'.format(ra[i], dec[i]), list_crosshairs[i][0], list_crosshairs[i][1]) for i in range(len(ra))]
[docs]class ViewerPlots(object): """ Encapsulates Bokeh plot-like objects that are part of prospect's GUI. """ def __init__(self, colors=None): # "Hardcoded" plotting parameters here: self.legend_outside_plot = True if (self.legend_outside_plot): self.xmargin_left = 100. self.xmargin_right = 100. else: self.xmargin_left = 200. self.xmargin_right = 400. self.plot_width=800 self.plot_height=400 self.colors = dict(b='#1f77b4', r='#d62728', z='maroon', coadd='#d62728', brz='#d62728') self.noise_colors = dict(b='greenyellow', r='green', z='forestgreen', coadd='green', brz='green') self.model_color = 'black' ## overlap wavelengths are hardcoded, from 1907.10688 (Table 1) self.overlap_waves = [ [5660, 5930], [7470, 7720] ] self.alpha_overlapband = 0.03 if colors is not None: assert len(colors)==3 self.colors['coadd'] = self.colors['brz'] = colors[0] self.model_color = colors[1] self.noise_colors['coadd'] = self.noise_colors['brz'] = colors[2] self.fig = None self.zoomfig = None self.zoom_callback = None self.imfig = bl.Spacer(width=self.plot_height//2, height=self.plot_height//2) self.imfig_source = self.imfig_urls = self.crosshair_source = None def create_mainfig(self, spectra, title, viewer_cds, survey, with_noise=True, with_coaddcam=True): #----- #- Main figure #- Determine initial ymin, ymax, xmin, xmax self.ymin = self.ymax = self.xmax = 0 self.xmin = 100000. if survey == 'SDSS': bands = ['coadd'] try: self.ymin = np.nanmin(spectra.flux.value[0, :]) self.ymax = np.nanmax(spectra.flux.value[0, :]) except IndexError: # Catch case where, e.g., flux.shape == (3000, ). self.ymin = np.nanmin(spectra.flux.value) self.ymax = np.nanmax(spectra.flux.value) self.xmin = np.min(spectra.spectral_axis.value) self.xmax = np.max(spectra.spectral_axis.value) else: bands = spectra.bands for i, band in enumerate(bands): if _specutils_imported and isinstance(spectra, SpectrumList): sp_flux = spectra[i].flux.value[0] sp_wave = spectra[i].spectral_axis.value else: sp_flux = spectra.flux[band][0] sp_wave = spectra.wave[band] if np.isfinite(sp_flux).any(): self.ymin = min(self.ymin, np.nanmin(sp_flux)) self.ymax = max(self.ymax, np.nanmax(sp_flux)) self.xmin = min(self.xmin, np.min(sp_wave)) self.xmax = max(self.xmax, np.max(sp_wave)) self.xmin -= self.xmargin_left self.xmax += self.xmargin_right tools = 'pan,box_zoom,ywheel_zoom,xwheel_zoom,save' tooltips_fig = [("wave","$x"),("flux","$y")] self.fig = bk.figure(height=self.plot_height, width=self.plot_width, title=title, tools=tools, toolbar_location='above', tooltips=tooltips_fig, y_range=(self.ymin, self.ymax), x_range=(self.xmin, self.xmax)) self.fig.sizing_mode = 'stretch_width' self.fig.toolbar.active_drag = self.fig.tools[0] #- pan zoom (previously box) self.fig.toolbar.active_scroll = self.fig.tools[2] #- wheel zoom self.fig.xaxis.axis_label = 'Wavelength [Å]' self.fig.yaxis.axis_label = 'Flux [10⁻¹⁷ erg cm⁻² s⁻¹ Å⁻¹]' self.fig.xaxis.axis_label_text_font_style = 'normal' self.fig.yaxis.axis_label_text_font_style = 'normal' self.alpha_discrete = 0.2 # alpha for "almost-hidden" curves (single-arm spectra and noise by default) if not with_coaddcam : self.alpha_discrete = 1 #- Highlight overlap regions between arms self.overlap_bands = [] if bands == ['brz'] or set(bands) == set(['b','r','z']) : for i in range(len(self.overlap_waves)) : fill_alpha = self.alpha_overlapband # if with_coaddcam else 0 self.overlap_bands.append( BoxAnnotation(left=self.overlap_waves[i][0], right=self.overlap_waves[i][1], fill_color='blue', fill_alpha=fill_alpha, line_alpha=0) ) self.fig.add_layout(self.overlap_bands[-1]) self.data_lines = list() for spec in viewer_cds.cds_spectra: lx = self.fig.line('plotwave', 'plotflux', source=spec, line_color=self.colors[spec.name], line_alpha=self.alpha_discrete) self.data_lines.append(lx) if with_coaddcam : lx = self.fig.line('plotwave', 'plotflux', source=viewer_cds.cds_coaddcam_spec, line_color=self.colors['coadd'], line_alpha=1) self.data_lines.append(lx) self.noise_lines = list() if with_noise : for spec in viewer_cds.cds_spectra : lx = self.fig.line('plotwave', 'plotnoise', source=spec, line_color=self.noise_colors[spec.name], line_alpha=self.alpha_discrete) self.noise_lines.append(lx) if with_coaddcam : lx = self.fig.line('plotwave', 'plotnoise', source=viewer_cds.cds_coaddcam_spec, line_color=self.noise_colors['coadd'], line_alpha=1) self.noise_lines.append(lx) self.model_lines = list() if viewer_cds.cds_model is not None: lx = self.fig.line('plotwave', 'plotflux', source=viewer_cds.cds_model, line_color=self.model_color) self.model_lines.append(lx) self.othermodel_lines = list() if viewer_cds.cds_othermodel is not None : lx = self.fig.line('plotwave', 'plotflux', source=viewer_cds.cds_othermodel, line_color=self.model_color, line_dash='dashed') self.othermodel_lines.append(lx) legend_items = [("data", self.data_lines[-1::-1])] #- reversed to get blue as lengend entry if viewer_cds.cds_model is not None : legend_items.append(("pipeline fit", self.model_lines)) if viewer_cds.cds_othermodel is not None : legend_items.append(("other model", self.othermodel_lines)) if with_noise : legend_items.append(("noise", self.noise_lines[-1::-1])) # same as for data_lines if (self.legend_outside_plot): legend = Legend(items=legend_items, border_line_alpha=0, label_text_font_size='11px', glyph_width=20, margin=0, padding=0, spacing=20, orientation='horizontal') self.fig.add_layout(legend, 'below') else: legend = Legend(items=legend_items) self.fig.add_layout(legend, 'center') self.fig.legend.click_policy = 'hide' #- or 'mute' def create_zoomfig(self, viewer_cds, with_noise=True, with_coaddcam=True): #----- #- Zoom figure around mouse hover of main plot tooltips_zoomfig = [("wave","$x"),("flux","$y")] self.zoomfig = bk.figure(height=self.plot_height//2, width=self.plot_height//2, y_range=self.fig.y_range, x_range=(5000,5100), # output_backend="webgl", toolbar_location=None, tooltips=tooltips_zoomfig, tools=[]) self.zoom_data_lines = list() self.zoom_noise_lines = list() for spec in viewer_cds.cds_spectra: self.zoom_data_lines.append(self.zoomfig.line('plotwave', 'plotflux', source=spec, line_color=self.colors[spec.name], line_width=1, line_alpha=self.alpha_discrete)) if with_noise : self.zoom_noise_lines.append(self.zoomfig.line('plotwave', 'plotnoise', source=spec, line_color=self.noise_colors[spec.name], line_width=1, line_alpha=self.alpha_discrete)) if with_coaddcam : self.zoom_data_lines.append(self.zoomfig.line('plotwave', 'plotflux', source=viewer_cds.cds_coaddcam_spec, line_color=self.colors['coadd'], line_alpha=1)) if with_noise : lx = self.zoomfig.line('plotwave', 'plotnoise', source=viewer_cds.cds_coaddcam_spec, line_color=self.noise_colors['coadd'], line_alpha=1) self.zoom_noise_lines.append(lx) if viewer_cds.cds_model is not None: lx = self.zoomfig.line('plotwave', 'plotflux', source=viewer_cds.cds_model, line_color=self.model_color) if viewer_cds.cds_othermodel is not None : lx = self.zoomfig.line('plotwave', 'plotflux', source=viewer_cds.cds_othermodel, line_color=self.model_color, line_dash='dashed') #- Callback to update zoom window x-range self.zoom_callback = CustomJS( args=dict(zoomfig=self.zoomfig,fig=self.fig), code=""" zoomfig.x_range.start = cb_obj.x - 100; zoomfig.x_range.end = cb_obj.x + 100; """) self.fig.js_on_event(bokeh.events.MouseMove, self.zoom_callback) def create_imfig(self, spectra): #----- #- Targeting image npix_cutout = 256 # size of legacysurvey JPEG cutouts self.imfig = bk.figure(width=self.plot_height//2, height=self.plot_height//2, x_range=(0, npix_cutout), y_range=(0, npix_cutout), x_axis_location=None, y_axis_location=None, output_backend="webgl", toolbar_location=None, tools=[]) self.imfig.min_border_left = 0 self.imfig.min_border_right = 0 self.imfig.min_border_top = 0 self.imfig.min_border_bottom = 0 self.imfig_urls = _viewer_urls(spectra) self.imfig_source = ColumnDataSource(data=dict(url=[self.imfig_urls[0][0]], txt=[self.imfig_urls[0][2]])) imfig_img = self.imfig.image_url('url', source=self.imfig_source, x=1, y=1, w=256, h=256, anchor='bottom_left') imfig_txt = self.imfig.text(10, 256-30, text='txt', source=self.imfig_source, text_color='yellow', text_font_size='8pt') #- This cross-hair is visible if the estimated PM correction is larger than 1 arcsec self.crosshair_source = ColumnDataSource(data=dict(xs=self.imfig_urls[0][3], ys=self.imfig_urls[0][4])) pm_crosshair = self.imfig.multi_line('xs','ys', source=self.crosshair_source, line_width=1, line_color='white') #- Central cross-hair xs_center, ys_center = _cross_hair_points(npix_cutout//2, npix_cutout//2) central_crosshair = self.imfig.multi_line(xs_center, ys_center, line_width=1.5, line_color='yellow') def add_imfig_callback(self, viewer_widgets): #----- #- Targeting image callback # This has to be called once viewer_widgets are created => fct separated from create_imfig() self.imfig_callback = CustomJS(args = dict( urls = self.imfig_urls, ispectrumslider = viewer_widgets.ispectrumslider), code='''window.open(urls[ispectrumslider.value][1], "_blank");''') self.imfig.js_on_event('tap', self.imfig_callback)
[docs] def add_spectral_lines(self, viewer_cds, figure='main', label_offset_top=100, label_offset_bottom=5): """Add spectral line markers to plot. Parameters ---------- viewer_cds : array-like Viewer data. figure : {'main', 'zoom'}, optional Figure to add spectral lines to. label_offset_top : float, optional Offset in y-position for line labels with respect to top of the figure for emission lines. label_offset_bottom : float, optional Offset in y-position for line labels with respect to bottom of the figure for absorption lines. """ if figure=='main' : bk_figure = self.fig elif figure=='zoom' : bk_figure = self.zoomfig else : raise ValueError("Unknown input figure type.") fig_height = bk_figure.plot_height if self.legend_outside_plot and figure=='main': label_offset_top += 10 line_data = dict(viewer_cds.cds_spectral_lines.data) #- Labels y-position: default values y = list() for i in range(len(line_data['restwave'])): if line_data['emission'][i]: y.append(fig_height - label_offset_top) else: y.append(label_offset_bottom) #- Labels y-position: avoid overlaps restwave = np.asarray(line_data['restwave']) emission = np.asarray(line_data['emission']) for emission_flag in [True, False]: w, = np.where( (emission == emission_flag) ) # Indices for the restwave array, sorted, and filtering emission_flag: sorted_indices = w[np.argsort(restwave[w])] for i in range(len(sorted_indices)-1): if (restwave[sorted_indices[i+1]] < restwave[sorted_indices[i]]+100): # The following allows to have 4 levels of y-position offsets: if emission_flag and (y[sorted_indices[i]]>=fig_height-label_offset_top-2*15): y[sorted_indices[i+1]] = y[sorted_indices[i]] - 15 elif (not emission_flag) and (y[sorted_indices[i]]<=label_offset_bottom+2*15): y[sorted_indices[i+1]] = y[sorted_indices[i]] + 15 line_data['y'] = y #- Add vertical spans to figure if figure == 'main' : self.speclines = list() self.specline_labels = list() else : self.zoom_speclines = list() self.zoom_specline_labels = list() for w, y, name, emission, major in zip( line_data['plotwave'], line_data['y'], line_data['plotname'], line_data['emission'], line_data['major'] ): color = 'blueviolet' if emission else 'green' visible = True if major else False s = Span(location=w, dimension='height', line_color=color, line_alpha=1.0, line_dash='dashed', visible=visible) bk_figure.add_layout(s) lb = Label(x=w, y=y, x_units='data', y_units='screen', text=name, text_color='gray', text_font_size="8pt", x_offset=2, y_offset=0, visible=visible) bk_figure.add_layout(lb) if figure == 'main' : self.speclines.append(s) self.specline_labels.append(lb) else : self.zoom_speclines.append(s) self.zoom_specline_labels.append(lb)