diff --git a/CHANGES.rst b/CHANGES.rst index b5f7982e22..6dd84c2fe3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,7 @@ New Features ------------ - Added flux/surface brightness translation and surface brightness - unit conversion in Cubeviz and Specviz. [#2781, #3088] + unit conversion in Cubeviz and Specviz. [#2781, #2940, #3088] - Plugin tray is now open by default. [#2892] diff --git a/jdaviz/app.py b/jdaviz/app.py index 3c846eb7be..15d721ee4f 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -76,20 +76,17 @@ def equivalent_units(self, data, cid, units): 'Jy', 'mJy', 'uJy', 'MJy', '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 / (s cm2 Angstrom)', 'erg / (s cm2 Hz)', 'erg / (Hz s cm2)', - 'ph / (s cm2 Angstrom)', 'ph / (s cm2 Angstrom)', + 'ph / (Angstrom s cm2)', 'ph / (Hz s cm2)', 'ph / (Hz s cm2)', 'bol', 'AB', 'ST' ] + [ 'Jy / sr', 'mJy / sr', 'uJy / sr', 'MJy / sr', 'W / (Hz sr m2)', - 'eV / (s m2 Hz sr)', - 'erg / (s cm2 sr)', - 'erg / (s cm2 Angstrom sr)', 'erg / (s cm2 Hz sr)', - 'ph / (s cm2 Angstrom sr)', 'ph / (s cm2 Hz sr)', - 'bol / sr', 'AB / sr', 'ST / sr' + 'eV / (Hz s sr m2)', + 'erg / (s sr cm2)', + 'AB / sr' ]) else: # spectral axis # prefer Hz over Bq and um over micron diff --git a/jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py b/jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py index f2af63cd83..b0089f99d6 100644 --- a/jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py +++ b/jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py @@ -22,8 +22,6 @@ with_spinner) from jdaviz.core.validunits import check_if_unit_is_per_solid_angle from jdaviz.core.user_api import PluginUserApi -from jdaviz.utils import flux_conversion - __all__ = ['MomentMap'] @@ -358,18 +356,7 @@ def calculate_moment(self, add_data=True): moment_new_unit = flux_or_sb_display_unit else: moment_new_unit = flux_or_sb_display_unit * self.spectrum_viewer.state.x_display_unit # noqa: E501 - - # Create a temporary Spectrum1D object with ability to convert from surface brightness - # to flux - temp_spec = Spectrum1D(flux=self.moment) - flux_values = np.sum(np.ones_like(temp_spec.flux.value), axis=(0, 1)) - pix_scale = self.dataset.selected_dc_item.meta.get('PIXAR_SR', 1.0) - pix_scale_factor = (flux_values * pix_scale) - temp_spec.meta['_pixel_scale_factor'] = pix_scale_factor - converted_spec = flux_conversion(temp_spec, self.moment.value, - self.moment.unit, - moment_new_unit) * moment_new_unit - self.moment = converted_spec + self.moment = self.moment.to(moment_new_unit) # Reattach the WCS so we can load the result self.moment = CCDData(self.moment, wcs=data_wcs) diff --git a/jdaviz/configs/cubeviz/plugins/moment_maps/tests/test_moment_maps.py b/jdaviz/configs/cubeviz/plugins/moment_maps/tests/test_moment_maps.py index 545176e61e..c2efc22c4b 100644 --- a/jdaviz/configs/cubeviz/plugins/moment_maps/tests/test_moment_maps.py +++ b/jdaviz/configs/cubeviz/plugins/moment_maps/tests/test_moment_maps.py @@ -334,7 +334,7 @@ def test_correct_output_flux_or_sb_units(cubeviz_helper, spectrum1d_cube_custom_ # now change surface brightness units in the unit conversion plugin - uc.flux_or_sb_unit = 'Jy / sr' + uc.sb_unit = 'Jy / sr' # and make sure this change is propogated output_unit_moment_0 = mm.output_unit_items[0] @@ -344,21 +344,3 @@ def test_correct_output_flux_or_sb_units(cubeviz_helper, spectrum1d_cube_custom_ # and that calculated moment has the correct units mm.calculate_moment() assert mm.moment.unit == moment_unit - - uc.flux_or_sb.selected = 'Flux' - mm._set_data_units() - - # and make sure this change is propogated - output_unit_moment_0 = mm.output_unit_items[0] - assert output_unit_moment_0['label'] == 'Flux' - assert output_unit_moment_0['unit_str'] == 'Jy' - - # TODO: Failing because of dev version of upstream dependency, figure - # out which one - # assert mm.calculate_moment() - - # TODO: This test should pass once continuum subtraction works with - # flux to surface brightness conversion - # mm.continuum.selected = 'Surrounding' - # - # assert mm.calculate_moment() diff --git a/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py b/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py index 319333b195..3ae47a7767 100644 --- a/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py +++ b/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py @@ -516,6 +516,15 @@ def extract(self, return_bg=False, add_data=True, **kwargs): pix_scale_factor = self.aperture_area_along_spectral * self.spectral_cube.meta.get('PIXAR_SR', 1.0) # noqa spec.meta['_pixel_scale_factor'] = pix_scale_factor + # inform the user if scale factor keyword not in metadata + if 'PIXAR_SR' not in self.spectral_cube.meta: + snackbar_message = SnackbarMessage( + ("PIXAR_SR FITS header keyword not found when parsing spectral cube. " + "Flux/Surface Brightness will use default PIXAR_SR value of 1 sr/pix^2."), + color="warning", + sender=self) + self.hub.broadcast(snackbar_message) + # stuff for exporting to file self.extracted_spec = spec self.extracted_spec_available = True diff --git a/jdaviz/configs/imviz/plugins/coords_info/coords_info.py b/jdaviz/configs/imviz/plugins/coords_info/coords_info.py index 169e0c058c..326c80f5ce 100644 --- a/jdaviz/configs/imviz/plugins/coords_info/coords_info.py +++ b/jdaviz/configs/imviz/plugins/coords_info/coords_info.py @@ -17,6 +17,7 @@ from jdaviz.core.marks import PluginScatter, PluginLine from jdaviz.core.registries import tool_registry from jdaviz.core.template_mixin import TemplateMixin, DatasetSelectMixin +from jdaviz.utils import _eqv_pixar_sr __all__ = ['CoordsInfo'] @@ -561,10 +562,7 @@ def _copy_axes_to_spectral(): # temporarily here, may be removed after upstream units handling # or will be generalized for any sb <-> flux if '_pixel_scale_factor' in sp.meta: - eqv = [(u.MJy / u.sr, - u.MJy, - lambda x: (x * sp.meta['_pixel_scale_factor']), - lambda x: x)] + eqv = u.spectral_density(sp.spectral_axis) + _eqv_pixar_sr(sp.meta['_pixel_scale_factor']) # noqa disp_flux = sp.flux.to_value(viewer.state.y_display_unit, eqv) else: disp_flux = sp.flux.to_value(viewer.state.y_display_unit, diff --git a/jdaviz/configs/specviz/plugins/line_analysis/line_analysis.py b/jdaviz/configs/specviz/plugins/line_analysis/line_analysis.py index 5fea5bb25e..6704a388a1 100644 --- a/jdaviz/configs/specviz/plugins/line_analysis/line_analysis.py +++ b/jdaviz/configs/specviz/plugins/line_analysis/line_analysis.py @@ -288,6 +288,11 @@ def _uncertainty(result): # don't need these if statements if function == "Line Flux": flux_unit = spec_subtracted.flux.unit + if flux_unit == u.dimensionless_unscaled: + add_flux = True + flux_unit = u.Unit(self.spectrum_viewer.state.y_display_unit) + else: + add_flux = False # If the flux unit is equivalent to Jy, or Jy per spaxel for Cubeviz, # enforce integration in frequency space if (flux_unit.is_equivalent(u.Jy) or @@ -300,7 +305,10 @@ def _uncertainty(result): uncertainty=spec_subtracted.uncertainty) try: - raw_result = analysis.line_flux(freq_spec) + if add_flux: + raw_result = analysis.line_flux(freq_spec) * flux_unit + else: + raw_result = analysis.line_flux(freq_spec) except ValueError as e: # can happen if interpolation out-of-bounds or any error from specutils # let's avoid the whole app crashing and instead expose the error to the @@ -332,7 +340,10 @@ def _uncertainty(result): flux=spec_subtracted.flux, uncertainty=spec_subtracted.uncertainty) try: - raw_result = analysis.line_flux(wave_spec) + if add_flux: + raw_result = analysis.line_flux(wave_spec) * flux_unit + else: + raw_result = raw_result = analysis.line_flux(wave_spec) except ValueError as e: # can happen if interpolation out-of-bounds or any error from specutils # let's avoid the whole app crashing and instead expose the error to the 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 4d00c26409..b1b5380f2b 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 @@ -89,14 +89,28 @@ def test_conv_wave_flux(specviz_helper, spectrum1d, uncert): assert u.Unit(viewer.state.y_display_unit) == u.Unit(new_flux) -def test_conv_no_data(specviz_helper): +def test_conv_no_data(specviz_helper, spectrum1d): """plugin unit selections won't have valid choices yet, preventing attempting to set display units.""" plg = specviz_helper.plugins["Unit Conversion"] + # spectrum not load is in Flux units, sb_unit and flux_unit + # should be enabled, flux_or_sb should not be + assert hasattr(plg, 'sb_unit') + assert hasattr(plg, 'flux_unit') + assert not hasattr(plg, 'flux_or_sb') with pytest.raises(ValueError, match="no valid unit choices"): plg.spectral_unit = "micron" assert len(specviz_helper.app.data_collection) == 0 + specviz_helper.load_data(spectrum1d, data_label="Test 1D Spectrum") + plg = specviz_helper.plugins["Unit Conversion"] + + # spectrum loaded in Flux units, make sure sb_units don't + # display in the API and exposed translation isn't possible + assert hasattr(plg, 'flux_unit') + assert not hasattr(plg, 'sb_unit') + assert not hasattr(plg, 'flux_or_sb') + @pytest.mark.skipif(ASTROPY_LT_5_3, reason='this feature relies on astropy v5.3+') def test_non_stddev_uncertainty(specviz_helper): @@ -140,40 +154,21 @@ def test_unit_translation(cubeviz_helper): cubeviz_helper.load_regions(CirclePixelRegion(center, radius=2.5)) uc_plg = cubeviz_helper.plugins['Unit Conversion'] - # we can get rid of this after all spectra pass through - # spectral extraction plugin - extract_plg = cubeviz_helper.plugins['Spectral Extraction'] - - extract_plg.aperture = extract_plg.aperture.choices[-1] - extract_plg.aperture_method.selected = 'Exact' - extract_plg.wavelength_dependent = True - extract_plg.function = 'Sum' - # set so pixel scale factor != 1 - extract_plg.reference_spectral_value = 0.000001 - - # all spectra will pass through spectral extraction, - # this will store a scale factor for use in translations. - collapsed_spec = extract_plg.extract() # test that the scale factor was set - assert np.all(collapsed_spec.meta['_pixel_scale_factor'] != 1) + assert np.all(cubeviz_helper.app.data_collection['Spectrum (sum)'].meta['_pixel_scale_factor'] != 1) # noqa # When the dropdown is displayed, this ensures the loaded # data collection item units will be used for translations. - uc_plg._obj.show_translator = True assert uc_plg._obj.flux_or_sb_selected == 'Flux' # to have access to display units viewer_1d = cubeviz_helper.app.get_viewer( cubeviz_helper._default_spectrum_viewer_reference_name) - # for testing _set_flux_or_sb() - uc_plg._obj.show_translator = False - # change global y-units from Flux -> Surface Brightness uc_plg._obj.flux_or_sb_selected = 'Surface Brightness' - uc_plg._obj.show_translator = True assert uc_plg._obj.flux_or_sb_selected == 'Surface Brightness' y_display_unit = u.Unit(viewer_1d.state.y_display_unit) @@ -196,42 +191,35 @@ def test_sb_unit_conversion(cubeviz_helper): uc_plg = cubeviz_helper.plugins['Unit Conversion'] uc_plg.open_in_tray() + # ensure that per solid angle cube defaults to Flux spectrum + assert uc_plg.flux_or_sb == 'Flux' + # flux choices is populated with flux units + assert uc_plg.flux_unit.choices + # to have access to display units viewer_1d = cubeviz_helper.app.get_viewer( cubeviz_helper._default_spectrum_viewer_reference_name) - uc_plg._obj.show_translator = True uc_plg.flux_or_sb.selected = 'Surface Brightness' # Surface Brightness conversion - uc_plg.flux_or_sb_unit = 'Jy / sr' + uc_plg.sb_unit = 'Jy / sr' y_display_unit = u.Unit(viewer_1d.state.y_display_unit) assert y_display_unit == u.Jy / u.sr # Try a second conversion - uc_plg.flux_or_sb_unit = 'W / Hz sr m2' + uc_plg.sb_unit = 'W / Hz sr m2' y_display_unit = u.Unit(viewer_1d.state.y_display_unit) assert y_display_unit == u.Unit("W / (Hz sr m2)") # really a translation test, test_unit_translation loads a Flux # cube, this test load a Surface Brightness Cube, this ensures # two-way translation - uc_plg.flux_or_sb_unit = 'MJy / sr' + uc_plg.sb_unit = 'MJy / sr' y_display_unit = u.Unit(viewer_1d.state.y_display_unit) - # we can get rid of this after all spectra pass through - # spectral extraction plugin - extract_plg = cubeviz_helper.plugins['Spectral Extraction'] - extract_plg.aperture = extract_plg.aperture.choices[-1] - extract_plg.aperture_method.selected = 'Exact' - extract_plg.wavelength_dependent = True - extract_plg.function = 'Sum' - extract_plg.reference_spectral_value = 0.000001 - extract_plg.extract() - - uc_plg._obj.show_translator = True uc_plg._obj.flux_or_sb_selected = 'Flux' - uc_plg.flux_or_sb_unit = 'MJy' + uc_plg.flux_unit = 'MJy' y_display_unit = u.Unit(viewer_1d.state.y_display_unit) assert y_display_unit == u.MJy diff --git a/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.py b/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.py index 18266de424..39655cdf20 100644 --- a/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.py +++ b/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.py @@ -7,7 +7,10 @@ from jdaviz.core.template_mixin import (PluginTemplateMixin, UnitSelectPluginComponent, SelectPluginComponent, PluginUserApi) from jdaviz.core.validunits import (create_spectral_equivalencies_list, - create_flux_equivalencies_list) + create_flux_equivalencies_list, + create_sb_equivalencies_list, + check_if_unit_is_per_solid_angle, + units_to_strings) __all__ = ['UnitConversion'] @@ -43,11 +46,12 @@ class UnitConversion(PluginTemplateMixin): * :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_unit`` (:class:`~jdaviz.core.template_mixin.UnitSelectPluginComponent`): - Global unit to use for all flux/surface brightness (depending on flux_or_sb selection) axes. * ``flux_or_sb`` (:class:`~jdaviz.core.template_mixin.SelectPluginComponent`): - Y-axis physical type selection. Currently only accessible in Cubeviz (pixel scale factor - added in Cubeviz Spectral Extraction, and is used for this translation). + Select the y-axis physical type for the spectrum-viewer. + * ``flux_unit`` (:class:`~jdaviz.core.template_mixin.UnitSelectPluginComponent`): + Global display unit for flux axis. + * ``sb_unit`` (:class:`~jdaviz.core.template_mixin.UnitSelectPluginComponent`): + Global display unit for surface brightness axis. """ template_file = __file__, "unit_conversion.vue" @@ -57,10 +61,21 @@ class UnitConversion(PluginTemplateMixin): flux_unit_items = List().tag(sync=True) flux_unit_selected = Unicode().tag(sync=True) - show_translator = Bool(False).tag(sync=True) + sb_unit_items = List().tag(sync=True) + sb_unit_selected = Unicode().tag(sync=True) + flux_or_sb_items = List().tag(sync=True) flux_or_sb_selected = Unicode().tag(sync=True) + # in certain configs, a pixel scale factor will not be in the FITS header + # we need to disable translation in the API and UI variables/functions. + flux_or_sb_config_disabler = Unicode().tag(sync=True) + can_translate = Bool(True).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) @@ -76,23 +91,38 @@ def __init__(self, *args, **kwargs): # 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_or_sb_unit = UnitSelectPluginComponent(self, - items='flux_unit_items', - selected='flux_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.sb_unit = UnitSelectPluginComponent(self, + items='sb_unit_items', + selected='sb_unit_selected') + @property def user_api(self): - return PluginUserApi(self, expose=('spectral_unit', 'flux_or_sb', 'flux_or_sb_unit')) + if self.app.config == 'cubeviz': + expose = ('spectral_unit', 'flux_or_sb', 'flux_unit', 'sb_unit') + elif self.app.config == 'specviz' and not self.flux_or_sb_config_disabler: + expose = ('spectral_unit', 'flux_unit', 'sb_unit') + elif self.flux_or_sb_config_disabler == 'Flux': + expose = ('spectral_unit', 'sb_unit') + else: # self.flux_or_sb_config_disabler == 'Surface Brightness' + expose = ('spectral_unit', 'flux_unit') + return PluginUserApi(self, expose=expose) def _on_glue_x_display_unit_changed(self, x_unit): if x_unit is None: @@ -110,7 +140,7 @@ def _on_glue_x_display_unit_changed(self, x_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_or_sb_unit.choices): + if not len(self.flux_unit.choices) or not len(self.sb_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) @@ -124,31 +154,46 @@ def _on_glue_y_display_unit_changed(self, y_unit): # and call this manually in the case that that is triggered second. return self.spectrum_viewer.set_plot_axes() - if y_unit != self.flux_or_sb_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) + + if check_if_unit_is_per_solid_angle(y_unit): + flux_or_sb = 'Surface Brightness' + else: + flux_or_sb = 'Flux' + + 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) + + if flux_or_sb == 'Flux' and y_unit != self.flux_unit.selected: + flux_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_or_sb_unit.choices = choices - self.flux_or_sb_unit.selected = y_unit + if not np.any([y_u == u.Unit(choice) for choice in flux_choices]): + flux_choices = [y_unit] + flux_choices - def translate_units(self, flux_or_sb_selected): - spec_units = u.Unit(self.spectrum_viewer.state.y_display_unit) - # Surface Brightness -> Flux - if u.sr in spec_units.bases and flux_or_sb_selected == 'Flux': - spec_units *= u.sr - # update display units - self.spectrum_viewer.state.y_display_unit = str(spec_units) + if self.app.config == 'cubeviz': + sb_choices = create_sb_equivalencies_list(y_u / u.sr, x_u) + self.sb_unit.choices = sb_choices + if y_unit + ' / sr' in self.sb_unit.choices: + self.sb_unit.selected = y_unit + ' / sr' - # Flux -> Surface Brightness - elif u.sr not in spec_units.bases and flux_or_sb_selected == 'Surface Brightness': - spec_units /= u.sr - # update display units - self.spectrum_viewer.state.y_display_unit = str(spec_units) + self.flux_unit.choices = flux_choices + self.flux_unit.selected = y_unit + self.flux_or_sb.selected = 'Flux' + + elif flux_or_sb == 'Surface Brightness' and y_unit != self.sb_unit.selected: + sb_choices = create_sb_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 sb_choices]): + sb_choices = [y_unit] + sb_choices + + if self.app.config == 'cubeviz': + flux_choices = create_flux_equivalencies_list(y_u * u.sr, x_u) + self.flux_unit.choices = flux_choices + + self.sb_unit.choices = sb_choices + self.sb_unit.selected = y_unit @observe('spectral_unit_selected') def _on_spectral_unit_changed(self, *args): @@ -159,47 +204,148 @@ def _on_spectral_unit_changed(self, *args): self.spectral_unit.selected, sender=self)) - @observe('flux_unit_selected') - def _on_flux_unit_changed(self, *args): + @observe('flux_or_sb_selected', 'flux_unit_selected', 'sb_unit_selected') + def _on_flux_unit_changed(self, msg): + # may need to be updated if translations in other configs going to be supported + if not hasattr(self, 'flux_unit'): + return + if not self.flux_unit.choices and self.app.config == 'cubeviz': + return + flux_or_sb = None + current_y = self.spectrum_viewer.state.y_display_unit + + data_collection_unit = '' + # need to determine the input spectrum units to disable the additional + # drop down and possiblity of translations in Specviz. + if ( + len(self.app.data_collection) > 0 and + self.app.data_collection[0] and + self.app.config == 'specviz' + ): + if check_if_unit_is_per_solid_angle(self.app.data_collection[0].get_object().flux.unit): # noqa + data_collection_unit = 'Surface Brightness' + self.flux_or_sb_config_disabler = 'Flux' + else: + data_collection_unit = 'Flux' + self.flux_or_sb_config_disabler = 'Surface Brightness' - yunit = _valid_glue_display_unit(self.flux_or_sb_unit.selected, self.spectrum_viewer, 'y') + name = msg.get('name') + # determine if flux or surface brightness unit was changed by user + if name == 'flux_unit_selected': + # when the configuration is Specviz, translation is not currently supported. + # If in Cubeviz, all spectra pass through Spectral Extraction plugin and will + # have a scale factor assigned in the metadata, enabling translation. + if data_collection_unit == 'Surface Brightness': + raise ValueError( + f"Unit translation between Flux and Surface Brightness " + f"is not supported in {self.app.config}." + ) + flux_or_sb = self.flux_unit.selected + # update flux or surface brightness dropdown if necessary + if check_if_unit_is_per_solid_angle(current_y): + self._translate('Flux') + self.flux_or_sb.selected = 'Flux' + untranslatable_units = self._untranslatable_units + # disable translator if flux unit is untranslatable, + # still can convert flux units, this just disables flux + # to surface brightnes translation for units in list. + if flux_or_sb in untranslatable_units: + self.can_translate = False + else: + self.can_translate = True - if self.spectrum_viewer.state.y_display_unit != yunit: + elif name == 'sb_unit_selected': + if data_collection_unit == 'Flux': + # when the configuration is Specviz, translation is not currently supported. + # If in Cubeviz, all spectra pass through Spectral Xxtraction plugin and will + # have a scale factor assigned in the metadata, enabling translation. + raise ValueError( + "Unit translation between Flux and Surface Brightness " + f"is not supported in {self.app.config}." + ) + flux_or_sb = self.sb_unit.selected + self.can_translate = True + # update flux or surface brightness dropdown if necessary + if not check_if_unit_is_per_solid_angle(current_y): + self._translate('Surface Brightness') + self.flux_or_sb.selected = 'Surface Brightness' + elif name == 'flux_or_sb_selected': + self._translate(self.flux_or_sb_selected) + return + else: + return + + yunit = _valid_glue_display_unit(flux_or_sb, self.spectrum_viewer, 'y') + if self.spectrum_viewer.state.y_display_unit != yunit: self.spectrum_viewer.state.y_display_unit = yunit self.hub.broadcast(GlobalDisplayUnitChanged('flux', - self.flux_or_sb_unit.selected, + flux_or_sb, sender=self)) + self.spectrum_viewer.reset_limits() - # Ensure first dropdown selection for Flux/Surface Brightness - # is in accordance with the data collection item's units. - @observe('show_translator') - def _set_flux_or_sb(self, *args): - if (self.spectrum_viewer and hasattr(self.spectrum_viewer.state, 'y_display_unit') - and self.spectrum_viewer.state.y_display_unit is not None): - if u.sr in u.Unit(self.spectrum_viewer.state.y_display_unit).bases: - self.flux_or_sb_selected = 'Surface Brightness' - else: - self.flux_or_sb_selected = 'Flux' + if ( + len(self.app.data_collection) > 0 + and not self.app.data_collection[0].meta.get('PIXAR_SR') + ): + self.pixar_sr_exists = False - @observe('flux_or_sb_selected') - def _translate(self, *args): + def _translate(self, flux_or_sb=None): # currently unsupported, can be supported with a scale factor if self.app.config == 'specviz': return - # Check for a scale factor/data passed through spectral extraction plugin. - specs_w_factor = [spec for spec in self.app.data_collection - if "_pixel_scale_factor" in spec.meta] - # Translate if we have a scale factor - if specs_w_factor: - self.translate_units(self.flux_or_sb_selected) - # The translator dropdown hasn't been loaded yet so don't try translating - elif not self.show_translator: + # we want to raise an error if a user tries to translate with an + # untranslated Flux unit using the API + untranslatable_units = self._untranslatable_units + untranslatable_units = units_to_strings(untranslatable_units) + + if hasattr(self, 'flux_unit'): + if ((self.flux_unit.selected in untranslatable_units) + and (flux_or_sb == 'Surface Brightness')): + raise ValueError( + "Selected flux unit is not translatable. Please choose a flux unit " + f"that is not in the following list: {untranslatable_units}." + ) + + 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 - # Notify the user to extract a spectrum before using the surface brightness/flux - # translation. Can be removed after all 1D spectra in Cubeviz pass through - # spectral extraction plugin (as the scale factor will then be stored). + # 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) + self.flux_or_sb.selected = 'Flux' + + # 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) + self.flux_or_sb.selected = 'Surface Brightness' + # entered the translator when we shouldn't translate else: - raise ValueError("No collapsed spectra in data collection, \ - please collapse a spectrum first.") + return + + self.hub.broadcast(GlobalDisplayUnitChanged('flux', + spec_units, + sender=self)) + self.spectrum_viewer.reset_limits() + + @property + def _untranslatable_units(self): + return [ + u.erg / (u.s * u.cm**2), + u.erg / (u.s * u.cm**2 * u.Angstrom), + u.erg / (u.s * u.cm**2 * u.Hz), + u.ph / (u.Angstrom * u.s * u.cm**2), + u.ph / (u.s * u.cm**2 * u.Hz), + u.ST, u.bol + ] diff --git a/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.vue b/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.vue index ee252b246b..5eae0ec7d2 100644 --- a/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.vue +++ b/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.vue @@ -19,28 +19,54 @@ > - + - + + Translation is not available due to current unit selection. + + + + + + + + + Translation is not available due to current unit selection. + + + PIXAR_SR FITS header keyword not found when parsing spectral cube. + Flux/Surface Brightness will use default PIXAR_SR value of 1. + + diff --git a/jdaviz/core/marks.py b/jdaviz/core/marks.py index 0286c0dac9..ae6533dbdb 100644 --- a/jdaviz/core/marks.py +++ b/jdaviz/core/marks.py @@ -5,6 +5,7 @@ from bqplot.marks import Lines, Label, Scatter from glue.core import HubListener from specutils import Spectrum1D +from jdaviz.utils import _eqv_pixar_sr from jdaviz.core.events import GlobalDisplayUnitChanged from jdaviz.core.events import (SliceToolStateMessage, LineIdentifyMessage, @@ -112,7 +113,9 @@ def set_y_unit(self, unit=None): if self.viewer.default_class is Spectrum1D: spec = self.viewer.state.reference_data.get_object(cls=Spectrum1D) eqv = u.spectral_density(spec.spectral_axis) - y = (self.y * self.yunit).to_value(unit, equivalencies=eqv) + if ('_pixel_scale_factor' in spec.meta): + eqv += _eqv_pixar_sr(spec.meta['_pixel_scale_factor']) + y = (self.y * self.yunit).to_value(unit, equivalencies=eqv) else: y = (self.y * self.yunit).to_value(unit) self.yunit = unit diff --git a/jdaviz/core/validunits.py b/jdaviz/core/validunits.py index 22c440a5e6..8880eb39f1 100644 --- a/jdaviz/core/validunits.py +++ b/jdaviz/core/validunits.py @@ -67,28 +67,17 @@ def create_flux_equivalencies_list(flux_unit, spectral_axis_unit): except u.core.UnitConversionError: return [] - # Get local units. - if u.sr not in flux_unit.bases: - locally_defined_flux_units = ['Jy', 'mJy', 'uJy', 'MJy', 'Jy', - 'W / (Hz m2)', - 'eV / (s m2 Hz)', - 'erg / (s cm2)', - 'erg / (s cm2 Angstrom)', - 'erg / (s cm2 Hz)', - 'ph / (s cm2 Angstrom)', - 'ph / (s cm2 Hz)'] - local_units = [u.Unit(unit) for unit in locally_defined_flux_units] - else: - locally_defined_flux_units = ['Jy / sr', 'mJy / sr', 'uJy / sr', 'MJy / sr', 'Jy / sr', - 'W / (Hz sr m2)', - 'eV / (s m2 Hz sr)', - 'erg / (s cm2 sr)', - 'erg / (s cm2 Angstrom sr)', - 'erg / (s cm2 Hz sr)', - 'ph / (s cm2 Angstrom sr)', - 'ph / (s cm2 Hz sr)', - 'bol / sr', 'AB / sr', 'ST / sr'] - local_units = [u.Unit(unit) for unit in locally_defined_flux_units] + # Get local flux units. + locally_defined_flux_units = ['Jy', 'mJy', 'uJy', 'MJy', + 'W / (Hz m2)', + 'eV / (s m2 Hz)', + 'erg / (s cm2 Hz)', + 'erg / (s cm2 Angstrom)', + 'ph / (Angstrom s cm2)', + 'ph / (Hz s cm2)', + 'bol', 'AB', 'ST' + ] + local_units = [u.Unit(unit) for unit in locally_defined_flux_units] # Remove overlap units. curr_flux_unit_equivalencies = list(set(curr_flux_unit_equivalencies) @@ -101,6 +90,40 @@ def create_flux_equivalencies_list(flux_unit, spectral_axis_unit): return sorted(units_to_strings(local_units)) + flux_unit_equivalencies_titles +def create_sb_equivalencies_list(sb_unit, spectral_axis_unit): + """Get all possible conversions for flux from current flux units.""" + if ((sb_unit in (u.count, u.dimensionless_unscaled)) + or (spectral_axis_unit in (u.pix, u.dimensionless_unscaled))): + return [] + + # Get unit equivalencies. Value passed into u.spectral_density() is irrelevant. + try: + curr_sb_unit_equivalencies = sb_unit.find_equivalent_units( + equivalencies=u.spectral_density(1 * spectral_axis_unit), + include_prefix_units=False) + except u.core.UnitConversionError: + return [] + + locally_defined_sb_units = ['Jy / sr', 'mJy / sr', + 'uJy / sr', 'MJy / sr', + 'W / (Hz sr m2)', + 'eV / (Hz s sr m2)', + 'AB / sr' + ] + + local_units = [u.Unit(unit) for unit in locally_defined_sb_units] + + # Remove overlap units. + curr_sb_unit_equivalencies = list(set(curr_sb_unit_equivalencies) + - set(local_units)) + + # Convert equivalencies into readable versions of the units and sort them alphabetically. + sb_unit_equivalencies_titles = sorted(units_to_strings(curr_sb_unit_equivalencies)) + + # Concatenate both lists with the local units coming first. + return sorted(units_to_strings(local_units)) + sb_unit_equivalencies_titles + + def check_if_unit_is_per_solid_angle(unit): """ Check if a given u.Unit or unit string (that can be converted to diff --git a/jdaviz/tests/test_utils.py b/jdaviz/tests/test_utils.py index de9aac78c5..bf4f7ceb2d 100644 --- a/jdaviz/tests/test_utils.py +++ b/jdaviz/tests/test_utils.py @@ -4,24 +4,83 @@ import photutils import pytest from asdf.exceptions import AsdfWarning +from astropy import units as u from astropy.utils import minversion from astropy.wcs import FITSFixedWarning -from jdaviz import utils +from numpy.testing import assert_allclose +from specutils import Spectrum1D + +from jdaviz.utils import alpha_index, download_uri_to_path, flux_conversion PHOTUTILS_LT_1_12_1 = not minversion(photutils, "1.12.1.dev") +def test_spec_sb_flux_conversion(): + # Actual spectrum content does not matter, just the meta is used here. + spec = Spectrum1D(flux=[1, 1, 1] * u.Jy, spectral_axis=[1, 2, 3] * u.um) + + # values != 2 + values = [10, 20, 30] + + # Float scalar pixel scale factor + spec.meta["_pixel_scale_factor"] = 0.1 + assert_allclose(flux_conversion(spec, values, u.Jy / u.sr, u.Jy), [1, 2, 3]) + assert_allclose(flux_conversion(spec, values, u.Jy, u.Jy / u.sr), [100, 200, 300]) + + # Quantity scalar pixel scale factor + spec.meta["_pixel_scale_factor"] = 0.1 * (u.sr / u.pix) + assert_allclose(flux_conversion(spec, values, u.Jy / u.sr, u.Jy), [1, 2, 3]) + assert_allclose(flux_conversion(spec, values, u.Jy, u.Jy / u.sr), [100, 200, 300]) + + # values == 2 + values = [10, 20] + assert_allclose(flux_conversion(spec, values, u.Jy / u.sr, u.Jy), [1, 2]) + assert_allclose(flux_conversion(spec, values, u.Jy, u.Jy / u.sr), [100, 200]) + + # float array pixel scale factor + spec.meta["_pixel_scale_factor"] = [0.1, 0.2, 0.3] # min_max = [0.1, 0.3] + assert_allclose(flux_conversion(spec, values, u.Jy / u.sr, u.Jy), [1, 6]) + assert_allclose(flux_conversion(spec, values, u.Jy, u.Jy / u.sr), [100, 66.66666666666667]) + + # Quantity array pixel scale factor + spec.meta["_pixel_scale_factor"] = [0.1, 0.2, 0.3] * (u.sr / u.pix) # min_max = [0.1, 0.3] + assert_allclose(flux_conversion(spec, values, u.Jy / u.sr, u.Jy), [1, 6]) + assert_allclose(flux_conversion(spec, values, u.Jy, u.Jy / u.sr), [100, 66.66666666666667]) + + # values != 2 + values = [10, 20, 30] + spec.meta["_pixel_scale_factor"] = [0.1, 0.2, 0.3] + assert_allclose(flux_conversion(spec, values, u.Jy / u.sr, u.Jy), [1, 4, 9]) + assert_allclose(flux_conversion(spec, values, u.Jy, u.Jy / u.sr), 100) + + # values != 2 but _pixel_scale_factor size mismatch + with pytest.raises(ValueError, match="operands could not be broadcast together"): + spec.meta["_pixel_scale_factor"] = [0.1, 0.2, 0.3, 0.4] + flux_conversion(spec, values, u.Jy / u.sr, u.Jy) + + # Other kind of flux conversion unrelated to _pixel_scale_factor. + # The answer was obtained from synphot unit conversion. + spec.meta["_pixel_scale_factor"] = 0.1 + targ = [2.99792458e-12, 1.49896229e-12, 9.99308193e-13] * (u.erg / (u.AA * u.cm * u.cm * u.s)) # FLAM # noqa: E501 + assert_allclose(flux_conversion(spec, values, u.Jy, targ.unit), targ.value) + + # values == 2 (only used spec.spectral_axis[0] for some reason) + values = [10, 20] + targ = [2.99792458e-12, 5.99584916e-12] * (u.erg / (u.AA * u.cm * u.cm * u.s)) # FLAM + assert_allclose(flux_conversion(spec, values, u.Jy, targ.unit), targ.value) + + @pytest.mark.parametrize("test_input,expected", [(0, 'a'), (1, 'b'), (25, 'z'), (26, 'aa'), (701, 'zz'), (702, '{a')]) def test_alpha_index(test_input, expected): - assert utils.alpha_index(test_input) == expected + assert alpha_index(test_input) == expected def test_alpha_index_exceptions(): with pytest.raises(TypeError, match="index must be an integer"): - utils.alpha_index(4.2) + alpha_index(4.2) with pytest.raises(ValueError, match="index must be positive"): - utils.alpha_index(-1) + alpha_index(-1) def test_uri_to_download_bad_scheme(imviz_helper): @@ -54,7 +113,7 @@ def test_url_to_download_imviz_local_path_warning(imviz_helper): def test_uri_to_download_specviz_local_path_check(): uri = "mast:JWST/product/jw02732-o004_t004_miri_ch1-shortmediumlong_x1d.fits" - local_path = utils.download_uri_to_path(uri, cache=False, dryrun=True) # No download + local_path = download_uri_to_path(uri, cache=False, dryrun=True) # No download # Wrong: '.\\JWST/product/jw02732-o004_t004_miri_ch1-shortmediumlong_x1d.fits' # Correct: '.\\jw02732-o004_t004_miri_ch1-shortmediumlong_x1d.fits' diff --git a/jdaviz/utils.py b/jdaviz/utils.py index c406f246c3..34e9505c1e 100644 --- a/jdaviz/utils.py +++ b/jdaviz/utils.py @@ -3,6 +3,7 @@ import threading import warnings from collections import deque +from collections.abc import Iterable from urllib.parse import urlparse import numpy as np @@ -292,7 +293,7 @@ def flux_conversion(spec, values, original_units, target_units): Parameters ---------- - spec : ~specutils.Spectrum1D~ object + spec : `~specutils.Spectrum1D` object The Spectrum1D object that will have converted flux units. values : float array @@ -306,8 +307,13 @@ def flux_conversion(spec, values, original_units, target_units): Returns ------- - Flux values in the target units. + result : float array + Flux values in the target units. """ + # we set surface brightness choices and selection before flux, which can + # cause a dimensionless translation attempt at instantiation + if not target_units: + return values # If there are only two values, this is likely the limits being converted, so then # in case we need to use the spectral density equivalency, we need to provide only # to spectral axis values. If there is only one value @@ -316,57 +322,47 @@ def flux_conversion(spec, values, original_units, target_units): else: spectral_values = spec.spectral_axis + # Need this for setting the y-limits + eqv = u.spectral_density(spectral_values) + + orig_units = u.Unit(original_units) + orig_bases = orig_units.bases + targ_units = u.Unit(target_units) + targ_bases = targ_units.bases + # Ensure a spectrum passed through Spectral Extraction plugin - if '_pixel_scale_factor' in spec.meta and len(values) != 2: + if (('_pixel_scale_factor' in spec.meta) and + (((u.sr in orig_bases) and (u.sr not in targ_bases)) or + ((u.sr not in orig_bases) and (u.sr in targ_bases)))): # Data item in data collection does not update from conversion/translation. - # App wide original data units are used for conversion, original and + # App-wide original data units are used for conversion, original and # target_units dictate the conversion to take place. + n_values = len(values) + + # Make sure they are float (can be Quantity). + fac = spec.meta['_pixel_scale_factor'] + if isinstance(fac, Quantity): + fac = fac.value - if (u.sr in u.Unit(original_units).bases) and \ - (u.sr not in u.Unit(target_units).bases): - # Surface Brightness -> Flux - eqv = [(u.MJy / u.sr, - u.MJy, - lambda x: (x * np.array(spec.meta.get('_pixel_scale_factor', 1))), - lambda x: x)] - elif (u.sr not in u.Unit(original_units).bases) and \ - (u.sr in u.Unit(target_units).bases): - # Flux -> Surface Brightness - eqv = [(u.MJy, - u.MJy / u.sr, - lambda x: (x / np.array(spec.meta.get('_pixel_scale_factor', 1))), - lambda x: x)] + # Get min and max scale factors, to use with min and max of spec for y-limits. + if n_values == 2 and isinstance(fac, Iterable): + eqv_in = [min(fac), max(fac)] else: - eqv = u.spectral_density(spectral_values) - - elif len(values) == 2: - # Need this for setting the y-limits - eqv = u.spectral_density(spectral_values) - - if '_pixel_scale_factor' in spec.meta: - # get min and max scale factors, to use with min and max of spec for - # y-limits. Make sure they are Quantities (can be numpy.float64). - pixel_scale_min = (Quantity(min(spec.meta.get('_pixel_scale_factor', 1)))).value - pixel_scale_max = (Quantity(max(spec.meta.get('_pixel_scale_factor', 1)))).value - min_max = [pixel_scale_min, pixel_scale_max] - - if (u.sr in u.Unit(original_units).bases) and \ - (u.sr not in u.Unit(target_units).bases): - eqv += [(u.MJy, - u.MJy / u.sr, - lambda x: x * np.array(min_max), - lambda x: x)] - elif (u.sr not in u.Unit(original_units).bases) and \ - (u.sr in u.Unit(target_units).bases): - eqv += [(u.MJy / u.sr, - u.MJy, - lambda x: x / np.array(min_max), - lambda x: x)] + eqv_in = fac - else: - eqv = u.spectral_density(spectral_values) + eqv += _eqv_pixar_sr(np.array(eqv_in)) - return (values * u.Unit(original_units)).to_value(u.Unit(target_units), equivalencies=eqv) + return (values * orig_units).to_value(targ_units, equivalencies=eqv) + + +def _eqv_pixar_sr(pixar_sr): + def converter_flux(x): # Surface Brightness -> Flux + return x * pixar_sr + + def iconverter_flux(x): # Flux -> Surface Brightness + return x / pixar_sr + + return [(u.MJy / u.sr, u.MJy, converter_flux, iconverter_flux)] def spectral_axis_conversion(values, original_units, target_units):