diff --git a/CHANGES.rst b/CHANGES.rst index 7b801c010b..e2978ea353 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -18,6 +18,8 @@ Mosviz Specviz ^^^^^^^ +* Re-enabled unit conversion support. [#2048] + Specviz2d ^^^^^^^^^ diff --git a/docs/specviz/plugins.rst b/docs/specviz/plugins.rst index e6ed909749..4fccafb30c 100644 --- a/docs/specviz/plugins.rst +++ b/docs/specviz/plugins.rst @@ -118,15 +118,8 @@ To export the table into the notebook via the API, call Unit Conversion =============== -.. note:: - - This plugin is temporarily disabled. Effort to improve it is being - tracked at https://github.com/spacetelescope/jdaviz/issues/1972 . - The spectral flux density and spectral axis units can be converted -using the Unit Conversion plugin. The Spectrum1D object to be -converted is the currently selected spectrum in the spectrum viewer :guilabel:`Data` -icon in the viewer toolbar. +using the Unit Conversion plugin. Select the frequency, wavelength, or energy unit in the :guilabel:`New Spectral Axis Unit` pulldown @@ -135,11 +128,8 @@ Select the frequency, wavelength, or energy unit in the Select the flux density unit in the :guilabel:`New Flux Unit` pulldown (e.g., Jansky, W/(Hz/m2), ph/(Angstrom cm2 s)). -The :guilabel:`Apply` button will convert the flux density and/or -spectral axis units and create a new Spectrum1D object that -is automatically switched to in the spectrum viewer. -The name of the new Spectrum1D object is "_units_copy_" plus -the flux and spectral units of the spectrum. +Note that this affects the default units in all viewers and plugins, where applicable, +but does not affect the underlying data. .. _line-lists: diff --git a/jdaviz/app.py b/jdaviz/app.py index 00ece17760..ac57a8a6cf 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -9,9 +9,9 @@ from ipywidgets import widget_serialization import ipyvue +from astropy import units as u from astropy.nddata import CCDData, NDData from astropy.io import fits -from astropy import units as u from astropy.coordinates import Angle from regions import PixCoord, CirclePixelRegion, RectanglePixelRegion, EllipsePixelRegion @@ -27,7 +27,7 @@ from glue.config import colormaps, data_translator from glue.config import settings as glue_settings from glue.core import BaseData, HubListener, Data, DataCollection -from glue.core.link_helpers import LinkSame +from glue.core.link_helpers import LinkSame, LinkSameWithUnits from glue.plugins.wcs_autolinking.wcs_autolinking import WCSLink, IncompatibleWCS from glue.core.message import (DataCollectionAddMessage, DataCollectionDeleteMessage, @@ -38,6 +38,7 @@ from glue.core.subset import (Subset, RangeSubsetState, RoiSubsetState, CompositeSubsetState, InvertState) from glue.core.roi import CircularROI, EllipticalROI, RectangularROI +from glue.core.units import unit_converter from glue_astronomy.spectral_coordinates import SpectralCoordinates from glue_jupyter.app import JupyterApplication from glue_jupyter.common.toolbar_vuetify import read_icon @@ -68,10 +69,51 @@ mask=['mask', 'dq']) +@unit_converter('custom-jdaviz') +class UnitConverterWithSpectral: + + def equivalent_units(self, data, cid, units): + if cid.label == "flux": + eqv = u.spectral_density(1 * u.m) # Value does not matter here. + list_of_units = set(list(map(str, u.Unit(units).find_equivalent_units( + include_prefix_units=True, equivalencies=eqv))) + [ + 'Jy', 'mJy', 'uJy', + 'W / (m2 Hz)', 'W / (Hz m2)', # Order is different in astropy v5.3 + 'eV / (s m2 Hz)', 'eV / (Hz s m2)', + 'erg / (s cm2)', + 'erg / (s cm2 Angstrom)', 'erg / (Angstrom s cm2)', + 'erg / (s cm2 Hz)', 'erg / (Hz s cm2)', + 'ph / (s cm2 Angstrom)', 'ph / (Angstrom s cm2)', + 'ph / (s cm2 Hz)', 'ph / (Hz s cm2)' + ]) + else: # spectral axis + # prefer Hz over Bq and um over micron + exclude = {'Bq', 'micron'} + list_of_units = set(list(map(str, u.Unit(units).find_equivalent_units( + include_prefix_units=True, equivalencies=u.spectral())))) - exclude + return list_of_units + + def to_unit(self, data, cid, values, original_units, target_units): + # Given a glue data object (data), a component ID (cid), the values + # to convert, and the original and target units of the values, this method + # should return the converted values. Note that original_units + # gives the units of the values array, which might not be the same + # as the original native units of the component in the data. + if cid.label == "flux": + spec = data.get_object(cls=Spectrum1D) + eqv = u.spectral_density(spec.spectral_axis) + else: # spectral axis + eqv = u.spectral() + return (values * u.Unit(original_units)).to_value(u.Unit(target_units), equivalencies=eqv) + + # Set default opacity for data layers to 1 instead of 0.8 in # some glue-core versions glue_settings.DATA_ALPHA = 1 +# Enable spectrum unit conversion. +glue_settings.UNIT_CONVERTER = 'custom-jdaviz' + custom_components = {'j-tooltip': 'components/tooltip.vue', 'j-external-link': 'components/external_link.vue', 'j-docs-link': 'components/docs_link.vue', @@ -446,7 +488,7 @@ def _link_new_data(self, reference_data=None, data_to_be_linked=None): if isinstance(linked_data.coords, SpectralCoordinates): wc_old = ref_data.world_component_ids[-1] wc_new = linked_data.world_component_ids[0] - self.data_collection.add_link(LinkSame(wc_old, wc_new)) + self.data_collection.add_link(LinkSameWithUnits(wc_old, wc_new)) return try: @@ -492,8 +534,8 @@ def _link_new_data(self, reference_data=None, data_to_be_linked=None): else: continue - links.append(LinkSame(ref_data.pixel_component_ids[ref_index], - linked_data.pixel_component_ids[linked_index])) + links.append(LinkSameWithUnits(ref_data.pixel_component_ids[ref_index], + linked_data.pixel_component_ids[linked_index])) dc.add_link(links) @@ -828,7 +870,8 @@ def get_subsets_from_viewer(self, viewer_reference, data_label=None, subset_type return regions def get_subsets(self, subset_name=None, spectral_only=False, - spatial_only=False, object_only=False): + spatial_only=False, object_only=False, + use_display_units=False): """ Returns all branches of glue subset tree in the form that subset plugin can recognize. @@ -843,6 +886,8 @@ def get_subsets(self, subset_name=None, spectral_only=False, object_only : bool Return only object relevant information and leave out the region class name and glue_state. + use_display_units: bool, optional + Whether to convert to the display units defined in the plugin. Returns ------- @@ -861,14 +906,15 @@ def get_subsets(self, subset_name=None, spectral_only=False, if isinstance(subset.subset_state, CompositeSubsetState): # Region composed of multiple ROI or Range subset # objects that must be traversed - subset_region = self.get_sub_regions(subset.subset_state) + subset_region = self.get_sub_regions(subset.subset_state, use_display_units) elif isinstance(subset.subset_state, RoiSubsetState): # 3D regions represented as a dict including an # AstropyRegion object if possible subset_region = self._get_roi_subset_definition(subset.subset_state) elif isinstance(subset.subset_state, RangeSubsetState): # 2D regions represented as SpectralRegion objects - subset_region = self._get_range_subset_bounds(subset.subset_state) + subset_region = self._get_range_subset_bounds(subset.subset_state, + use_display_units) else: # subset.subset_state can be an instance of MaskSubsetState # or something else we do not know how to handle @@ -909,17 +955,20 @@ def _remove_duplicate_bounds(self, spec_regions): regions_no_dups += region return regions_no_dups - def _get_range_subset_bounds(self, subset_state): - # TODO: Use global display units + def _get_range_subset_bounds(self, subset_state, use_display_units): # units = dc[0].data.coords.spectral_axis.unit viewer = self.get_viewer(self._jdaviz_helper. _default_spectrum_viewer_reference_name) data = viewer.data() - if viewer: - units = u.Unit(viewer.state.x_display_unit) - elif data and len(data) > 0 and isinstance(data[0], Spectrum1D): + if data and len(data) > 0 and isinstance(data[0], Spectrum1D): units = data[0].spectral_axis.unit else: raise ValueError("Unable to find spectral axis units") + if use_display_units: + # converting may result in flipping order (wavelength <-> frequency) + ret_units = self._get_display_unit('spectral') + subset_bounds = [(subset_state.lo * units).to(ret_units, u.spectral()), + (subset_state.hi * units).to(ret_units, u.spectral())] + return SpectralRegion(min(subset_bounds), max(subset_bounds)) return SpectralRegion(subset_state.lo * units, subset_state.hi * units) def _get_roi_subset_definition(self, subset_state): @@ -948,12 +997,12 @@ def _get_roi_subset_definition(self, subset_state): "glue_state": subset_state.__class__.__name__, "region": roi_as_region}] - def get_sub_regions(self, subset_state): + def get_sub_regions(self, subset_state, use_display_units): if isinstance(subset_state, CompositeSubsetState): if subset_state and hasattr(subset_state, "state2") and subset_state.state2: - one = self.get_sub_regions(subset_state.state1) - two = self.get_sub_regions(subset_state.state2) + one = self.get_sub_regions(subset_state.state1, use_display_units) + two = self.get_sub_regions(subset_state.state2, use_display_units) if (isinstance(one, list) and "glue_state" in one[0] and one[0]["glue_state"] == "RoiSubsetState"): @@ -1014,7 +1063,7 @@ def get_sub_regions(self, subset_state): else: # This gets triggered in the InvertState case where state1 # is an object and state2 is None - return self.get_sub_regions(subset_state.state1) + return self.get_sub_regions(subset_state.state1, use_display_units) elif subset_state is not None: # This is the leaf node of the glue subset state tree where # a subset_state is either ROI or Range. @@ -1022,7 +1071,16 @@ def get_sub_regions(self, subset_state): return self._get_roi_subset_definition(subset_state) elif isinstance(subset_state, RangeSubsetState): - return self._get_range_subset_bounds(subset_state) + return self._get_range_subset_bounds(subset_state, use_display_units) + + def _get_display_unit(self, axis): + if self._jdaviz_helper is None or self._jdaviz_helper.plugins.get('Unit Conversion') is None: # noqa + raise ValueError("cannot detect unit conversion plugin") + try: + return getattr(self._jdaviz_helper.plugins.get('Unit Conversion')._obj, + f'{axis}_unit_selected') + except AttributeError: + raise ValueError(f"could not find display unit for axis='{axis}'") def add_data(self, data, data_label=None, notify_done=True): """ @@ -1204,19 +1262,6 @@ def add_data_to_viewer(self, viewer_reference, data_label, self.set_data_visibility(viewer_item, data_label, visible=visible, replace=clear_other_data) - def _set_plot_axes_labels(self, viewer_id): - """ - Sets the plot axes labels to be the units of the data to be loaded. - - Parameters - ---------- - viewer_id : str - The UUID associated with the desired viewer item. - """ - viewer = self._viewer_by_id(viewer_id) - - viewer.set_plot_axes() - def remove_data_from_viewer(self, viewer_reference, data_label): """ Removes a data set from the specified viewer. @@ -1589,11 +1634,8 @@ def set_data_visibility(self, viewer_reference, data_label, visible=True, replac # active data. viewer_data_labels = [layer.layer.label for layer in viewer.layers] if len(viewer_data_labels) > 0 and getattr(self._jdaviz_helper, '_in_batch_load', 0) == 0: - active_data = self.data_collection[viewer_data_labels[0]] - if (hasattr(active_data, "_preferred_translation") - and active_data._preferred_translation is not None): - self._set_plot_axes_labels(viewer_id) - + # This "if" is nested on purpose to make parent "if" available + # for other configs in the future, as needed. if self.config == 'imviz': viewer.on_limits_change() # Trigger compass redraw diff --git a/jdaviz/configs/cubeviz/helper.py b/jdaviz/configs/cubeviz/helper.py index 24da3f0243..84b75826a1 100644 --- a/jdaviz/configs/cubeviz/helper.py +++ b/jdaviz/configs/cubeviz/helper.py @@ -120,7 +120,8 @@ def specviz(self): self._specviz = Specviz(app=self.app) return self._specviz - def get_data(self, data_label=None, cls=None, subset_to_apply=None, function=None): + def get_data(self, data_label=None, cls=None, subset_to_apply=None, function=None, + use_display_units=False): """ Returns data with name equal to data_label of type cls with subsets applied from subset_to_apply. @@ -136,6 +137,8 @@ def get_data(self, data_label=None, cls=None, subset_to_apply=None, function=Non function : {'minimum', 'maximum', 'mean', 'median', 'sum'}, optional If provided and not ``None`` and ``data_label`` points to cube-like data, the cube will be collapsed with the provided function. Otherwise the entire cube will be returned. + use_display_units: bool, optional + Whether to convert to the display units defined in the plugin. Returns ------- @@ -144,7 +147,7 @@ def get_data(self, data_label=None, cls=None, subset_to_apply=None, function=Non """ return self._get_data(data_label=data_label, cls=cls, subset_to_apply=subset_to_apply, - function=function) + function=function, use_display_units=use_display_units) def layer_is_cube_image_data(layer): diff --git a/jdaviz/configs/default/plugins/line_lists/line_lists.py b/jdaviz/configs/default/plugins/line_lists/line_lists.py index f46f8bb0b2..1061ff195d 100644 --- a/jdaviz/configs/default/plugins/line_lists/line_lists.py +++ b/jdaviz/configs/default/plugins/line_lists/line_lists.py @@ -199,7 +199,8 @@ def _on_viewer_data_changed(self, msg=None): self._on_spectrum_viewer_limits_changed() # will also trigger _auto_slider_step # set the choices (and default) for the units for new custom lines - self.custom_unit_choices = create_spectral_equivalencies_list(viewer_data) + self.custom_unit_choices = create_spectral_equivalencies_list( + viewer_data.spectral_axis.unit) self.custom_unit = str(viewer_data.spectral_axis.unit) def _parse_redshift_msg(self, msg): @@ -410,6 +411,10 @@ def vue_slider_reset(self, event): def _on_spectrum_viewer_limits_changed(self, event=None): sv = self.app.get_viewer(self._default_spectrum_viewer_reference_name) + + if sv.state.x_min is None or sv.state.x_max is None: + return + self.spectrum_viewer_min = float(sv.state.x_min) self.spectrum_viewer_max = float(sv.state.x_max) diff --git a/jdaviz/configs/default/plugins/viewers.py b/jdaviz/configs/default/plugins/viewers.py index de2660590f..7023f19671 100644 --- a/jdaviz/configs/default/plugins/viewers.py +++ b/jdaviz/configs/default/plugins/viewers.py @@ -115,8 +115,6 @@ def _get_layer_info(layer): return "mdi-chart-bell-curve", "" return "", suffix - return '', '' - visible_layers = {} for layer in self.state.layers[::-1]: if layer.visible: diff --git a/jdaviz/configs/imviz/plugins/coords_info/coords_info.py b/jdaviz/configs/imviz/plugins/coords_info/coords_info.py index 3da6b7dc49..a66b3f1f9b 100644 --- a/jdaviz/configs/imviz/plugins/coords_info/coords_info.py +++ b/jdaviz/configs/imviz/plugins/coords_info/coords_info.py @@ -389,13 +389,10 @@ def _image_viewer_update(self, viewer, x, y): def _spectrum_viewer_update(self, viewer, x, y): def _cursor_fallback(): - statistic = getattr(viewer.state, 'function', None) - cache_key = (viewer.state.layers[0].layer.label, statistic) - sp = self.app._get_object_cache.get(cache_key, viewer.data()[0]) self._dict['axes_x'] = x - self._dict['axes_x:unit'] = sp.spectral_axis.unit.to_string() + self._dict['axes_x:unit'] = viewer.state.x_display_unit self._dict['axes_y'] = y - self._dict['axes_y:unit'] = sp.flux.unit.to_string() + self._dict['axes_y:unit'] = viewer.state.y_display_unit self._dict['data_label'] = '' def _copy_axes_to_spectral(): @@ -418,8 +415,6 @@ def _copy_axes_to_spectral(): self.row3_text = '' self.icon = 'mdi-cursor-default' self.marks[viewer._reference_id].visible = False - # get the units from the first layer - # TODO: replace with display units once implemented _cursor_fallback() _copy_axes_to_spectral() return @@ -462,17 +457,22 @@ def _copy_axes_to_spectral(): subset_to_apply=subset_label) self.app._get_object_cache[cache_key] = sp + # Calculations have to happen in the frame of viewer display units. + disp_wave = sp.spectral_axis.to_value(viewer.state.x_display_unit, u.spectral()) + disp_flux = sp.flux.to_value(viewer.state.y_display_unit, + u.spectral_density(sp.spectral_axis)) + # Out of range in spectral axis. if (self.dataset.selected != lyr.layer.label and - (x < sp.spectral_axis.value.min() or x > sp.spectral_axis.value.max())): + (x < disp_wave.min() or x > disp_wave.max())): continue - cur_i = np.argmin(abs(sp.spectral_axis.value - x)) - cur_wave = sp.spectral_axis[cur_i] - cur_flux = sp.flux[cur_i] + cur_i = np.argmin(abs(disp_wave - x)) + cur_wave = disp_wave[cur_i] + cur_flux = disp_flux[cur_i] - dx = cur_wave.value - x - dy = cur_flux.value - y + dx = cur_wave - x + dy = cur_flux - y cur_distance = math.sqrt(dx * dx + dy * dy) if (closest_distance is None) or (cur_distance < closest_distance): closest_distance = cur_distance @@ -497,27 +497,34 @@ def _copy_axes_to_spectral(): return self.row2_title = 'Wave' - self.row2_text = f'{closest_wave.value:10.5e} {closest_wave.unit.to_string()}' - self._dict['axes_x'] = closest_wave.value - self._dict['axes_x:unit'] = closest_wave.unit.to_string() - if closest_wave.unit != u.pix: + self.row2_text = f'{closest_wave:10.5e} {viewer.state.x_display_unit}' + self._dict['axes_x'] = closest_wave + self._dict['axes_x:unit'] = viewer.state.x_display_unit + if viewer.state.x_display_unit != u.pix: self.row2_text += f' ({int(closest_i)} pix)' if self.app.config == 'cubeviz': # float to be compatible with nan self._dict['slice'] = float(closest_i) - self._dict['spectral_axis'] = closest_wave.value - self._dict['spectral_axis:unit'] = closest_wave.unit.to_string() + self._dict['spectral_axis'] = closest_wave + self._dict['spectral_axis:unit'] = viewer.state.x_display_unit else: # float to be compatible with nan self._dict['index'] = float(closest_i) + if viewer.state.y_display_unit is None: + flux_unit = "" + else: + flux_unit = viewer.state.y_display_unit self.row3_title = 'Flux' - self.row3_text = f'{closest_flux.value:10.5e} {closest_flux.unit.to_string()}' - self._dict['axes_y'] = closest_flux.value - self._dict['axes_y:unit'] = closest_flux.unit.to_string() + self.row3_text = f'{closest_flux:10.5e} {flux_unit}' + self._dict['axes_y'] = closest_flux + self._dict['axes_y:unit'] = viewer.state.y_display_unit - self.icon = closest_icon + if closest_icon is not None: + self.icon = closest_icon + else: + self.icon = "" - self.marks[viewer._reference_id].update_xy([closest_wave.value], [closest_flux.value]) # noqa + self.marks[viewer._reference_id].update_xy([closest_wave], [closest_flux]) self.marks[viewer._reference_id].visible = True _copy_axes_to_spectral() diff --git a/jdaviz/configs/mosviz/plugins/parsers.py b/jdaviz/configs/mosviz/plugins/parsers.py index a04ffd90a9..0ffec66b9d 100644 --- a/jdaviz/configs/mosviz/plugins/parsers.py +++ b/jdaviz/configs/mosviz/plugins/parsers.py @@ -10,7 +10,7 @@ from astropy.io.registry import IORegistryError from astropy.wcs import WCS from glue.core.data import Data -from glue.core.link_helpers import LinkSame +from glue.core.link_helpers import LinkSameWithUnits from specutils import Spectrum1D, SpectrumList, SpectrumCollection from specutils.io.default_loaders.jwst_reader import identify_jwst_s2d_multi_fits @@ -141,7 +141,7 @@ def link_data_in_table(app, data_obj=None): wc_spec_1d = app.session.data_collection[spec_1d].world_component_ids wc_spec_2d = app.session.data_collection[spec_2d].world_component_ids - wc_spec_ids.append(LinkSame(wc_spec_1d[0], wc_spec_2d[1])) + wc_spec_ids.append(LinkSameWithUnits(wc_spec_1d[0], wc_spec_2d[1])) app.session.data_collection.add_link(wc_spec_ids) diff --git a/jdaviz/configs/specviz/helper.py b/jdaviz/configs/specviz/helper.py index 04db731d89..7692ec04be 100644 --- a/jdaviz/configs/specviz/helper.py +++ b/jdaviz/configs/specviz/helper.py @@ -271,7 +271,7 @@ def set_spectrum_tick_format(self, fmt, axis=None): self._default_spectrum_viewer_reference_name ).figure.axes[axis].tick_format = fmt - def get_data(self, data_label=None, cls=None, subset_to_apply=None): + def get_data(self, data_label=None, cls=None, subset_to_apply=None, use_display_units=False): """ Returns data with name equal to data_label of type cls with subsets applied from subset_to_apply. @@ -284,6 +284,8 @@ def get_data(self, data_label=None, cls=None, subset_to_apply=None): The type that data will be returned as. subset_to_apply : str, optional Subset that is to be applied to data before it is returned. + use_display_units: bool, optional + Whether to convert to the display units defined in the plugin. Returns ------- @@ -302,4 +304,4 @@ def get_data(self, data_label=None, cls=None, subset_to_apply=None): function = None return self._get_data(data_label=data_label, cls=cls, subset_to_apply=subset_to_apply, - function=function) + function=function, use_display_units=use_display_units) diff --git a/jdaviz/configs/specviz/plugins/parsers.py b/jdaviz/configs/specviz/plugins/parsers.py index a567e65ddc..73c019060b 100644 --- a/jdaviz/configs/specviz/plugins/parsers.py +++ b/jdaviz/configs/specviz/plugins/parsers.py @@ -76,14 +76,6 @@ def specviz_spectrum1d_parser(app, data, data_label=None, format=None, show_in_v raise ValueError(f"Length of data labels list ({len(data_label)}) is different" f" than length of list of data ({len(data)})") - # If there's already visible data in the viewer, convert units if needed - current_unit = None - if spectrum_viewer_reference_name in app.get_viewer_reference_names(): - sv = app.get_viewer(spectrum_viewer_reference_name) - for layer_state in sv.state.layers: - if layer_state.visible: - current_unit = sv.state.x_display_unit - with app.data_collection.delay_link_manager_update(): # these are used to build a combined spectrum with all # input spectra included (taken from https://github.com/spacetelescope/ @@ -98,10 +90,6 @@ def specviz_spectrum1d_parser(app, data, data_label=None, format=None, show_in_v wave_units = spec.spectral_axis.unit flux_units = spec.flux.unit - if current_unit is not None and spec.spectral_axis.unit != current_unit: - spec = Spectrum1D(flux=spec.flux, - spectral_axis=spec.spectral_axis.to(current_unit)) - # Make metadata layout conform with other viz. spec.meta = standardize_metadata(spec.meta) diff --git a/jdaviz/configs/specviz/plugins/unit_conversion/tests/test_unit_conversion.py b/jdaviz/configs/specviz/plugins/unit_conversion/tests/test_unit_conversion.py index cf1af35805..245c19c124 100644 --- a/jdaviz/configs/specviz/plugins/unit_conversion/tests/test_unit_conversion.py +++ b/jdaviz/configs/specviz/plugins/unit_conversion/tests/test_unit_conversion.py @@ -1,272 +1,91 @@ -import numpy as np import pytest from astropy import units as u -from astropy.nddata import UnknownUncertainty -from astropy.tests.helper import assert_quantity_allclose -from glue.core.roi import XRangeROI - -from jdaviz.configs.specviz.plugins.unit_conversion import unit_conversion as uc - -RESULT_SPECTRAL_AXIS = [0.6, 0.62222222, 0.64444444, 0.66666667, - 0.68888889, 0.71111111, 0.73333333, - 0.75555556, 0.77777778, 0.8] * u.micron - -RESULT_FLUX = [1.04067240e-07, 9.52912307e-08, 9.77144651e-08, - 1.00212528e-07, 8.55573341e-08, 8.29285448e-08, - 9.05651431e-08, 8.33870526e-08, 7.47628902e-08, - 7.74896053e-08] * u.Unit("erg / (s cm2 um)") - -RESULT_UNCERTAINTY = [3.85914248e-09, 3.60631495e-09, 1.74661581e-09, - 1.29057072e-08, 1.08965936e-08, 3.33352891e-09, - 5.64618219e-09, 1.65028707e-09, 4.49994292e-09, - 6.61559372e-09] +# On failure, should not crash; essentially a no-op. @pytest.mark.parametrize( - ('new_spectral_axis', 'new_flux'), - [("fail", "erg / (s cm2 um)"), - ("None", "fail"), - ("micron", "fail")]) -def test_value_error_exception(specviz_helper, spectrum1d, new_spectral_axis, new_flux): - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) - - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d, - new_flux=new_flux, - new_spectral_axis=new_spectral_axis) - - assert converted_spectrum is None - - -def test_no_spec_no_flux_no_uncert(specviz_helper, spectrum1d): - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) - - spectrum1d.uncertainty = None - - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d) - - assert converted_spectrum.flux.unit == spectrum1d.flux.unit - assert converted_spectrum.spectral_axis.unit == spectrum1d.spectral_axis.unit - assert converted_spectrum.uncertainty is None - - # Test that applying and removing Subset disables and enables it, respectively. - conv_plugin = specviz_helper.app.get_tray_item_from_name('g-unit-conversion') - specviz_helper.app.get_viewer("spectrum-viewer").apply_roi(XRangeROI(6000, 6500)) - assert conv_plugin.disabled_msg == 'Please create Subsets only after unit conversion' - specviz_helper.app.data_collection.remove_subset_group( - specviz_helper.app.data_collection.subset_groups[0]) - assert conv_plugin.disabled_msg == '' - - -def test_spec_no_flux_no_uncert(specviz_helper, spectrum1d): - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) - + ('new_spectral_axis', 'new_flux', 'expected_spectral_axis', 'expected_flux'), + [("fail", "erg / (s cm2 Angstrom)", "Angstrom", "erg / (s cm2 Angstrom)"), + ("None", "fail", "Angstrom", "Jy"), + ("micron", "fail", "micron", "Jy")]) +def test_value_error_exception(specviz_helper, spectrum1d, new_spectral_axis, new_flux, + expected_spectral_axis, expected_flux): + specviz_helper.load_spectrum(spectrum1d, data_label="Test 1D Spectrum") + viewer = specviz_helper.app.get_viewer("spectrum-viewer") + plg = specviz_helper.plugins["Unit Conversion"] + + try: + plg.spectral_unit = new_spectral_axis + except ValueError as e: + if "reverting selection to" not in repr(e): + raise + try: + plg.flux_unit = new_flux + except ValueError as e: + if "reverting selection to" not in repr(e): + raise + + assert len(specviz_helper.app.data_collection) == 1 + assert u.Unit(viewer.state.x_display_unit) == u.Unit(expected_spectral_axis) + assert u.Unit(viewer.state.y_display_unit) == u.Unit(expected_flux) + + +@pytest.mark.parametrize('uncert', (False, True)) +def test_conv_wave_only(specviz_helper, spectrum1d, uncert): + if uncert is False: + spectrum1d.uncertainty = None + specviz_helper.load_spectrum(spectrum1d, data_label="Test 1D Spectrum") + + viewer = specviz_helper.app.get_viewer("spectrum-viewer") + plg = specviz_helper.plugins["Unit Conversion"] new_spectral_axis = "micron" - spectrum1d.uncertainty = None - - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d, - new_spectral_axis=new_spectral_axis) - - assert_quantity_allclose(converted_spectrum.spectral_axis, - RESULT_SPECTRAL_AXIS, atol=1e-5*u.um) - assert converted_spectrum.flux.unit == spectrum1d.flux.unit - assert converted_spectrum.spectral_axis.unit == new_spectral_axis - assert converted_spectrum.uncertainty is None - + plg.spectral_unit = new_spectral_axis -def test_no_spec_no_flux_uncert(specviz_helper, spectrum1d): - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) + assert len(specviz_helper.app.data_collection) == 1 + assert u.Unit(viewer.state.x_display_unit) == u.Unit(new_spectral_axis) + assert u.Unit(viewer.state.y_display_unit) == u.Unit('Jy') - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d) - assert converted_spectrum.flux.unit == spectrum1d.flux.unit +@pytest.mark.parametrize('uncert', (False, True)) +def test_conv_flux_only(specviz_helper, spectrum1d, uncert): + if uncert is False: + spectrum1d.uncertainty = None + specviz_helper.load_spectrum(spectrum1d, data_label="Test 1D Spectrum") + viewer = specviz_helper.app.get_viewer("spectrum-viewer") + plg = specviz_helper.plugins["Unit Conversion"] + new_flux = "erg / (s cm2 Angstrom)" + plg.flux_unit = new_flux -def test_no_spec_no_flux_uncert_unit_exp_none(specviz_helper, spectrum1d): - np.random.seed(42) - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) + assert len(specviz_helper.app.data_collection) == 1 + assert u.Unit(viewer.state.x_display_unit) == u.Unit('Angstrom') + assert u.Unit(viewer.state.y_display_unit) == u.Unit(new_flux) - spectrum1d.uncertainty = UnknownUncertainty(np.abs( - np.random.randn(len(spectrum1d.spectral_axis)))) - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d) - - assert converted_spectrum.flux.unit == spectrum1d.flux.unit - assert converted_spectrum.spectral_axis.unit == spectrum1d.spectral_axis.unit - assert converted_spectrum.uncertainty is None - - -def test_no_spec_flux_no_uncert(specviz_helper, spectrum1d): - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) - - spectrum1d.uncertainty = None - new_flux = "erg / (s cm2 um)" - - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d, - new_flux=new_flux) - - assert_quantity_allclose(converted_spectrum.flux, - RESULT_FLUX, atol=1e-5*u.Unit(new_flux)) - assert converted_spectrum.spectral_axis.unit == spectrum1d.spectral_axis.unit - assert converted_spectrum.uncertainty is None - - -def test_no_spec_flux_unit_exp_not_none(specviz_helper, spectrum1d): - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) - - new_flux = "erg / (s cm2 um)" - - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d, - new_flux=new_flux) - - assert_quantity_allclose(converted_spectrum.flux, - RESULT_FLUX, atol=1e-5*u.Unit(new_flux)) - assert_quantity_allclose(converted_spectrum.uncertainty.quantity.value, - RESULT_UNCERTAINTY, atol=1e-11) - - -def test_spec_flux_no_uncert(specviz_helper, spectrum1d): - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) +@pytest.mark.parametrize('uncert', (False, True)) +def test_conv_wave_flux(specviz_helper, spectrum1d, uncert): + if uncert is False: + spectrum1d.uncertainty = None + specviz_helper.load_spectrum(spectrum1d, data_label="Test 1D Spectrum") + viewer = specviz_helper.app.get_viewer("spectrum-viewer") + plg = specviz_helper.plugins["Unit Conversion"] new_spectral_axis = "micron" - new_flux = "erg / (s cm2 um)" - - spectrum1d.uncertainty = None - - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d, - new_flux=new_flux, - new_spectral_axis=new_spectral_axis) - - assert_quantity_allclose(converted_spectrum.flux, - RESULT_FLUX, atol=1e-5*u.Unit(new_flux)) - assert_quantity_allclose(converted_spectrum.spectral_axis, - RESULT_SPECTRAL_AXIS, atol=1e-5*u.um) - assert converted_spectrum.uncertainty is None - - -def test_spec_no_flux_uncert_no_unit_exp(specviz_helper, spectrum1d): - np.random.seed(42) - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) - - new_spectral_axis = "micron" - - spectrum1d.uncertainty = UnknownUncertainty(np.abs( - np.random.randn(len(spectrum1d.spectral_axis)))) - - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d, - new_spectral_axis=new_spectral_axis) - - assert converted_spectrum.flux.unit == spectrum1d.flux.unit - assert_quantity_allclose(converted_spectrum.spectral_axis, - RESULT_SPECTRAL_AXIS, atol=1e-5*u.um) - assert converted_spectrum.uncertainty is None - - -def test_no_spec_flux_uncert_no_unit_exp(specviz_helper, spectrum1d): - np.random.seed(42) - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) - - new_flux = "erg / (s cm2 um)" - - spectrum1d.uncertainty = UnknownUncertainty(np.abs( - np.random.randn(len(spectrum1d.spectral_axis)))) - - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d, - new_flux=new_flux) - - assert converted_spectrum.spectral_axis.unit == spectrum1d.spectral_axis.unit - assert_quantity_allclose(converted_spectrum.flux, - RESULT_FLUX, atol=1e-5*u.Unit(new_flux)) - assert converted_spectrum.uncertainty is None - - -def test_spec_no_flux_uncert_unit_exp(specviz_helper, spectrum1d): - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) - - new_spectral_axis = "micron" - - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d, - new_spectral_axis=new_spectral_axis) - - assert_quantity_allclose(converted_spectrum.spectral_axis, - RESULT_SPECTRAL_AXIS, atol=1e-5*u.um) - - -def test_spec_flux_uncert_no_unit_exp(specviz_helper, spectrum1d): - np.random.seed(42) - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) - - new_spectral_axis = "micron" - new_flux = "erg / (s cm2 um)" - - spectrum1d.uncertainty = UnknownUncertainty(np.abs( - np.random.randn(len(spectrum1d.spectral_axis)))) - - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d, - new_flux=new_flux, - new_spectral_axis=new_spectral_axis) - - assert converted_spectrum.uncertainty is None - assert_quantity_allclose(converted_spectrum.spectral_axis, - RESULT_SPECTRAL_AXIS, atol=1e-5*u.um) - assert_quantity_allclose(converted_spectrum.flux, - RESULT_FLUX, atol=1e-5*u.Unit(new_flux)) - - -def test_spec_flux_uncert_unit_exp(specviz_helper, spectrum1d): - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) - - new_spectral_axis = "micron" - new_flux = "erg / (s cm2 um)" - - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d, - new_flux=new_flux, - new_spectral_axis=new_spectral_axis) - - assert_quantity_allclose(converted_spectrum.spectral_axis, - RESULT_SPECTRAL_AXIS, atol=1e-5*u.um) - assert_quantity_allclose(converted_spectrum.flux, - RESULT_FLUX, atol=1e-5*u.Unit(new_flux)) - assert_quantity_allclose(converted_spectrum.uncertainty.quantity.value, - RESULT_UNCERTAINTY, atol=1e-11) - - -def test_converted_spec_is_none(specviz_helper, spectrum1d): - label = "Test 1D Spectrum" - specviz_helper.load_spectrum(spectrum1d, data_label=label) - - new_spectral_axis = "feet" - - unit_conversion = specviz_helper.app.get_tray_item_from_name("g-unit-conversion") - unit_conversion.new_spectral_axis_unit = new_spectral_axis - converted_spectrum = unit_conversion.vue_unit_conversion(specviz_helper.app, - spectrum=spectrum1d, - new_flux=None, - new_spectral_axis=new_spectral_axis) - - assert converted_spectrum is None + new_flux = "erg / (s cm2 Angstrom)" + plg.spectral_unit = new_spectral_axis + plg.flux_unit = new_flux + + assert len(specviz_helper.app.data_collection) == 1 + assert u.Unit(viewer.state.x_display_unit) == u.Unit(new_spectral_axis) + assert u.Unit(viewer.state.y_display_unit) == u.Unit(new_flux) + + +def test_conv_no_data(specviz_helper): + """plugin unit selections won't have valid choices yet, preventing + attempting to set display units.""" + plg = specviz_helper.plugins["Unit Conversion"] + with pytest.raises(ValueError, match="no valid unit choices"): + plg.spectral_unit = "micron" + with pytest.raises(ValueError, match="no valid unit choices"): + plg.flux_unit = "erg / (s cm2 Angstrom)" + assert len(specviz_helper.app.data_collection) == 0 diff --git a/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.py b/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.py index acae7e3f26..05d62ddb79 100644 --- a/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.py +++ b/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.py @@ -1,274 +1,133 @@ -from packaging.version import Version - -import specutils +import numpy as np from astropy import units as u -from astropy.nddata import VarianceUncertainty, StdDevUncertainty, InverseVariance -from glue.core.message import SubsetCreateMessage, SubsetDeleteMessage -from traitlets import List, Unicode, Any, observe +from traitlets import List, Unicode, observe -from jdaviz.core.events import SnackbarMessage, RedshiftMessage from jdaviz.core.registries import tray_registry -from jdaviz.core.template_mixin import PluginTemplateMixin, DatasetSelectMixin +from jdaviz.core.template_mixin import PluginTemplateMixin, UnitSelectPluginComponent, PluginUserApi from jdaviz.core.validunits import (create_spectral_equivalencies_list, create_flux_equivalencies_list) -from jdaviz.configs.specviz.helper import _apply_redshift_to_spectra __all__ = ['UnitConversion'] -unit_exponents = {StdDevUncertainty: 1, - InverseVariance: -2, - VarianceUncertainty: 2} -SPECUTILS_GT_1_7_0 = Version(specutils.__version__) > Version('1.7.0') + +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] @tray_registry('g-unit-conversion', label="Unit Conversion", viewer_requirements='spectrum') -class UnitConversion(PluginTemplateMixin, DatasetSelectMixin): - +class UnitConversion(PluginTemplateMixin): + """ + The Unit Conversion plugin handles global app-wide unit-conversion. + See the :ref:`Unit Conversion Plugin Documentation ` for more details. + + Only the following attributes and methods are available through the + :ref:`public plugin API `: + + * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.show` + * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.open_in_tray` + * ``spectral_unit`` (:class:`~jdaviz.core.template_mixin.SelectPluginComponent`): + Global unit to use for all spectral axes. + * ``flux_unit`` (:class:`~jdaviz.core.template_mixin.SelectPluginComponent`): + Global unit to use for all flux axes. + """ template_file = __file__, "unit_conversion.vue" - current_flux_unit = Unicode().tag(sync=True) - current_spectral_axis_unit = Unicode().tag(sync=True) - new_flux_unit = Any().tag(sync=True) - new_spectral_axis_unit = Any().tag(sync=True) - - spectral_axis_unit_equivalencies = List([]).tag(sync=True) - flux_unit_equivalencies = List([]).tag(sync=True) + 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) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.hub.subscribe(self, SubsetCreateMessage, handler=self._on_viewer_subset_changed) - self.hub.subscribe(self, SubsetDeleteMessage, handler=self._on_viewer_subset_changed) - - self._redshift = None - self.app.hub.subscribe(self, RedshiftMessage, handler=self._redshift_listener) - - self._default_spectrum_viewer_reference_name = kwargs.get( - "spectrum_viewer_reference_name", "spectrum-viewer" - ) - - # when accessing the selected data, access the spectrum-viewer version - # TODO: we'll probably want to update unit-conversion to be able to act on cubes directly - # in the future - 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') - - def _on_viewer_subset_changed(self, *args): - if len(self.app.data_collection.subset_groups) == 0: - self.disabled_msg = '' - else: - self.disabled_msg = 'Please create Subsets only after unit conversion' - - def _redshift_listener(self, msg): - '''Save new redshifts (including from the helper itself)''' - if msg.param == "redshift": - self._redshift = msg.value - - @observe('dataset_selected') - def update_ui(self, event=None): - """ - Set up UI to have all values of currently visible spectra. - """ - spectrum = self.dataset.selected_obj - if spectrum is None: + if self.config not in ['specviz']: + # 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.spectral_unit = UnitSelectPluginComponent(self, + items='spectral_unit_items', + selected='spectral_unit_selected') + self.flux_unit = UnitSelectPluginComponent(self, + items='flux_unit_items', + selected='flux_unit_selected') + + @property + def user_api(self): + return PluginUserApi(self, expose=('spectral_unit', 'flux_unit')) + + def _on_glue_x_display_unit_changed(self, x_unit): + if x_unit is None: return - - # Set UI label to show current flux and spectral axis units. - self.current_flux_unit = spectrum.flux.unit.to_string() - self.current_spectral_axis_unit = spectrum.spectral_axis.unit.to_string() - - # Populate drop down with all valid options for unit conversion. - self.spectral_axis_unit_equivalencies = create_spectral_equivalencies_list(spectrum) - self.flux_unit_equivalencies = create_flux_equivalencies_list(spectrum) - - def vue_unit_conversion(self, *args, **kwargs): - """ - Runs when the ``apply`` button is hit. Tries to change units if ``new`` units are set - and are valid. - """ - if self._redshift is not None: - # apply the global redshift to the new spectrum - spectrum = _apply_redshift_to_spectra(self.dataset.selected_obj, self._redshift) - else: - spectrum = self.dataset.selected_obj - - converted_spec = self.process_unit_conversion(spectrum, - self.new_flux_unit, - self.new_spectral_axis_unit) - if converted_spec is None: + self.spectrum_viewer.set_plot_axes() + if x_unit != self.spectral_unit.selected: + x_unit = _valid_glue_display_unit(x_unit, self.spectrum_viewer, 'x') + x_u = u.Unit(x_unit) + choices = create_spectral_equivalencies_list(x_u) + # ensure that original entry is in the list of choices + if not np.any([x_u == u.Unit(choice) for choice in choices]): + choices = [x_unit] + 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 + if not len(self.flux_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): + if y_unit is None: return - - label = f"_units_copy_Flux:{converted_spec.flux.unit}_" +\ - f"SpectralAxis:{converted_spec.spectral_axis.unit}" - new_label = "" - - # Finds the '_units_copy_' spectrum and does unit conversions in that copy. - if "_units_copy_" in self.dataset_selected: - - selected_data_label = self.dataset_selected - selected_data_label_split = selected_data_label.split("_units_copy_") - - new_label = selected_data_label_split[0] + label - - original_spectrum = self.data_collection[selected_data_label_split[0]] - original_flux = original_spectrum.get_object().flux.unit - original_spectral_axis = original_spectrum.get_object().spectral_axis.unit - - if new_label in self.data_collection: - # Spectrum with these converted units already exists. - msg = SnackbarMessage( - "Spectrum with these units already exists, please check the data drop down.", - color="warning", - sender=self) - self.hub.broadcast(msg) - return - - elif converted_spec.flux.unit == original_flux and \ - converted_spec.spectral_axis.unit == original_spectral_axis: - # Check if converted units already exist in the original spectrum. - msg = SnackbarMessage( - "These are the units of the original spectrum, please use " - "that spectrum instead.", - color="warning", - sender=self) - self.hub.broadcast(msg) - return - - else: - # Add spectrum with converted units to app. - self.app.add_data(converted_spec, new_label) - self.app.add_data_to_viewer( - self._default_spectrum_viewer_reference_name, - new_label, clear_other_data=True - ) - - else: - new_label = self.dataset_selected + label - - if new_label in self.data_collection: - # Spectrum with these converted units already exists. - msg = SnackbarMessage( - "Spectrum with these units already exists, please check the data drop down.", - color="warning", - sender=self) - self.hub.broadcast(msg) - - return - else: - - # Replace old spectrum with new one with updated units. - self.app.add_data(converted_spec, new_label) - - self.app.add_data_to_viewer( - self._default_spectrum_viewer_reference_name, - new_label, clear_other_data=True - ) - snackbar_message = SnackbarMessage( - f"Data set '{label}' units converted successfully.", - color="success", - sender=self) - self.hub.broadcast(snackbar_message) - - def process_unit_conversion(self, spectrum, new_flux=None, new_spectral_axis=None): - """ - - Parameters - ---------- - spectrum : `specutils.Spectrum1D` - The spectrum that will have its units converted. - new_flux - The flux of spectrum will be converted to these units if they are provided. - new_spectral_axis - The spectral_axis of spectrum will be converted to these units if they are provided. - - Returns - ------- - converted_spectrum : `specutils.Spectrum1D` - A new spectrum with converted units. - """ - set_spectral_axis_unit = spectrum.spectral_axis - set_flux_unit = spectrum.flux - - current_flux_unit = spectrum.flux.unit.to_string() - current_spectral_axis_unit = spectrum.spectral_axis.unit.to_string() - - # Try to set new units if set and are valid. - if new_spectral_axis is not None \ - and new_spectral_axis != "" \ - and new_spectral_axis != current_spectral_axis_unit: - try: - set_spectral_axis_unit = spectrum.spectral_axis.to(u.Unit(new_spectral_axis)) - except ValueError as e: - snackbar_message = SnackbarMessage( - "Unable to convert spectral axis units for selected data. " - f"Try different units: {repr(e)}", - color="error", - sender=self) - self.hub.broadcast(snackbar_message) - - return - - # Try to set new units if set and are valid. - if new_flux is not None \ - and new_flux != "" \ - and new_flux != current_flux_unit: - try: - equivalencies = u.spectral_density(set_spectral_axis_unit) - set_flux_unit = spectrum.flux.to(u.Unit(new_flux), - equivalencies=equivalencies) - except ValueError as e: - snackbar_message = SnackbarMessage( - "Unable to convert flux units for selected data. " - f"Try different units: {repr(e)}", - color="error", - sender=self) - self.hub.broadcast(snackbar_message) - - return - - # Uncertainty converted to new flux units - if spectrum.uncertainty is not None: - unit_exp = unit_exponents.get(spectrum.uncertainty.__class__) - # If uncertainty type not in our lookup, drop the uncertainty - if unit_exp is None: - msg = SnackbarMessage( - "Warning: Unrecognized uncertainty type, cannot guarantee " - "conversion so dropping uncertainty in resulting data", - color="warning", - sender=self) - self.hub.broadcast(msg) - temp_uncertainty = None - else: - try: - # Catch and handle error trying to convert variance uncertainties - # between frequency and wavelength space. - # TODO: simplify this when astropy handles it - temp_uncertainty = spectrum.uncertainty.quantity**(1/unit_exp) - temp_uncertainty = temp_uncertainty.to(u.Unit(set_flux_unit.unit), - equivalencies=u.spectral_density(set_spectral_axis_unit)) # noqa - temp_uncertainty **= unit_exp - temp_uncertainty = spectrum.uncertainty.__class__(temp_uncertainty.value) - except u.UnitConversionError: - msg = SnackbarMessage( - "Warning: Could not convert uncertainty, setting to " - "None in converted data", - color="warning", - sender=self) - self.hub.broadcast(msg) - temp_uncertainty = None - else: - temp_uncertainty = None - - # Create new spectrum with new units. - converted_spectrum = spectrum._copy(flux=set_flux_unit, - wcs=None, - spectral_axis=set_spectral_axis_unit, - unit=set_flux_unit.unit, - uncertainty=temp_uncertainty) - if SPECUTILS_GT_1_7_0: - converted_spectrum.shift_spectrum_to(redshift=spectrum.redshift) - else: - converted_spectrum.redshift = spectrum.redshift - return converted_spectrum + if self.spectral_unit.selected == "": + # no spectral unit set yet, cannot determine equivalencies + # setting the spectral unit will check len(flux_unit.choices) and call this manually + # in the case that that is triggered second. + return + self.spectrum_viewer.set_plot_axes() + if y_unit != self.flux_unit.selected: + x_u = u.Unit(self.spectral_unit.selected) + y_unit = _valid_glue_display_unit(y_unit, self.spectrum_viewer, 'y') + y_u = u.Unit(y_unit) + choices = create_flux_equivalencies_list(y_u, x_u) + # ensure that original entry is in the list of choices + if not np.any([y_u == u.Unit(choice) for choice in choices]): + choices = [y_unit] + choices + self.flux_unit.choices = choices + self.flux_unit.selected = y_unit + + @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 + + @observe('flux_unit_selected') + def _on_flux_unit_changed(self, *args): + yunit = _valid_glue_display_unit(self.flux_unit.selected, self.spectrum_viewer, 'y') + if self.spectrum_viewer.state.y_display_unit != yunit: + self.spectrum_viewer.state.y_display_unit = yunit diff --git a/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.vue b/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.vue index fac0a6d9e8..f544f5733f 100644 --- a/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.vue +++ b/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.vue @@ -3,72 +3,31 @@ - - - - + > - - - - - - - - - + > - - - - Apply - - - diff --git a/jdaviz/configs/specviz/plugins/viewers.py b/jdaviz/configs/specviz/plugins/viewers.py index 65c105f4fe..a917fb4578 100644 --- a/jdaviz/configs/specviz/plugins/viewers.py +++ b/jdaviz/configs/specviz/plugins/viewers.py @@ -350,10 +350,21 @@ def add_data(self, data, color=None, alpha=None, **layer_state): result : bool `True` if successful, `False` otherwise. """ + # If this is the first loaded data, set things up for unit conversion. + if len(self.layers) == 0: + reset_plot_axes = True + else: + reset_plot_axes = False + # The base class handles the plotting of the main # trace representing the spectrum itself. result = super().add_data(data, color, alpha, **layer_state) + if reset_plot_axes: + self.state.x_display_unit = data.get_component(self.state.x_att.label).units + self.state.y_display_unit = data.get_component("flux").units + self.set_plot_axes() + self._plot_uncertainties() self._plot_mask() @@ -476,22 +487,20 @@ def _plot_uncertainties(self): self.figure.marks = list(self.figure.marks) + [error_line_mark] def set_plot_axes(self): - # Get data to be used for axes labels - data = self.data()[0] - # Set axes labels for the spectrum viewer - spectral_axis_unit_type = str(data.spectral_axis.unit.physical_type).title() - # flux_unit_type = data.flux.unit.physical_type.title() flux_unit_type = "Flux density" - - if data.spectral_axis.unit.is_equivalent(u.m): + x_unit = u.Unit(self.state.x_display_unit) + if x_unit.is_equivalent(u.m): spectral_axis_unit_type = "Wavelength" - elif data.spectral_axis.unit.is_equivalent(u.pixel): - spectral_axis_unit_type = "pixel" + elif x_unit.is_equivalent(u.Hz): + spectral_axis_unit_type = "Frequency" + elif x_unit.is_equivalent(u.pixel): + spectral_axis_unit_type = "Pixel" + else: + spectral_axis_unit_type = str(x_unit.physical_type).title() - label_0 = f"{spectral_axis_unit_type} [{data.spectral_axis.unit.to_string()}]" - self.figure.axes[0].label = label_0 - self.figure.axes[1].label = f"{flux_unit_type} [{data.flux.unit.to_string()}]" + self.figure.axes[0].label = f"{spectral_axis_unit_type} [{self.state.x_display_unit}]" + self.figure.axes[1].label = f"{flux_unit_type} [{self.state.y_display_unit}]" # Make it so y axis label is not covering tick numbers. self.figure.axes[1].label_offset = "-50" diff --git a/jdaviz/configs/specviz/tests/test_helper.py b/jdaviz/configs/specviz/tests/test_helper.py index 82543695ff..f3041ab734 100644 --- a/jdaviz/configs/specviz/tests/test_helper.py +++ b/jdaviz/configs/specviz/tests/test_helper.py @@ -10,7 +10,6 @@ from astropy.utils.data import download_file from jdaviz.app import Application -from jdaviz.configs.specviz.plugins.unit_conversion import unit_conversion as uc from jdaviz.core.marks import LineUncertainties from jdaviz import Specviz @@ -277,7 +276,7 @@ def test_get_spectral_regions_unit_conversion(specviz_helper, spectrum1d): # If the reference (visible) data changes via unit conversion, # check that the region's units convert too - specviz_helper.load_spectrum(spectrum1d) + specviz_helper.load_spectrum(spectrum1d) # Originally Angstrom # Also check coordinates info panel. # x=0 -> 6000 A, x=1 -> 6222.222 A @@ -293,27 +292,18 @@ def test_get_spectral_regions_unit_conversion(specviz_helper, spectrum1d): assert label_mouseover.as_text() == ('', '', '') assert label_mouseover.icon == '' - # Convert the wavelength axis to microns + # Convert the wavelength axis to micron new_spectral_axis = "micron" - conv_func = uc.UnitConversion.process_unit_conversion - converted_spectrum = conv_func(specviz_helper.app, spectrum=spectrum1d, - new_spectral_axis=new_spectral_axis) - - # Add this new data and clear the other, making the converted spectrum our reference - specviz_helper.app.add_data(converted_spectrum, "Converted Spectrum") - specviz_helper.app.add_data_to_viewer("spectrum-viewer", - "Converted Spectrum", - clear_other_data=True) + spec_viewer.state.x_display_unit = new_spectral_axis + spec_viewer.set_plot_axes() - specviz_helper.app.get_viewer("spectrum-viewer").apply_roi(XRangeROI(0.6, 0.7)) + spec_viewer.apply_roi(XRangeROI(0.6, 0.7)) - # TODO: Is this test still relevant with the upcoming glue unit conversion changes? # Retrieve the Subset - # subsets = specviz_helper.get_spectral_regions() - # reg = subsets.get('Subset 1') - # - # assert reg.lower.unit == u.Unit(new_spectral_axis) - # assert reg.upper.unit == u.Unit(new_spectral_axis) + subsets = specviz_helper.get_spectral_regions() + reg = subsets.get('Subset 1') + assert reg.lower.unit == u.micron + assert reg.upper.unit == u.micron # Coordinates info panel should show new unit label_mouseover._viewer_mouse_event(spec_viewer, @@ -321,7 +311,7 @@ def test_get_spectral_regions_unit_conversion(specviz_helper, spectrum1d): label_mouseover.as_text() == ('Cursor 6.10000e-01, 1.25000e+01', 'Wave 6.00000e-01 micron (0 pix)', 'Flux 1.24967e+01 Jy') - assert label_mouseover.icon == 'b' + assert label_mouseover.icon == 'a' label_mouseover._viewer_mouse_event(spec_viewer, {'event': 'mouseleave'}) assert label_mouseover.as_text() == ('', '', '') diff --git a/jdaviz/configs/specviz2d/tests/test_parsers.py b/jdaviz/configs/specviz2d/tests/test_parsers.py index 1e4fea74b8..78a4096068 100644 --- a/jdaviz/configs/specviz2d/tests/test_parsers.py +++ b/jdaviz/configs/specviz2d/tests/test_parsers.py @@ -81,7 +81,7 @@ def test_2d_parser_no_unit(specviz2d_helper, mos_spectrum2d): label_mouseover._viewer_mouse_event(viewer_1d, {'event': 'mousemove', 'domain': {'x': 6.5, 'y': 3}}) assert label_mouseover.as_text() == ('Cursor 6.50000e+00, 3.00000e+00', - 'Wave 6.00000e+00 pix', + 'Wave 6.00000e+00 pixel', 'Flux -3.59571e+00') assert label_mouseover.icon == 'b' diff --git a/jdaviz/core/freezable_state.py b/jdaviz/core/freezable_state.py index 0d1a14600f..6405bf035b 100644 --- a/jdaviz/core/freezable_state.py +++ b/jdaviz/core/freezable_state.py @@ -8,7 +8,7 @@ __all__ = ['FreezableState', 'FreezableProfileViewerState', 'FreezableBqplotImageViewerState'] -class FreezableState(): +class FreezableState: _frozen_state = [] def __setattr__(self, k, v): diff --git a/jdaviz/core/helpers.py b/jdaviz/core/helpers.py index fad12728e6..4998feebfa 100644 --- a/jdaviz/core/helpers.py +++ b/jdaviz/core/helpers.py @@ -407,7 +407,23 @@ def show_in_new_tab(self, title=None): # pragma: no cover DeprecationWarning) return self.show(loc="sidecar:tab-after", title=title) - def _get_data(self, data_label=None, cls=None, subset_to_apply=None, function=None): + def _get_data(self, data_label=None, cls=None, subset_to_apply=None, function=None, + use_display_units=False): + def _handle_display_units(data, use_display_units): + if use_display_units: + if isinstance(data, Spectrum1D): + spectral_unit = self.app._get_display_unit('spectral') + flux_unit = self.app._get_display_unit('flux') + # TODO: any other attributes (meta, wcs, etc)? + # TODO: implement uncertainty.to upstream + data = Spectrum1D(spectral_axis=data.spectral_axis.to(spectral_unit, + u.spectral()), + flux=data.flux.to(flux_unit), + uncertainty=data.uncertainty.__class__(data.uncertainty.quantity.to(flux_unit))) # noqa + else: # pragma: nocover + raise NotImplementedError(f"converting {data.__class__.__name__} to display units is not supported") # noqa + return data + list_of_valid_function_values = ('minimum', 'maximum', 'mean', 'median', 'sum') if function and function not in list_of_valid_function_values: @@ -454,7 +470,7 @@ def _get_data(self, data_label=None, cls=None, subset_to_apply=None, function=No else: data = data.get_object(cls=cls, **object_kwargs) - return data + return _handle_display_units(data, use_display_units) if not cls and subset_to_apply: raise AttributeError(f"A valid cls must be provided to" @@ -477,9 +493,9 @@ def _get_data(self, data_label=None, cls=None, subset_to_apply=None, function=No warnings.warn(f"Not able to get {data_label} returned with" f" subset {subsets.label} applied of type {cls}." f" Exception: {e}") - return data + return _handle_display_units(data, use_display_units) - def get_data(self, data_label=None, cls=None, subset_to_apply=None): + def get_data(self, data_label=None, cls=None, subset_to_apply=None, use_display_units=False): """ Returns data with name equal to data_label of type cls with subsets applied from subset_to_apply. @@ -492,6 +508,8 @@ def get_data(self, data_label=None, cls=None, subset_to_apply=None): The type that data will be returned as. subset_to_apply : str, optional Subset that is to be applied to data before it is returned. + use_display_units: bool, optional + Whether to convert to the display units defined in the plugin. Returns ------- @@ -499,7 +517,8 @@ def get_data(self, data_label=None, cls=None, subset_to_apply=None): Data is returned as type cls with subsets applied. """ - return self._get_data(data_label=data_label, cls=cls, subset_to_apply=subset_to_apply) + return self._get_data(data_label=data_label, cls=cls, subset_to_apply=subset_to_apply, + use_display_units=use_display_units) class ImageConfigHelper(ConfigHelper): diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index 722ddfb7ff..4092724239 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -28,7 +28,7 @@ __all__ = ['show_widget', 'TemplateMixin', 'PluginTemplateMixin', - 'BasePluginComponent', 'SelectPluginComponent', + 'BasePluginComponent', 'SelectPluginComponent', 'UnitSelectPluginComponent', 'PluginSubcomponent', 'SubsetSelect', 'SpatialSubsetSelectMixin', 'SpectralSubsetSelectMixin', 'DatasetSpectralSubsetValidMixin', @@ -101,7 +101,32 @@ def _subset_type(subset): return 'spectral' -class TemplateMixin(VuetifyTemplate, HubListener): +class ViewerPropertiesMixin: + # assumes that self.app is defined by the class + @cached_property + def spectrum_viewer(self): + if hasattr(self, '_default_spectrum_viewer_reference_name'): + viewer_reference = self._default_spectrum_viewer_reference_name + else: + viewer_reference = self.app._get_first_viewer_reference_name( + require_spectrum_viewer=True + ) + + return self.app.get_viewer(viewer_reference) + + @cached_property + def spectrum_2d_viewer(self): + if hasattr(self, '_default_spectrum_2d_viewer_reference_name'): + viewer_reference = self._default_spectrum_2d_viewer_reference_name + else: + viewer_reference = self.app._get_first_viewer_reference_name( + require_spectrum_2d_viewer=True + ) + + return self.app.get_viewer(viewer_reference) + + +class TemplateMixin(VuetifyTemplate, HubListener, ViewerPropertiesMixin): config = Unicode("").tag(sync=True) vdocs = Unicode("").tag(sync=True) popout_button = Any().tag(sync=True, **widget_serialization) @@ -293,7 +318,7 @@ def show(self, loc="inline", title=None): # pragma: no cover show_widget(self, loc=loc, title=title) -class BasePluginComponent(HubListener): +class BasePluginComponent(HubListener, ViewerPropertiesMixin): """ This base class handles attaching traitlets from the plugin itself to logic handled within the component, support for caching and clearing caches on properties, @@ -375,28 +400,6 @@ def _dict_from_viewer(viewer, viewer_item): for vid, viewer in self.app._viewer_store.items() if viewer.__class__.__name__ != 'MosvizTableViewer'] - @cached_property - def spectrum_viewer(self): - if hasattr(self, '_default_spectrum_viewer_reference_name'): - viewer_reference = self._default_spectrum_viewer_reference_name - else: - viewer_reference = self.app._get_first_viewer_reference_name( - require_spectrum_viewer=True - ) - - return self._plugin.app.get_viewer(viewer_reference) - - @cached_property - def spectrum_2d_viewer(self): - if hasattr(self, '_default_spectrum_2d_viewer_reference_name'): - viewer_reference = self._default_spectrum_2d_viewer_reference_name - else: - viewer_reference = self.app._get_first_viewer_reference_name( - require_spectrum_2d_viewer=True - ) - - return self._plugin.app.get_viewer(viewer_reference) - class SelectPluginComponent(BasePluginComponent, HasTraits): """ @@ -460,7 +463,7 @@ def __init__(self, *args, **kwargs): def __repr__(self): if hasattr(self, 'multiselect'): return f"" # noqa - return f"" + return f"" def __eq__(self, other): return self.selected == other @@ -473,6 +476,10 @@ def __hash__(self): def choices(self): return self.labels + @choices.setter + def choices(self, choices=[]): + self.items = [{'label': choice} for choice in choices] + @property def is_multiselect(self): if not hasattr(self, 'multiselect'): @@ -641,6 +648,48 @@ def _selected_changed(self, event): raise ValueError(f"{event['new']} not one of {self.labels}, reverting selection to {event['old']}") # noqa +class UnitSelectPluginComponent(SelectPluginComponent): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.add_observe('items', lambda _: self._clear_cache('unit_choices')) + self._addl_unit_strings = [] + + @cached_property + def unit_choices(self): + return [u.Unit(lbl) for lbl in self.labels] + + @property + def addl_unit_choices(self): + return [u.Unit(choice) for choice in self._addl_unit_strings] + + def _selected_changed(self, event): + self._clear_cache() + if event['new'] in self.labels + ['']: + # the string is an exact match, no converting necessary + return + elif not len(self.labels): + raise ValueError("no valid unit choices") + try: + new_u = u.Unit(event['new']) + except ValueError: + self.selected = event['old'] + raise ValueError(f"{event['new']} could not be converted to a valid unit, reverting selection to {event['old']}") # noqa + if new_u not in self.unit_choices: + if new_u in self.addl_unit_choices: + # append this one (as the valid string representation) to the list of user-choices + addl_index = self.addl_unit_choices.index(new_u) + self.choices = self.choices + [self._addl_unit_strings[addl_index]] + # clear the cache so we can find the appropriate entry in unit_choices + self._clear_cache('unit_choices') + else: + self.selected = event['old'] + raise ValueError(f"{event['new']} not one of {self.labels}, reverting selection to {event['old']}") # noqa + + # convert to default string representation from the valid choices + ind = self.unit_choices.index(new_u) + self.selected = self.labels[ind] + + class LayerSelect(SelectPluginComponent): """ Plugin select for layers, with support for single or multi-selection. @@ -1483,11 +1532,14 @@ def selected_obj(self): # from the data collection return self.get_object(cls=self.default_data_cls) - def selected_spectrum_for_spatial_subset(self, spatial_subset=SPATIAL_DEFAULT_TEXT): + def selected_spectrum_for_spatial_subset(self, + spatial_subset=SPATIAL_DEFAULT_TEXT, + use_display_units=True): if spatial_subset == SPATIAL_DEFAULT_TEXT: spatial_subset = None return self.plugin._specviz_helper.get_data(data_label=self.selected, - subset_to_apply=spatial_subset) + subset_to_apply=spatial_subset, + use_display_units=use_display_units) def _is_valid_item(self, data): def not_from_plugin(data): diff --git a/jdaviz/core/tests/test_tools.py b/jdaviz/core/tests/test_tools.py index 2785d0bce4..aace5d537c 100644 --- a/jdaviz/core/tests/test_tools.py +++ b/jdaviz/core/tests/test_tools.py @@ -1,24 +1,19 @@ -import numpy as np -from glue.core import Data +from numpy.testing import assert_allclose -def test_boxzoom(cubeviz_helper, spectral_cube_wcs): - data = Data(flux=np.ones((128, 128, 256)), label='Test Flux', coords=spectral_cube_wcs) - cubeviz_helper.app.data_collection.append(data) - - cubeviz_helper.app.add_data_to_viewer('spectrum-viewer', 'Test Flux') - cubeviz_helper.app.add_data_to_viewer('flux-viewer', 'Test Flux') +def test_boxzoom(cubeviz_helper, image_cube_hdu_obj_microns): + cubeviz_helper.load_data(image_cube_hdu_obj_microns, data_label="Test Flux") flux_viewer = cubeviz_helper.app.get_viewer('flux-viewer') assert flux_viewer.state.y_min == -0.5 - assert flux_viewer.state.y_max == 127.5 + assert flux_viewer.state.y_max == 8.5 assert flux_viewer.state.x_min == -0.5 - assert flux_viewer.state.x_max == 127.5 + assert flux_viewer.state.x_max == 9.5 t = flux_viewer.toolbar.tools['jdaviz:boxzoom'] t.activate() - t.interact.selected_x = [10, 20] - t.interact.selected_y = [20, 60] + t.interact.selected_x = [1, 4] + t.interact.selected_y = [2, 6] - assert t.get_x_axis_with_aspect_ratio() == (-5., 35.) + assert_allclose(t.get_x_axis_with_aspect_ratio(), [0.277778, 4.722222], rtol=1e-6) diff --git a/jdaviz/core/user_api.py b/jdaviz/core/user_api.py index 9ef69ed4f6..4a31da9197 100644 --- a/jdaviz/core/user_api.py +++ b/jdaviz/core/user_api.py @@ -1,3 +1,5 @@ +import astropy.units as u + __all__ = ['UserApiWrapper', 'PluginUserApi'] @@ -38,12 +40,15 @@ def __setattr__(self, attr, value): exp_obj = getattr(self._obj, attr) from jdaviz.core.template_mixin import (SelectPluginComponent, + UnitSelectPluginComponent, PlotOptionsSyncState, AddResults, AutoTextField) if isinstance(exp_obj, SelectPluginComponent): # this allows setting the selection directly without needing to access the underlying # .selected traitlet + if isinstance(exp_obj, UnitSelectPluginComponent) and isinstance(value, u.Unit): + value = value.to_string() exp_obj.selected = value return elif isinstance(exp_obj, AddResults): diff --git a/jdaviz/core/validunits.py b/jdaviz/core/validunits.py index 01a939ead0..372a5f2904 100644 --- a/jdaviz/core/validunits.py +++ b/jdaviz/core/validunits.py @@ -1,5 +1,4 @@ -import astropy.units as u -import numpy as np +from astropy import units as u __all__ = ['units_to_strings', 'create_spectral_equivalencies_list', 'create_flux_equivalencies_list'] @@ -19,68 +18,61 @@ def units_to_strings(unit_list): result : list A list of the units with their best (i.e., most readable) string version. """ - return [u.Unit(unit).name - if u.Unit(unit) == u.Unit("Angstrom") - else u.Unit(unit).long_names[0] if ( - hasattr(u.Unit(unit), "long_names") and len(u.Unit(unit).long_names) > 0) - else u.Unit(unit).to_string() - for unit in unit_list] + return [u.Unit(unit).to_string() for unit in unit_list] -def create_spectral_equivalencies_list(spectrum, +def create_spectral_equivalencies_list(spectral_axis_unit, exclude=[u.jupiterRad, u.earthRad, u.solRad, - u.lyr, u.AU, u.pc]): - """Get all possible conversions from current spectral_axis_unit. - """ - if spectrum.spectral_axis.unit == u.pix: + u.lyr, u.AU, u.pc, u.Bq, u.micron, u.lsec]): + """Get all possible conversions from current spectral_axis_unit.""" + if spectral_axis_unit in (u.pix, u.dimensionless_unscaled): return [] # Get unit equivalencies. - curr_spectral_axis_unit_equivalencies = u.Unit( - spectrum.spectral_axis.unit).find_equivalent_units( - equivalencies=u.spectral()) + try: + curr_spectral_axis_unit_equivalencies = spectral_axis_unit.find_equivalent_units( + equivalencies=u.spectral()) + except u.core.UnitConversionError: + return [] # Get local units. - locally_defined_spectral_axis_units = ['angstrom', 'nanometer', - 'micron', 'hertz', 'erg'] + locally_defined_spectral_axis_units = ['Angstrom', 'nm', + 'um', 'Hz', 'erg'] local_units = [u.Unit(unit) for unit in locally_defined_spectral_axis_units] # Remove overlap units. curr_spectral_axis_unit_equivalencies = list(set(curr_spectral_axis_unit_equivalencies) - - set(local_units+exclude)) + - set(local_units + exclude)) # Convert equivalencies into readable versions of the units and sorted alphabetically. spectral_axis_unit_equivalencies_titles = sorted(units_to_strings( curr_spectral_axis_unit_equivalencies)) # Concatenate both lists with the local units coming first. - spectral_axis_unit_equivalencies_titles = sorted(units_to_strings( - local_units)) + spectral_axis_unit_equivalencies_titles - - return spectral_axis_unit_equivalencies_titles + return sorted(units_to_strings(local_units)) + spectral_axis_unit_equivalencies_titles -def create_flux_equivalencies_list(spectrum): - """Get all possible conversions for flux from current flux units. - """ - if ((spectrum.flux.unit == u.count) or (spectrum.spectral_axis.unit == u.pix)): +def create_flux_equivalencies_list(flux_unit, spectral_axis_unit): + """Get all possible conversions for flux from current flux units.""" + if ((flux_unit in (u.count, (u.MJy / u.sr), u.dimensionless_unscaled)) + or (spectral_axis_unit in (u.pix, u.dimensionless_unscaled))): return [] - # Get unit equivalencies. - curr_flux_unit_equivalencies = u.Unit( - spectrum.flux.unit).find_equivalent_units( - equivalencies=u.spectral_density(np.sum(spectrum.spectral_axis)), + # Get unit equivalencies. Value passed into u.spectral_density() is irrelevant. + try: + curr_flux_unit_equivalencies = flux_unit.find_equivalent_units( + equivalencies=u.spectral_density(1 * spectral_axis_unit), include_prefix_units=False) + except u.core.UnitConversionError: + return [] # Get local units. locally_defined_flux_units = ['Jy', 'mJy', 'uJy', 'W / (m2 Hz)', 'eV / (s m2 Hz)', 'erg / (s cm2)', - 'erg / (s cm2 um)', 'erg / (s cm2 Angstrom)', 'erg / (s cm2 Hz)', - 'ph / (s cm2 um)', 'ph / (s cm2 Angstrom)', 'ph / (s cm2 Hz)'] local_units = [u.Unit(unit) for unit in locally_defined_flux_units] @@ -93,7 +85,4 @@ def create_flux_equivalencies_list(spectrum): flux_unit_equivalencies_titles = sorted(units_to_strings(curr_flux_unit_equivalencies)) # Concatenate both lists with the local units coming first. - flux_unit_equivalencies_titles = (sorted(units_to_strings(local_units)) + - flux_unit_equivalencies_titles) - - return flux_unit_equivalencies_titles + return sorted(units_to_strings(local_units)) + flux_unit_equivalencies_titles diff --git a/jdaviz/tests/test_subsets.py b/jdaviz/tests/test_subsets.py index 40cda42b08..782cc4d3cd 100644 --- a/jdaviz/tests/test_subsets.py +++ b/jdaviz/tests/test_subsets.py @@ -4,12 +4,10 @@ from astropy.tests.helper import assert_quantity_allclose from glue.core import Data from glue.core.roi import CircularROI, EllipticalROI, RectangularROI, XRangeROI - from glue.core.edit_subset_mode import AndMode, AndNotMode, OrMode from regions import PixCoord, CirclePixelRegion, RectanglePixelRegion, EllipsePixelRegion - from numpy.testing import assert_allclose -from specutils import SpectralRegion +from specutils import SpectralRegion, Spectrum1D from jdaviz.core.marks import ShadowSpatialSpectral @@ -135,12 +133,10 @@ def test_region_from_subset_3d(cubeviz_helper): def test_region_from_subset_profile(cubeviz_helper, spectral_cube_wcs): - data = Data(flux=np.ones((128, 128, 256)), label='Test 1D Flux', coords=spectral_cube_wcs) + data = Spectrum1D(flux=np.ones((128, 128, 256)) * u.nJy, wcs=spectral_cube_wcs) subset_plugin = cubeviz_helper.app.get_tray_item_from_name('g-subset-plugin') - cubeviz_helper.app.data_collection.append(data) - - cubeviz_helper.app.add_data_to_viewer('spectrum-viewer', 'Test 1D Flux') + cubeviz_helper.load_data(data, data_label='Test 1D Flux') cubeviz_helper.app.get_viewer("spectrum-viewer").apply_roi(XRangeROI(5, 15.5)) @@ -184,11 +180,8 @@ def test_region_from_subset_profile(cubeviz_helper, spectral_cube_wcs): def test_region_spectral_spatial(cubeviz_helper, spectral_cube_wcs): - data = Data(flux=np.ones((128, 128, 256)), label='Test Flux', coords=spectral_cube_wcs) - cubeviz_helper.app.data_collection.append(data) - - cubeviz_helper.app.add_data_to_viewer('spectrum-viewer', 'Test Flux') - cubeviz_helper.app.add_data_to_viewer('flux-viewer', 'Test Flux') + data = Spectrum1D(flux=np.ones((128, 128, 256)) * u.nJy, wcs=spectral_cube_wcs) + cubeviz_helper.load_data(data, data_label="Test Flux") # use gaussian smooth plugin as a regression test for # https://github.com/spacetelescope/jdaviz/issues/1853 @@ -236,11 +229,8 @@ def test_region_spectral_spatial(cubeviz_helper, spectral_cube_wcs): def test_disjoint_spatial_subset(cubeviz_helper, spectral_cube_wcs): - data = Data(flux=np.ones((128, 128, 256)), label='Test Flux', coords=spectral_cube_wcs) - cubeviz_helper.app.data_collection.append(data) - - cubeviz_helper.app.add_data_to_viewer('spectrum-viewer', 'Test Flux') - cubeviz_helper.app.add_data_to_viewer('flux-viewer', 'Test Flux') + data = Spectrum1D(flux=np.ones((128, 128, 256)) * u.nJy, wcs=spectral_cube_wcs) + cubeviz_helper.load_data(data, data_label="Test Flux") flux_viewer = cubeviz_helper.app.get_viewer("flux-viewer") flux_viewer.apply_roi(CircularROI(xc=3, yc=4, radius=1)) @@ -260,11 +250,8 @@ def test_disjoint_spatial_subset(cubeviz_helper, spectral_cube_wcs): def test_disjoint_spectral_subset(cubeviz_helper, spectral_cube_wcs): subset_plugin = cubeviz_helper.app.get_tray_item_from_name('g-subset-plugin') - data = Data(flux=np.ones((128, 128, 256)), label='Test Flux', coords=spectral_cube_wcs) - cubeviz_helper.app.data_collection.append(data) - - cubeviz_helper.app.add_data_to_viewer('spectrum-viewer', 'Test Flux') - cubeviz_helper.app.add_data_to_viewer('flux-viewer', 'Test Flux') + data = Spectrum1D(flux=np.ones((128, 128, 256)) * u.nJy, wcs=spectral_cube_wcs) + cubeviz_helper.load_data(data, data_label="Test Flux") spec_viewer = cubeviz_helper.app.get_viewer("spectrum-viewer") spec_viewer.apply_roi(XRangeROI(5, 15.5)) diff --git a/jdaviz/utils.py b/jdaviz/utils.py index 3d042d99f0..59ad0cf814 100644 --- a/jdaviz/utils.py +++ b/jdaviz/utils.py @@ -4,7 +4,6 @@ from collections import deque import matplotlib.pyplot as plt - from astropy.io import fits from ipyvue import watch from glue.config import settings diff --git a/notebooks/concepts/specviz_glue_unit_conversion.ipynb b/notebooks/concepts/specviz_glue_unit_conversion.ipynb new file mode 100644 index 0000000000..614d7c46b1 --- /dev/null +++ b/notebooks/concepts/specviz_glue_unit_conversion.ipynb @@ -0,0 +1,293 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "57c41ae5", + "metadata": {}, + "source": [ + "This is a concept notebook to investigate Glue unit conversion behavior integrated into Jdaviz spectrum viewer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2b3bbfb1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from astropy import units as u\n", + "from specutils import Spectrum1D\n", + "\n", + "from jdaviz import Specviz" + ] + }, + { + "cell_type": "markdown", + "id": "0d253733", + "metadata": {}, + "source": [ + "First spectrum." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cba69bb7", + "metadata": {}, + "outputs": [], + "source": [ + "wave1 = np.linspace(2, 5, 10) * u.um\n", + "flux1 = [1, 2, 3, 4, 5, 5, 4, 3, 2, 1] * u.Jy\n", + "spec1 = Spectrum1D(flux=flux1, spectral_axis=wave1)\n", + "\n", + "print(wave1)\n", + "print(flux1)" + ] + }, + { + "cell_type": "markdown", + "id": "1e0e7dc5", + "metadata": {}, + "source": [ + "Second spectrum in different units and with slight offsets in spectral axis." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26bc794d", + "metadata": {}, + "outputs": [], + "source": [ + "wave2 = (wave1 + (0.1 * u.um)).to(u.GHz, u.spectral())\n", + "flux2 = flux1.to(u.mJy)\n", + "spec2 = Spectrum1D(flux=flux2, spectral_axis=wave2)\n", + "\n", + "print(wave2)\n", + "print(wave2.to(u.um, u.spectral()))\n", + "print(flux2)" + ] + }, + { + "cell_type": "markdown", + "id": "2bb4f4ea", + "metadata": {}, + "source": [ + "Fire up Specviz." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "74692a02", + "metadata": {}, + "outputs": [], + "source": [ + "specviz = Specviz()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "efdba701", + "metadata": {}, + "outputs": [], + "source": [ + "specviz.show()" + ] + }, + { + "cell_type": "markdown", + "id": "642fbae1", + "metadata": {}, + "source": [ + "Load the data into Specviz. Desired behavior:\n", + "\n", + "1. \"Jy_um\" would load with Jy in Y-axis and um in X-axis.\n", + "2. \"mJy_GHz\" would load with data automatically converted to Jy and um in the plot. You would see the same shape but slightly offset in X-axis, just slightly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5ee09160", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "specviz.load_data(spec1, data_label=\"Jy_um\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9655292b", + "metadata": {}, + "outputs": [], + "source": [ + "specviz.load_data(spec2, data_label=\"mJy_GHz\")" + ] + }, + { + "cell_type": "markdown", + "id": "e6012551", + "metadata": {}, + "source": [ + "Change the spectral axis display unit to GHz." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6a4611b3", + "metadata": {}, + "outputs": [], + "source": [ + "viewer = specviz.app.get_viewer(\"spectrum-viewer\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ac319e71", + "metadata": {}, + "outputs": [], + "source": [ + "viewer.state.x_display_unit = \"GHz\"" + ] + }, + { + "cell_type": "markdown", + "id": "b2e84da2", + "metadata": {}, + "source": [ + "Change the flux axis display unit to FLAM." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4892cb38", + "metadata": {}, + "outputs": [], + "source": [ + "FLAM = u.erg / (u.s * u.cm * u.cm * u.AA)\n", + "\n", + "# If astropy can do it, Jdaviz should too.\n", + "spec1.flux.to(FLAM, u.spectral_density(spec1.spectral_axis))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3be87c54", + "metadata": {}, + "outputs": [], + "source": [ + "# this MIGHT fail depending on the version of astropy (since glue harcodes the expected string formatting \n", + "# for units, whereas astropy recently changed the default order of units)\n", + "try:\n", + " viewer.state.y_display_unit = FLAM.to_string()\n", + "except ValueError as e:\n", + " print(\"setting y_display_unit failed: \", repr(e))" + ] + }, + { + "cell_type": "markdown", + "id": "116e5806", + "metadata": {}, + "source": [ + "The plugin select component, however, is unit-aware and will handle mapping to the string formatting expected by glue" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "da5df8d4", + "metadata": {}, + "outputs": [], + "source": [ + "uc = specviz.plugins['Unit Conversion']\n", + "uc.flux_unit.choices" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b74ae8aa", + "metadata": {}, + "outputs": [], + "source": [ + "uc.flux_unit = FLAM.to_string()" + ] + }, + { + "cell_type": "markdown", + "id": "8be218b4", + "metadata": {}, + "source": [ + "Change the spectral axis again, this time to Angstrom via the plugin API." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8f9073f7", + "metadata": {}, + "outputs": [], + "source": [ + "uc.spectral_unit = 'Angstrom'" + ] + }, + { + "cell_type": "markdown", + "id": "fa487529", + "metadata": {}, + "source": [ + "Change everything back to original units." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8c110686", + "metadata": {}, + "outputs": [], + "source": [ + "viewer.state.x_display_unit = \"micron\"\n", + "viewer.state.y_display_unit = \"Jy\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "87642b18", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.15" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/concepts/specviz_unit_conversion.ipynb b/notebooks/concepts/specviz_unit_conversion.ipynb deleted file mode 100644 index 1402e6c56c..0000000000 --- a/notebooks/concepts/specviz_unit_conversion.ipynb +++ /dev/null @@ -1,61 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from jdaviz.configs.specviz.helper import Specviz\n", - "import specutils\n", - "import astropy.units as u\n", - "import numpy as np" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "spec_url = 'https://dr14.sdss.org/optical/spectrum/view/data/format=fits/spec=lite?plateid=1323&mjd=52797&fiberid=12'\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "scrolled": false - }, - "outputs": [], - "source": [ - "specviz = Specviz()\n", - "spec = specutils.Spectrum1D.read(spec_url)\n", - "specviz.load_spectrum(spec)\n", - "\n", - "specviz.show()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.8.5" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -}