from astropy import units as u
from glue.core.subset_group import GroupedSubset
from glue_jupyter.bqplot.image import BqplotImageView
import numpy as np
from traitlets import List, Unicode, observe, Bool
from jdaviz.core.events import GlobalDisplayUnitChanged, AddDataToViewerMessage
from jdaviz.core.registries import tray_registry
from jdaviz.core.template_mixin import (PluginTemplateMixin, UnitSelectPluginComponent,
SelectPluginComponent, PluginUserApi)
from jdaviz.core.validunits import (create_spectral_equivalencies_list,
create_flux_equivalencies_list,
check_if_unit_is_per_solid_angle,
create_angle_equivalencies_list)
__all__ = ['UnitConversion']
def _valid_glue_display_unit(unit_str, sv, axis='x'):
# need to make sure the unit string is formatted according to the list of valid choices
# that glue will accept (may not be the same as the defaults of the installed version of
# astropy)
if not unit_str:
return unit_str
unit_u = u.Unit(unit_str)
choices_str = getattr(sv.state.__class__, f'{axis}_display_unit').get_choices(sv.state)
choices_str = [choice for choice in choices_str if choice is not None]
choices_u = [u.Unit(choice) for choice in choices_str]
if unit_u not in choices_u:
raise ValueError(f"{unit_str} could not find match in valid {axis} display units {choices_str}") # noqa
ind = choices_u.index(unit_u)
return choices_str[ind]
[docs]
@tray_registry('g-unit-conversion', label="Unit Conversion",
viewer_requirements='spectrum')
class UnitConversion(PluginTemplateMixin):
"""
The Unit Conversion plugin handles global app-wide unit-conversion.
See the :ref:`Unit Conversion Plugin Documentation <unit-conversion>` 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`
* ``spectral_unit`` (:class:`~jdaviz.core.template_mixin.UnitSelectPluginComponent`):
Global unit to use for all spectral axes.
* ``flux_or_sb`` (:class:`~jdaviz.core.template_mixin.SelectPluginComponent`):
Select the y-axis physical type for the spectrum-viewer.
* ``flux_unit`` (:class:`~jdaviz.core.template_mixin.UnitSelectPluginComponent`):
Global display unit for flux axis.
* ``angle_unit`` (:class:`~jdaviz.core.template_mixin.UnitSelectPluginComponent`):
Solid angle unit.
"""
template_file = __file__, "unit_conversion.vue"
spectral_unit_items = List().tag(sync=True)
spectral_unit_selected = Unicode().tag(sync=True)
flux_unit_items = List().tag(sync=True)
flux_unit_selected = Unicode().tag(sync=True)
sb_unit_selected = Unicode().tag(sync=True)
angle_unit_items = List().tag(sync=True)
angle_unit_selected = Unicode().tag(sync=True)
flux_or_sb_items = List().tag(sync=True)
flux_or_sb_selected = Unicode().tag(sync=True)
# This is used a warning message if False. This can be changed from
# bool to unicode when we eventually handle inputing this value if it
# doesn't exist in the FITS header
pixar_sr_exists = Bool(True).tag(sync=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.config not in ['specviz', 'cubeviz']:
# TODO [specviz2d, mosviz] x_display_unit is not implemented in glue for image viewer
# used by spectrum-2d-viewer
# TODO [mosviz]: add to yaml file
# TODO [cubeviz, slice]: slice indicator broken after changing spectral_unit
# TODO: support for multiple viewers and handling of mixed state from glue (or does
# this force all to sync?)
self.disabled_msg = f'This plugin is temporarily disabled in {self.config}. Effort to improve it is being tracked at GitHub Issue 1972.' # noqa
# TODO [markers]: existing markers need converting
self.spectrum_viewer.state.add_callback('x_display_unit',
self._on_glue_x_display_unit_changed)
self.spectrum_viewer.state.add_callback('y_display_unit',
self._on_glue_y_display_unit_changed)
self.session.hub.subscribe(self, AddDataToViewerMessage,
handler=self._find_and_convert_contour_units)
self.spectral_unit = UnitSelectPluginComponent(self,
items='spectral_unit_items',
selected='spectral_unit_selected')
self.flux_or_sb = SelectPluginComponent(self,
items='flux_or_sb_items',
selected='flux_or_sb_selected',
manual_options=['Surface Brightness', 'Flux'])
self.flux_unit = UnitSelectPluginComponent(self,
items='flux_unit_items',
selected='flux_unit_selected')
self.angle_unit = UnitSelectPluginComponent(self,
items='angle_unit_items',
selected='angle_unit_selected')
@property
def user_api(self):
if self.app.config == 'cubeviz':
expose = ('spectral_unit', 'flux_or_sb', 'flux_unit', 'angle_unit', 'sb_unit')
else:
expose = ('spectral_unit', 'flux_unit', 'angle_unit')
return PluginUserApi(self, expose=expose)
@property
def sb_unit(self):
return self.sb_unit_selected
def _on_glue_x_display_unit_changed(self, x_unit_str):
if x_unit_str is None:
return
self.spectrum_viewer.set_plot_axes()
if x_unit_str != self.spectral_unit.selected:
x_unit_str = _valid_glue_display_unit(x_unit_str, self.spectrum_viewer, 'x')
x_unit = u.Unit(x_unit_str)
choices = create_spectral_equivalencies_list(x_unit)
# ensure that original entry is in the list of choices
if not np.any([x_unit == u.Unit(choice) for choice in choices]):
choices = [x_unit_str] + choices
self.spectral_unit.choices = choices
# in addition to the jdaviz options, allow the user to set any glue-valid unit
# which would then be appended on to the list of choices going forward
self.spectral_unit._addl_unit_strings = self.spectrum_viewer.state.__class__.x_display_unit.get_choices(self.spectrum_viewer.state) # noqa
self.spectral_unit.selected = x_unit_str
if not len(self.flux_unit.choices) or not len(self.angle_unit.choices):
# in case flux_unit was triggered first (but could not be set because there
# as no spectral_unit to determine valid equivalencies)
self._on_glue_y_display_unit_changed(self.spectrum_viewer.state.y_display_unit)
def _on_glue_y_display_unit_changed(self, y_unit_str):
if y_unit_str is None:
return
if self.spectral_unit.selected == "":
# no spectral unit set yet, cannot determine equivalencies
# setting the spectral unit will check len(flux_or_sb_unit.choices)
# and call this manually in the case that that is triggered second.
return
self.spectrum_viewer.set_plot_axes()
x_unit = u.Unit(self.spectral_unit.selected)
y_unit_str = _valid_glue_display_unit(y_unit_str, self.spectrum_viewer, 'y')
y_unit = u.Unit(y_unit_str)
if not check_if_unit_is_per_solid_angle(y_unit_str) and y_unit_str != self.flux_unit.selected: # noqa
flux_choices = create_flux_equivalencies_list(y_unit, x_unit)
# ensure that original entry is in the list of choices
if not np.any([y_unit == u.Unit(choice) for choice in flux_choices]):
flux_choices = [y_unit_str] + flux_choices
self.flux_unit.choices = flux_choices
self.flux_unit.selected = y_unit_str
# if the y-axis is set to surface brightness,
# untranslatable units need to be removed from the flux choices
if check_if_unit_is_per_solid_angle(y_unit_str):
flux_choices = create_flux_equivalencies_list(y_unit * u.sr, x_unit)
self.flux_unit.choices = flux_choices
# sets the angle unit drop down and the surface brightness read-only text
if self.app.data_collection[0]:
dc_unit = self.app.data_collection[0].get_component("flux").units
self.angle_unit.choices = create_angle_equivalencies_list(dc_unit)
self.angle_unit.selected = self.angle_unit.choices[0]
self.sb_unit_selected = self._append_angle_correctly(
self.flux_unit.selected,
self.angle_unit.selected
)
if self.angle_unit.selected == 'pix':
mouseover_unit = self.flux_unit.selected
else:
mouseover_unit = self.sb_unit_selected
self.hub.broadcast(GlobalDisplayUnitChanged("sb", mouseover_unit, sender=self))
else:
# if cube was loaded in flux units, we still need to broadcast
# a 'sb' message for mouseover info. this should be removed when
# unit change messaging is improved and is a temporary fix
self.hub.broadcast(GlobalDisplayUnitChanged('sb',
self.flux_unit.selected,
sender=self))
if not self.flux_unit.selected:
y_display_unit = self.spectrum_viewer.state.y_display_unit
self.flux_unit.selected = (str(u.Unit(y_display_unit * u.sr)))
@observe('spectral_unit_selected')
def _on_spectral_unit_changed(self, *args):
xunit = _valid_glue_display_unit(self.spectral_unit.selected, self.spectrum_viewer, 'x')
if self.spectrum_viewer.state.x_display_unit != xunit:
self.spectrum_viewer.state.x_display_unit = xunit
self.hub.broadcast(GlobalDisplayUnitChanged('spectral',
self.spectral_unit.selected,
sender=self))
@observe('flux_or_sb_selected')
def _on_flux_or_sb_selected(self, msg):
"""
Observes toggle between surface brightness or flux selection for
spectrum viewer to trigger translation.
"""
if msg.get('name') == 'flux_or_sb_selected':
self._translate(self.flux_or_sb_selected)
@observe('flux_unit_selected')
def _on_flux_unit_changed(self, msg):
"""
Observes changes in selected flux unit.
When the selected flux unit changes, a GlobalDisplayUnitChange needs
to be broadcasted indicating that the flux unit has changed.
Note: The 'axis' of the broadcast should always be 'flux', even though a
change in flux unit indicates a change in surface brightness unit, because
SB is read only, so anything observing for changes in surface brightness
should be looking for a change in 'flux' (as well as angle).
"""
if msg.get('name') != 'flux_unit_selected':
# not sure when this would be encountered but keeping as a safeguard
return
if not hasattr(self, 'flux_unit'):
return
if not self.flux_unit.choices and self.app.config == 'cubeviz':
return
# various plugins are listening for changes in either flux or sb and
# need to be able to filter messages accordingly, so broadcast both when
# flux unit is updated. if data was loaded in a flux unit (i.e MJy), it
# can be reperesented as a per-pixel surface brightness unit
flux_unit = self.flux_unit.selected
sb_unit = self._append_angle_correctly(flux_unit, self.angle_unit.selected)
self.hub.broadcast(GlobalDisplayUnitChanged("flux", flux_unit, sender=self))
self.hub.broadcast(GlobalDisplayUnitChanged("sb", sb_unit, sender=self))
spectral_y = sb_unit if self.flux_or_sb == 'Surface Brightness' else flux_unit
yunit = _valid_glue_display_unit(spectral_y, self.spectrum_viewer, 'y')
# update spectrum viewer with new y display unit
if self.spectrum_viewer.state.y_display_unit != yunit:
self.spectrum_viewer.state.y_display_unit = yunit
self.spectrum_viewer.reset_limits()
# and broacast that there has been a change in the spectral axis y unit
# to either a flux or surface brightness unit, for plugins that specifically
# care about this toggle selection
self.hub.broadcast(GlobalDisplayUnitChanged("spectral_y", spectral_y, sender=self))
if not check_if_unit_is_per_solid_angle(self.spectrum_viewer.state.y_display_unit):
self.flux_or_sb_selected = 'Flux'
else:
self.flux_or_sb_selected = 'Surface Brightness'
# Always send a surface brightness unit to contours
if self.flux_or_sb_selected == 'Flux':
yunit = self._append_angle_correctly(yunit, self.angle_unit.selected)
self._find_and_convert_contour_units(yunit=yunit)
# for displaying message that PIXAR_SR = 1 if it is not found in the FITS header
if (
len(self.app.data_collection) > 0
and not self.app.data_collection[0].meta.get('PIXAR_SR')
):
self.pixar_sr_exists = False
def _find_and_convert_contour_units(self, msg=None, yunit=None):
if not yunit:
yunit = self.sb_unit_selected
if msg is not None:
viewers = [self.app.get_viewer(msg.viewer_reference)]
else:
viewers = self._app._viewer_store.values()
if self.angle_unit_selected is None or self.angle_unit_selected == '':
# Can't do this before the plugin is initialized completely
return
for viewer in viewers:
if not isinstance(viewer, BqplotImageView):
continue
for layer in viewer.state.layers:
# DQ layer doesn't play nicely with this attribute
if "DQ" in layer.layer.label or isinstance(layer.layer, GroupedSubset):
continue
elif u.Unit(layer.layer.get_component("flux").units).physical_type != 'surface brightness': # noqa
continue
if hasattr(layer, 'attribute_display_unit'):
layer.attribute_display_unit = yunit
def _translate(self, flux_or_sb=None):
# currently unsupported, can be supported with a scale factor
if self.app.config == 'specviz':
return
if self.spectrum_viewer.state.y_display_unit:
spec_units = u.Unit(self.spectrum_viewer.state.y_display_unit)
else:
return
# on instantiation, we set determine flux choices and selection
# after surface brightness
if not self.flux_unit.choices:
return
# Surface Brightness -> Flux
if check_if_unit_is_per_solid_angle(spec_units) and flux_or_sb == 'Flux':
spec_units *= u.sr
# update display units
self.spectrum_viewer.state.y_display_unit = str(spec_units)
# Flux -> Surface Brightness
elif (not check_if_unit_is_per_solid_angle(spec_units)
and flux_or_sb == 'Surface Brightness'):
spec_units /= u.sr
# update display units
self.spectrum_viewer.state.y_display_unit = str(spec_units)
# entered the translator when we shouldn't translate
else:
return
# broadcast that there has been a change in the spectrum viewer y axis,
# if translation was completed
self.hub.broadcast(GlobalDisplayUnitChanged('spectral_y',
spec_units,
sender=self))
self.spectrum_viewer.reset_limits()
def _append_angle_correctly(self, flux_unit, angle_unit):
if angle_unit not in ['pix', 'sr']:
self.sb_unit_selected = flux_unit
return flux_unit
if '(' in flux_unit:
pos = flux_unit.rfind(')')
sb_unit_selected = flux_unit[:pos] + ' ' + angle_unit + flux_unit[pos:]
else:
# append angle if there are no parentheses
sb_unit_selected = flux_unit + ' / ' + angle_unit
if sb_unit_selected:
self.sb_unit_selected = sb_unit_selected
return sb_unit_selected