Source code for jdaviz.configs.specviz.plugins.line_analysis.line_analysis

import os
import logging

import numpy as np
from glue.core.message import (SubsetDeleteMessage,
                               SubsetUpdateMessage)
from glue_jupyter.common.toolbar_vuetify import read_icon
from traitlets import Bool, List, Float, Unicode, observe
from astropy import units as u
from specutils import analysis, Spectrum1D

from jdaviz.core.events import (AddDataMessage,
                                RemoveDataMessage,
                                SpectralMarksChangedMessage,
                                LineIdentifyMessage,
                                RedshiftMessage,
                                GlobalDisplayUnitChanged,
                                SnackbarMessage)
from jdaviz.core.registries import tray_registry
from jdaviz.core.template_mixin import (PluginTemplateMixin,
                                        DatasetSelectMixin,
                                        SpectralSubsetSelectMixin,
                                        DatasetSpectralSubsetValidMixin,
                                        SubsetSelect,
                                        SpectralContinuumMixin,
                                        SPATIAL_DEFAULT_TEXT,
                                        skip_if_no_updates_since_last_active,
                                        with_spinner)
from jdaviz.core.user_api import PluginUserApi
from jdaviz.core.tools import ICON_DIR


__all__ = ['LineAnalysis']

FUNCTIONS = {"Line Flux": analysis.line_flux,
             "Equivalent Width": analysis.equivalent_width,
             "Gaussian Sigma Width": analysis.gaussian_sigma_width,
             "Gaussian FWHM": analysis.gaussian_fwhm,
             "Centroid": analysis.centroid}


def _coerce_unit(quantity):
    """
    coerce the unit on a quantity to have a single length unit (will take the first length
    unit with a power of 1) and to strip any constants from the units.
    """
    # for some reason, quantity.unit.powers gives floats which then raise an error in
    # quantity.to and we want to avoid casting to integer in case of fractional powers
    unit = u.Unit(str(quantity.unit))
    unit_types = [str(subunit.physical_type) for subunit in unit.bases]
    length_inds = [ind for ind, (base, power, unit_type)
                   in enumerate(zip(unit.bases, unit.powers, unit_types))
                   if unit_type == 'length' and abs(power) == 1]
    # we want to force all length units (not area) to use the same base unit so they can
    # combine/cancel appropriately
    coerced_bases = [unit.bases[i if i not in length_inds else length_inds[0]]
                     for i in range(len(unit.bases))]
    coerced_unit_string = ' * '.join([f'{base}**{power}'
                                      for base, power in zip(coerced_bases, unit.powers)])
    coerced_quantity = quantity.to(coerced_unit_string)
    if getattr(quantity, 'uncertainty', None) is not None:
        coerced_quantity.uncertainty = quantity.uncertainty.to(coerced_unit_string)
    return coerced_quantity


[docs] @tray_registry('specviz-line-analysis', label="Line Analysis", viewer_requirements='spectrum') class LineAnalysis(PluginTemplateMixin, DatasetSelectMixin, SpectralSubsetSelectMixin, DatasetSpectralSubsetValidMixin, SpectralContinuumMixin): """ The Line Analysis plugin returns specutils analysis for a single spectral line. See the :ref:`Line Analysis Plugin Documentation <line-analysis>` for more details. Only the following attributes and methods are available through the :ref:`public plugin API <plugin-apis>`: * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.show` * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.open_in_tray` * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.close_in_tray` * ``dataset`` (:class:`~jdaviz.core.template_mixin.DatasetSelect`): Dataset to use for computing line statistics. * ``spatial_subset`` (:class:`~jdaviz.core.template_mixin.SubsetSelect`): Select which region's collapsed spectrum to analyze or ``Entire Cube``. * ``spectral_subset`` (:class:`~jdaviz.core.template_mixin.SubsetSelect`): Subset to use for the line, or ``Entire Spectrum``. * ``continuum`` (:class:`~jdaviz.core.template_mixin.SubsetSelect`): Subset to use for the continuum, or ``Surrounding`` to use a region surrounding the subset set in ``spectral_subset``. * ```continuum_width``: Width, relative to the overall line spectral region, to fit the linear continuum (excluding the region containing the line). If 1, will use endpoints within line region only. * :meth:`get_results` """ dialog = Bool(False).tag(sync=True) template_file = __file__, "line_analysis.vue" uses_active_status = Bool(True).tag(sync=True) spatial_subset_items = List().tag(sync=True) spatial_subset_selected = Unicode().tag(sync=True) results_computing = Bool(False).tag(sync=True) results = List().tag(sync=True) results_centroid = Float().tag(sync=True) # stored in AA units line_menu_items = List([]).tag(sync=True) sync_identify = Bool(True).tag(sync=True) sync_identify_icon_enabled = Unicode(read_icon(os.path.join(ICON_DIR, 'line_select.svg'), 'svg+xml')).tag(sync=True) # noqa sync_identify_icon_disabled = Unicode(read_icon(os.path.join(ICON_DIR, 'line_select_disabled.svg'), 'svg+xml')).tag(sync=True) # noqa identified_line = Unicode("").tag(sync=True) selected_line = Unicode("").tag(sync=True) selected_line_redshift = Float(0).tag(sync=True) def __init__(self, *args, **kwargs): super().__init__(**kwargs) self.update_results(None) # when accessing the selected data, access the spectrum-viewer version self.dataset._viewers = [self._default_spectrum_viewer_reference_name] # require entries to be in spectrum-viewer (not other cubeviz images, etc) self.dataset.add_filter('layer_in_spectrum_viewer') # continuum selection is mandatory for line-analysis self._continuum_remove_none_option() if self.app.state.settings.get("configuration") == "cubeviz": self.spatial_subset = SubsetSelect(self, 'spatial_subset_items', 'spatial_subset_selected', default_text=SPATIAL_DEFAULT_TEXT, filters=['is_spatial']) else: self.spatial_subset = None self.hub.subscribe(self, AddDataMessage, handler=self._on_viewer_data_changed) self.hub.subscribe(self, RemoveDataMessage, handler=self._on_viewer_data_changed) self.hub.subscribe(self, SubsetDeleteMessage, handler=self._on_viewer_subsets_changed) self.hub.subscribe(self, SubsetUpdateMessage, handler=self._on_viewer_subsets_changed) self.hub.subscribe(self, SpectralMarksChangedMessage, handler=self._on_plotted_lines_changed) self.hub.subscribe(self, LineIdentifyMessage, handler=self._on_identified_line_changed) self.hub.subscribe(self, GlobalDisplayUnitChanged, handler=self._on_global_display_unit_changed) @property def _default_spectrum_viewer_reference_name(self): return getattr( self.app._jdaviz_helper, '_default_spectrum_viewer_reference_name', 'spectrum-viewer' ) # backwards compatibility for width (replace with user API deprecation) @property def width(self): logging.warning(f"DeprecationWarning: width was replaced by continuum_width in 3.9 and will be removed in a future release") # noqa return self.continuum_width @width.setter def width(self, width): logging.warning("DeprecationWarning: width was replaced by continuum_width in 3.9 and will be removed in a future release") # noqa self.continuum_width = width @property def user_api(self): # deprecated: width was replaced with continuum_width in 3.9 so should be removed from the # user API and the property and setter above as soon as 3.11. return PluginUserApi(self, expose=('dataset', 'spatial_subset', 'spectral_subset', 'continuum', 'width', 'continuum_width', 'get_results')) @property def line_items(self): # Return list of only the table indices ("name_rest" in line table) from line_menu_items return [item["value"] for item in self.line_menu_items] def _on_viewer_data_changed(self, msg): viewer_id = self.app._viewer_item_by_reference( self._default_spectrum_viewer_reference_name ).get('id') if msg is None or msg.viewer_id != viewer_id or msg.data is None: return viewer = self.app.get_viewer(self._default_spectrum_viewer_reference_name) viewer_data_labels = [layer.layer.label for layer in viewer.layers] if msg.data.label not in viewer_data_labels: return get_data_kwargs = {'data_label': msg.data.label} if self.config == 'cubeviz': get_data_kwargs['function'] = getattr(viewer.state, 'function', None) viewer_data = self.app._jdaviz_helper.get_data(**get_data_kwargs) # If no data is currently plotted, don't attempt to update if viewer_data is None: self.disabled_msg = 'Line Analysis unavailable without spectral data' return if viewer_data.spectral_axis.unit == u.pix: # disable the plugin until we can address this properly (either using the wavelength # solution to support pixels in line-lists, or properly displaying the extracted # 1d spectrum in wavelength-space) self.disabled_msg = 'Line Analysis unavailable when x-axis is in pixels' else: self.disabled_msg = '' def _on_viewer_subsets_changed(self, msg): """ Update the statistics if any of the referenced regions have changed Parameters ---------- msg : `glue.core.Message` The glue message passed to this callback method. """ if (msg.subset.label in [self.spectral_subset_selected, self.spatial_subset_selected, self.continuum_subset_selected]): self._calculate_statistics(msg) def _on_global_display_unit_changed(self, msg): self._calculate_statistics(msg) @observe('is_active') def _is_active_changed(self, msg): if self.disabled_msg: return for pos, mark in self.continuum_marks.items(): mark.visible = self.is_active self._calculate_statistics(msg)
[docs] def update_results(self, results=None): if results is None: self.results = [{'function': function, 'result': ''} for function in FUNCTIONS] self._update_continuum_marks() else: self.results = results
[docs] def get_results(self): # user-facing API call to force updating and retrieving results, even if the plugin # is closed if not self.spectral_subset_valid: valid, spec_range, subset_range = self._check_dataset_spectral_subset_valid(return_ranges=True) # noqa raise ValueError(f"spectral subset '{self.spectral_subset.selected}' {subset_range}" f" is outside data range of '{self.dataset.selected}' {spec_range}") self._calculate_statistics() return self.results
def _on_plotted_lines_changed(self, msg): self.line_marks = msg.marks self.line_menu_items = [{"title": f"{mark.name} {mark.rest_value} {mark.xunit}", "value": name_rest} # noqa for mark, name_rest in zip(msg.marks, msg.names_rest)] if self.selected_line not in self.line_items: # default to identified line if available self.selected_line = self.identified_line def _on_identified_line_changed(self, msg): self.identified_line = msg.name_rest if self.sync_identify or not self.selected_line: # then we should follow the identified line, either because of sync # or because nothing has been selected yet. # if results aren't available yet, then we'll wait until they are # in which case we'll default to the identified line self.selected_line = self.identified_line @observe("dataset_selected", "spatial_subset_selected", "spectral_subset_selected", "continuum_subset_selected", "continuum_width") @skip_if_no_updates_since_last_active() @with_spinner('results_computing') def _calculate_statistics(self, msg={}): """ Run the line analysis functions on the selected data/subset and display the results. """ if not hasattr(self, 'dataset') or self.app._jdaviz_helper is None: # noqa # during initial init, this can trigger before the component is initialized return if self.disabled_msg != '': self.update_results(None) return # call directly since this observe may be triggered before the spectral_subset_valid # traitlet is updated if not self._check_dataset_spectral_subset_valid(): # skip gracefully, if the user called from get_results, and error would be raised there self.update_results(None) return spectrum, continuum, spec_subtracted = self._get_continuum(self.dataset, self.spatial_subset, self.spectral_subset, update_marks=True) if spectrum is None: self.update_results(None) return def _uncertainty(result): if getattr(result, 'uncertainty', None) is not None: # we'll keep the uncertainty and result in the same unit (so # we only have to show the unit at the end) if np.isnan(result.uncertainty.value) or np.isinf(result.uncertainty.value): return '' return str(result.uncertainty.to_value(result.unit)) else: return '' temp_results = [] if spec_subtracted.mask is not None: # temporary fix while mask may contain None: spec_subtracted.mask = spec_subtracted.mask.astype(bool) for function in FUNCTIONS: # TODO: update specutils to allow ALL analysis to take regions and continuum so we # don't need these if statements if function == "Line Flux": flux_unit = spec_subtracted.flux.unit # If the flux unit is equivalent to Jy, or Jy per spaxel for Cubeviz, # enforce integration in frequency space if (flux_unit.is_equivalent(u.Jy) or flux_unit.is_equivalent(u.Jy/u.sr)): # Perform integration in frequency space freq_spec = Spectrum1D( spectral_axis=spec_subtracted.spectral_axis.to(u.Hz, equivalencies=u.spectral()), flux=spec_subtracted.flux, uncertainty=spec_subtracted.uncertainty) try: raw_result = analysis.line_flux(freq_spec) except ValueError as e: # can happen if interpolation out-of-bounds or any error from specutils # let's avoid the whole app crashing and instead expose the error to the # user self.hub.broadcast(SnackbarMessage( f"failed to calculate line analysis statistics: {e}", sender=self, color="warning")) self.update_results(None) return # When flux is equivalent to Jy, lineflux result should be shown in W/m2 if flux_unit.is_equivalent(u.Jy/u.sr): final_unit = u.Unit('W/(m2 sr)') else: final_unit = u.Unit('W/m2') temp_result = raw_result.to(final_unit) if getattr(raw_result, 'uncertainty', None) is not None: temp_result.uncertainty = raw_result.uncertainty.to(final_unit) # If the flux unit is instead equivalent to power density # (Jy, but defined in wavelength), enforce integration in wavelength space elif (flux_unit.is_equivalent(u.Unit('W/(m2 m)')) or flux_unit.is_equivalent(u.Unit('W/(m2 m sr)'))): # Perform integration in wavelength space using MKS unit (meters) wave_spec = Spectrum1D( spectral_axis=spec_subtracted.spectral_axis.to(u.m, equivalencies=u.spectral()), flux=spec_subtracted.flux, uncertainty=spec_subtracted.uncertainty) try: raw_result = analysis.line_flux(wave_spec) except ValueError as e: # can happen if interpolation out-of-bounds or any error from specutils # let's avoid the whole app crashing and instead expose the error to the # user self.hub.broadcast(SnackbarMessage( f"failed to calculate line analysis statistics: {e}", sender=self, color="warning")) self.update_results(None) return # When flux is equivalent to Jy, lineflux result should be shown in W/m2 if flux_unit.is_equivalent(u.Unit('W/(m2 m)'/u.sr)): final_unit = u.Unit('W/(m2 sr)') else: final_unit = u.Unit('W/m2') temp_result = raw_result.to(final_unit) if getattr(raw_result, 'uncertainty', None) is not None: temp_result.uncertainty = raw_result.uncertainty.to(final_unit) # Otherwise, just rely on the default specutils line_flux result else: temp_result = analysis.line_flux(spec_subtracted) elif function == "Equivalent Width": if np.any(continuum <= 0): temp_results.append({'function': function, 'result': '', 'error_msg': 'N/A (continuum <= 0)', 'uncertainty': '', 'unit': ''}) continue else: spec_normalized = spectrum / continuum if spec_normalized.mask is not None: spec_normalized.mask = spec_normalized.mask.astype(bool) temp_result = FUNCTIONS[function](spec_normalized) elif function == "Centroid": # TODO: update specutils to be consistent with region vs regions and default to # regions=None so this elif can be removed temp_result = FUNCTIONS[function](spec_subtracted, region=None) self.results_centroid = temp_result.to_value(u.AA, equivalencies=u.spectral()) else: # if the minimum flux is negative, translate the spectrum until it is # non-negative for the these line analysis functions: if spec_subtracted.flux.min() < 0: spec_subtracted_nonneg_flux = ( spec_subtracted - np.min((spectrum - continuum).flux) ) else: spec_subtracted_nonneg_flux = spec_subtracted temp_result = FUNCTIONS[function](spec_subtracted_nonneg_flux) temp_result = _coerce_unit(temp_result) temp_results.append({'function': function, 'result': str(temp_result.value), 'uncertainty': _uncertainty(temp_result), 'unit': str(temp_result.unit)}) if not self.selected_line and self.identified_line: # default to the identified line self.selected_line = self.identified_line self.update_results(temp_results) def _compute_redshift_for_selected_line(self): index = self.line_items.index(self.selected_line) line_mark = self.line_marks[index] rest_value = (line_mark.rest_value * line_mark.xunit).to_value(u.AA, equivalencies=u.spectral()) return (self.results_centroid - rest_value) / rest_value @observe('sync_identify') def _sync_identify_changed(self, event={}): if not event.get('new', self.sync_identify): return if not self.identified_line and self.selected_line: # then we just enabled the sync, but no line is currently # identified, so we'll identify the current selection msg = LineIdentifyMessage(self.selected_line, sender=self) self.hub.broadcast(msg) elif self.identified_line: # then update the selection the the identified line self.selected_line = self.identified_line @observe('selected_line') def _selected_line_changed(self, event): if self.sync_identify: msg = LineIdentifyMessage(event.get('new', self.selected_line), sender=self) self.hub.broadcast(msg) @observe('results_centroid', 'selected_line') def _update_selected_line_redshift(self, event): if self.selected_line and self.results_centroid is not None: # compute redshift that WILL be applied if clicking assign self.selected_line_redshift = self._compute_redshift_for_selected_line()
[docs] def vue_line_assign(self, msg=None): z = self._compute_redshift_for_selected_line() msg = RedshiftMessage('redshift', z, sender=self) self.hub.broadcast(msg)