diff --git a/CHANGES.rst b/CHANGES.rst index 296f82eb5a..48cebcbbe2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -53,6 +53,10 @@ Specviz2d Bug Fixes --------- +- Fixed broken flux unit conversions in all plugins that respond to changes in flux unit changes. These cases + occured when certain flux-to flux-conversions occured, as well as certain conversions between flux and surface + brightness. This PR also fixed an issue with unit string formatting in the aperture photometry plugin. [#3228] + Cubeviz ^^^^^^^ - Removed the deprecated ``save as fits`` option from the Collapse, Moment Maps, and Spectral Extraction plugins; use the Export plugin instead. [#3256] diff --git a/docs/reference/api_nuts_bolts.rst b/docs/reference/api_nuts_bolts.rst index 1c0116deec..d3e6db87dc 100644 --- a/docs/reference/api_nuts_bolts.rst +++ b/docs/reference/api_nuts_bolts.rst @@ -79,7 +79,11 @@ Utilities API :no-inheritance-diagram: :no-inherited-members: -.. automodapi:: jdaviz.core.validunits +.. automodapi:: jdaviz.core.unit_conversion_utils + :no-inheritance-diagram: + :no-inherited-members: + +.. automodapi:: jdaviz.core.custom_units_and_equivs :no-inheritance-diagram: :no-inherited-members: diff --git a/jdaviz/app.py b/jdaviz/app.py index 132c3a5b5f..b5885b44b5 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -52,10 +52,10 @@ from jdaviz.utils import (SnackbarQueue, alpha_index, data_has_valid_wcs, layer_is_table_data, MultiMaskSubsetState, _wcs_only_label, flux_conversion, spectral_axis_conversion) -from jdaviz.core.custom_units import SPEC_PHOTON_FLUX_DENSITY_UNITS -from jdaviz.core.validunits import (check_if_unit_is_per_solid_angle, - combine_flux_and_angle_units, - supported_sq_angle_units) +from jdaviz.core.custom_units_and_equivs import SPEC_PHOTON_FLUX_DENSITY_UNITS +from jdaviz.core.unit_conversion_utils import (check_if_unit_is_per_solid_angle, + combine_flux_and_angle_units, + supported_sq_angle_units) __all__ = ['Application', 'ALL_JDAVIZ_CONFIGS', 'UnitConverterWithSpectral'] diff --git a/jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py b/jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py index d7783fff22..339f23d660 100644 --- a/jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py +++ b/jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py @@ -17,6 +17,7 @@ SpectralContinuumMixin, skip_if_no_updates_since_last_active, with_spinner) +from jdaviz.core.unit_conversion_utils import convert_integrated_sb_unit from jdaviz.core.user_api import PluginUserApi __all__ = ['MomentMap'] @@ -324,7 +325,40 @@ def calculate_moment(self, add_data=True): # convert units for moment 0, which is the only currently supported # moment for using converted units. if n_moment == 0: - self.moment = self.moment.to(self.moment_zero_unit) + if self.moment_zero_unit != self.moment.unit: + spectral_axis_unit = u.Unit(self.spectrum_viewer.state.x_display_unit) + + # if the flux unit is a per-frequency unit but the spectral axis unit + # is a wavelength, or vice versa, we need to convert the spectral axis + # unit that the flux was integrated over so they are compatible for + # unit conversion (e.g., Jy m / sr needs to become Jy Hz / sr, and + # (erg Hz)/(s * cm**2 * AA) needs to become (erg)/(s * cm**2) + desired_freq_unit = spectral_axis_unit if spectral_axis_unit.physical_type == 'frequency' else u.Hz # noqa E501 + desired_length_unit = spectral_axis_unit if spectral_axis_unit.physical_type == 'length' else u.AA # noqa E501 + moment_temp = convert_integrated_sb_unit(self.moment, + spectral_axis_unit, + desired_freq_unit, + desired_length_unit) + moment_zero_unit_temp = convert_integrated_sb_unit(1 * u.Unit(self.moment_zero_unit), # noqa E501 + spectral_axis_unit, + desired_freq_unit, + desired_length_unit) + + moment = moment_temp.to(moment_zero_unit_temp.unit, u.spectral()) + + # if flux and spectral axis units were incompatible in terms of freq/wav + # and needed to be converted to an intermediate unit for conversion, then + # re-instate the original chosen units (e.g Jy m /sr was converted to Jy Hz / sr + # for unit conversion, now back to Jy m / sr) + if spectral_axis_unit not in moment.unit.bases: + if spectral_axis_unit.physical_type == 'frequency': + moment *= (1*desired_length_unit).to(desired_freq_unit, + u.spectral()) / desired_length_unit + elif spectral_axis_unit.physical_type == 'length': + moment *= (1*desired_freq_unit).to(desired_length_unit, + u.spectral()) / desired_freq_unit + + self.moment = moment # 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 e85b2a4424..44389b352d 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 @@ -10,6 +10,8 @@ from numpy.testing import assert_allclose from specutils import SpectralRegion +from jdaviz.core.custom_units_and_equivs import PIX2, SPEC_PHOTON_FLUX_DENSITY_UNITS + @pytest.mark.parametrize("cube_type", ["Surface Brightness", "Flux"]) def test_user_api(cubeviz_helper, spectrum1d_cube, spectrum1d_cube_sb_unit, cube_type): @@ -350,3 +352,80 @@ def test_correct_output_spectral_y_units(cubeviz_helper, spectrum1d_cube_custom_ mm.calculate_moment() assert mm.moment.unit == moment_unit.replace('m', 'um') + + +@pytest.mark.parametrize("flux_unit", [u.Unit(x) for x in SPEC_PHOTON_FLUX_DENSITY_UNITS][1:2]) +@pytest.mark.parametrize("angle_unit", [u.sr, PIX2]) +@pytest.mark.parametrize("new_flux_unit", [u.Unit(x) for x in SPEC_PHOTON_FLUX_DENSITY_UNITS][1:2]) +def test_moment_zero_unit_flux_conversions(cubeviz_helper, + spectrum1d_cube_custom_fluxunit, + flux_unit, angle_unit, new_flux_unit): + """ + Test cubeviz moment maps with all possible unit conversions for + cubes in spectral/photon surface brightness units (e.g. Jy/sr, Jy/pix2). + + The moment map plugin should respect the choice of flux and angle + unit selected in the Unit Conversion plugin, and inputs and results should + be converted based on selection. All conversions between units in the + flux dropdown menu in the unit conversion plugin should be supported + by moment maps. + """ + + if new_flux_unit == flux_unit: # skip 'converting' to same unit + return + new_flux_unit_str = new_flux_unit.to_string() + + cube_unit = flux_unit / angle_unit + + sb_cube = spectrum1d_cube_custom_fluxunit(fluxunit=cube_unit) + + # load surface brigtness cube + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="No observer defined on WCS.*") + cubeviz_helper.load_data(sb_cube, data_label='test') + + # get plugins + uc = cubeviz_helper.plugins["Unit Conversion"] + mm = cubeviz_helper.plugins['Moment Maps']._obj + + # and flux viewer for mouseover info + flux_viewer = cubeviz_helper.app.get_viewer(cubeviz_helper._default_flux_viewer_reference_name) + label_mouseover = cubeviz_helper.app.session.application._tools['g-coords-info'] + + # convert to new flux unit + uc.flux_unit.selected = new_flux_unit_str + + new_mm_unit = (new_flux_unit * u.m / u.Unit(angle_unit)).to_string() + assert mm.output_unit_items[0]['label'] == 'Surface Brightness' + assert mm.output_unit_items[0]['unit_str'] == new_mm_unit + + # calculate moment with new output label and plot in flux viewer + mm.add_results.label = new_flux_unit_str + mm.add_results.viewer.selected = cubeviz_helper._default_flux_viewer_reference_name + mm.calculate_moment() + + assert mm.moment.unit == new_mm_unit + + # make sure mouseover info in flux unit is new moment map unit + # which should be flux/sb unit times spectral axis unit (e.g. MJy m / sr) + label_mouseover._viewer_mouse_event(flux_viewer, + {'event': 'mousemove', + 'domain': {'x': 0, 'y': 0}}) + m_orig = label_mouseover.as_text()[0] + assert ((new_flux_unit / angle_unit) * u.m).to_string() in m_orig + + # 'jiggle' mouse so we can move it back and compare original coordinate + label_mouseover._viewer_mouse_event(flux_viewer, + {'event': 'mousemove', + 'domain': {'x': 1, 'y': 1}}) + + # when flux unit is changed, the mouseover unit conversion should be + # skipped so that the plotted moment map remains in its original + # unit. setting back to the original flux unit also ensures that + # each iteration begins on the same unit so that every comparison + # is tested + uc.flux_unit.selected = new_flux_unit_str + label_mouseover._viewer_mouse_event(flux_viewer, + {'event': 'mousemove', + 'domain': {'x': 0, 'y': 0}}) + assert m_orig == label_mouseover.as_text()[0] diff --git a/jdaviz/configs/cubeviz/plugins/parsers.py b/jdaviz/configs/cubeviz/plugins/parsers.py index 502965531f..3a989c4fc5 100644 --- a/jdaviz/configs/cubeviz/plugins/parsers.py +++ b/jdaviz/configs/cubeviz/plugins/parsers.py @@ -10,11 +10,10 @@ from astropy.wcs import WCS from specutils import Spectrum1D -from jdaviz.core.custom_units import PIX2 +from jdaviz.core.custom_units_and_equivs import PIX2, _eqv_flux_to_sb_pixel from jdaviz.core.registries import data_parser_registry -from jdaviz.core.validunits import check_if_unit_is_per_solid_angle -from jdaviz.utils import (standardize_metadata, PRIHDR_KEY, download_uri_to_path, - _eqv_flux_to_sb_pixel) +from jdaviz.core.unit_conversion_utils import check_if_unit_is_per_solid_angle +from jdaviz.utils import standardize_metadata, PRIHDR_KEY, download_uri_to_path __all__ = ['parse_data'] diff --git a/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py b/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py index 913faf818b..a096826728 100644 --- a/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py +++ b/jdaviz/configs/cubeviz/plugins/spectral_extraction/spectral_extraction.py @@ -20,10 +20,11 @@ skip_if_no_updates_since_last_active, with_spinner, with_temp_disable) from jdaviz.core.user_api import PluginUserApi -from jdaviz.core.validunits import check_if_unit_is_per_solid_angle +from jdaviz.core.unit_conversion_utils import (all_flux_unit_conversion_equivs, + flux_conversion_general, + check_if_unit_is_per_solid_angle) from jdaviz.configs.cubeviz.plugins.parsers import _return_spectrum_with_correct_units from jdaviz.configs.cubeviz.plugins.viewers import WithSliceIndicator -from jdaviz.utils import _eqv_pixar_sr __all__ = ['SpectralExtraction'] @@ -562,9 +563,20 @@ def _preview_x_from_extracted(self, extracted): return extracted.spectral_axis def _preview_y_from_extracted(self, extracted): - # TODO: use extracted.meta.get('PIXAR_SR') once populated - return extracted.flux.to(self.spectrum_y_units, - equivalencies=_eqv_pixar_sr(self.dataset.selected_obj.meta.get('PIXAR_SR', 1.0))) # noqa: + """ + Convert y-axis units of extraction preview to display units, + if necessary. + """ + + if extracted.flux.unit != self.spectrum_y_units: + + eqv = all_flux_unit_conversion_equivs(self.dataset.selected_obj.meta.get('PIXAR_SR', 1.0), # noqa + self.dataset.selected_obj.spectral_axis) + + return flux_conversion_general(extracted.flux.value, extracted.flux.unit, + self.spectrum_y_units, eqv) + + return extracted.flux @with_spinner() def extract(self, return_bg=False, add_data=True, **kwargs): diff --git a/jdaviz/configs/cubeviz/plugins/spectral_extraction/tests/test_spectral_extraction.py b/jdaviz/configs/cubeviz/plugins/spectral_extraction/tests/test_spectral_extraction.py index 48ce1eb874..9893fd5420 100644 --- a/jdaviz/configs/cubeviz/plugins/spectral_extraction/tests/test_spectral_extraction.py +++ b/jdaviz/configs/cubeviz/plugins/spectral_extraction/tests/test_spectral_extraction.py @@ -8,7 +8,6 @@ from astropy.table import QTable from astropy.tests.helper import assert_quantity_allclose from astropy.utils.exceptions import AstropyUserWarning - from glue.core.roi import CircularROI, RectangularROI from numpy.testing import assert_allclose, assert_array_equal from regions import (CirclePixelRegion, CircleAnnulusPixelRegion, EllipsePixelRegion, @@ -16,6 +15,10 @@ from specutils import Spectrum1D from specutils.manipulation import FluxConservingResampler +from jdaviz.core.custom_units_and_equivs import PIX2, SPEC_PHOTON_FLUX_DENSITY_UNITS +from jdaviz.core.unit_conversion_utils import (all_flux_unit_conversion_equivs, + flux_conversion_general) + calspec_url = "https://archive.stsci.edu/hlsps/reference-atlases/cdbs/current_calspec/" @@ -650,3 +653,68 @@ def test_spectral_extraction_scientific_validation( ).to_value(u.dimensionless_unscaled) - 1 )) assert median_abs_relative_dev < expected_rtol + + +@pytest.mark.parametrize("flux_angle_unit", [(u.Unit(x), u.sr) for x in SPEC_PHOTON_FLUX_DENSITY_UNITS] # noqa + + [(u.Unit(x), PIX2) for x in SPEC_PHOTON_FLUX_DENSITY_UNITS]) # noqa +def test_spectral_extraction_flux_unit_conversions(cubeviz_helper, + spectrum1d_cube_custom_fluxunit, + flux_angle_unit): + """ + Test that cubeviz spectral extraction plugin works with all possible + flux unit conversions for a cube loaded in units spectral/photon flux + density. The spectral extraction result will remain in the native + data unit, but the extraction preview should be converted to the + display unit. + """ + + flux_unit, angle_unit = flux_angle_unit + + sb_cube = spectrum1d_cube_custom_fluxunit(fluxunit=flux_unit / angle_unit, + shape=(5, 4, 4), + with_uncerts=True) + cubeviz_helper.load_data(sb_cube) + + spectrum_viewer = cubeviz_helper.app.get_viewer( + cubeviz_helper._default_spectrum_viewer_reference_name) + + uc = cubeviz_helper.plugins["Unit Conversion"] + se = cubeviz_helper.plugins['Spectral Extraction'] + se.keep_active = True # keep active for access to preview markers + + # equivalencies for unit conversion, for comparison of outputs + equivs = all_flux_unit_conversion_equivs(se.dataset.selected_obj.meta.get('PIXAR_SR', 1.0), + se.dataset.selected_obj.spectral_axis) + + for new_flux_unit in SPEC_PHOTON_FLUX_DENSITY_UNITS: + if new_flux_unit != flux_unit: + + uc.flux_unit.selected = flux_unit.to_string() + uc.spectral_y_type.selected = 'Flux' + + # and set back to sum initially so units will always be flux not sb + se.function.selected = 'Sum' + se.extract() + + original_sum_y_values = se._obj.marks['extract'].y + + # set to new unit + uc.flux_unit.selected = new_flux_unit + assert spectrum_viewer.state.y_display_unit == new_flux_unit + + # still using 'sum', results should be in flux + collapsed = se.extract() + + # make sure extraction preview was translated to new display units + new_sum_y_values = se._obj.marks['extract'].y + new_converted_to_old_unit = flux_conversion_general(new_sum_y_values, + u.Unit(new_flux_unit), + u.Unit(flux_unit), + equivs, with_unit=False) + np.testing.assert_allclose(original_sum_y_values, new_converted_to_old_unit) + + # collapsed result will still have the native data flux unit + assert uc.spectral_y_type.selected == 'Flux' + assert collapsed.flux.unit == collapsed.uncertainty.unit == flux_unit + # but display units in spectrum viewer should reflect new flux unit selection + assert se._obj.spectrum_y_units == se._obj.results_units == new_flux_unit diff --git a/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_aperphot.py b/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_aperphot.py index c3410286ba..66ec5b972e 100644 --- a/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_aperphot.py +++ b/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_aperphot.py @@ -7,15 +7,18 @@ from numpy.testing import assert_allclose from regions import RectanglePixelRegion, PixCoord -from jdaviz.core.custom_units import PIX2 +from jdaviz.core.custom_units_and_equivs import PIX2, SPEC_PHOTON_FLUX_DENSITY_UNITS +from jdaviz.core.unit_conversion_utils import (flux_conversion_general, + handle_squared_flux_unit_conversions) def test_cubeviz_aperphot_cube_orig_flux(cubeviz_helper, image_cube_hdu_obj_microns): cubeviz_helper.load_data(image_cube_hdu_obj_microns, data_label="test") - flux_unit = u.Unit("1E-17 erg*s^-1*cm^-2*Angstrom^-1*pix^-2") # actually a sb + flux_unit = u.Unit("1E-17 erg*s^-1*cm^-2*Angstrom^-1*pix^-2") solid_angle_unit = PIX2 aper = RectanglePixelRegion(center=PixCoord(x=1, y=2), width=3, height=5) + cubeviz_helper.plugins['Subset Tools'].import_region(aper) # Make sure MASK is not an option even when shown in viewer. @@ -201,16 +204,18 @@ def test_cubeviz_aperphot_cube_sr_and_pix2(cubeviz_helper, assert_quantity_allclose(row["slice_wave"], 0.46236 * u.um) -def test_cubeviz_aperphot_cube_orig_flux_mjysr(cubeviz_helper, spectrum1d_cube_custom_fluxunit): - # this test is essentially the same as test_cubeviz_aperphot_cube_sr_and_pix2 but for a single - # surface brightness unit and without changing the pixel area to make outputs the same. it - # was requested in review to keep both tests +def test_cubeviz_aperphot_cube_orig_flux_mjysr(cubeviz_helper, + spectrum1d_cube_custom_fluxunit): + # this test is essentially the same as test_cubeviz_aperphot_cube_sr_and_pix2 + # but for a single surface brightness unit and without changing the pixel + # area to make outputs the same. it was requested in review to keep both tests cube = spectrum1d_cube_custom_fluxunit(fluxunit=u.MJy / u.sr) cubeviz_helper.load_data(cube, data_label="test") aper = RectanglePixelRegion(center=PixCoord(x=3, y=1), width=1, height=1) bg = RectanglePixelRegion(center=PixCoord(x=2, y=0), width=1, height=1) - cubeviz_helper.plugins['Subset Tools'].import_region([aper, bg], combination_mode='new') + cubeviz_helper.plugins['Subset Tools'].import_region([aper, bg], + combination_mode='new') plg = cubeviz_helper.plugins["Aperture Photometry"]._obj plg.dataset_selected = "test[FLUX]" @@ -237,10 +242,11 @@ def test_cubeviz_aperphot_cube_orig_flux_mjysr(cubeviz_helper, spectrum1d_cube_c def _compare_table_units(orig_tab, new_tab, orig_flux_unit=None, - new_flux_unit=None): + new_flux_unit=None, equivalencies=None): - # compare two photometry tables with different units and make sure that the - # units are as expected, and that they are equivalent once translated + # compare two photometry tables with different units row by row, and make + # sure that the units are as expected, and that they are equivalent once + # translated for i, row in enumerate(orig_tab): new_unit = new_tab[i]['unit'] or '-' @@ -253,90 +259,110 @@ def _compare_table_units(orig_tab, new_tab, orig_flux_unit=None, orig_unit = u.Unit(orig_unit) orig = float(row['result']) * orig_unit - # first check that the actual units differ as expected, - # as comparing them would pass if they were the same unit - if orig_flux_unit in orig_unit.bases: - assert new_flux_unit in new_unit.bases - - orig_converted = orig.to(new_unit) - assert_quantity_allclose(orig_converted, new) - - -def test_cubeviz_aperphot_unit_conversion(cubeviz_helper, spectrum1d_cube_custom_fluxunit): - """Make sure outputs of the aperture photometry plugin in Cubeviz - reflect the correct choice of display units from the Unit - Conversion plugin. + if 'var' in row['function']: # variance is in units of flux/sb squared + orig_converted = handle_squared_flux_unit_conversions(orig.value, + orig_unit, + new_unit, + equivalencies) + else: + orig_converted = flux_conversion_general(orig.value, + orig_unit, + new_unit, + equivalencies) + + # low rtol for match, phot table is rounded + assert_quantity_allclose(orig_converted, new, rtol=1e-03) + + +@pytest.mark.parametrize("flux_unit", [u.Unit(x) for x in SPEC_PHOTON_FLUX_DENSITY_UNITS]) +@pytest.mark.parametrize("angle_unit", [u.sr, PIX2]) +@pytest.mark.parametrize("new_flux_unit", [u.Unit(x) for x in SPEC_PHOTON_FLUX_DENSITY_UNITS]) +def test_cubeviz_aperphot_unit_conversions(cubeviz_helper, + spectrum1d_cube_custom_fluxunit, + flux_unit, angle_unit, new_flux_unit): + """ + Test cubeviz aperture photometry with all possible unit conversions for + cubes in spectral/photon surface brightness units (e.g. Jy/sr, Jy/pix2). + + The aperture photometry plugin should respect the choice of flux and angle + unit selected in the Unit Conversion plugin, and inputs and results should + be converted based on selection. All conversions between units in the + flux dropdown menu in the unit conversion plugin should be supported + by aperture photometry. """ - # create cube with units of MJy / sr - mjy_sr_cube = spectrum1d_cube_custom_fluxunit(fluxunit=u.MJy / u.sr, - shape=(5, 5, 4)) + if new_flux_unit == flux_unit: # skip 'converting' to same unit + return - # create apertures for photometry and background - aper = RectanglePixelRegion(center=PixCoord(x=2, y=3), width=1, height=1) - bg = RectanglePixelRegion(center=PixCoord(x=1, y=2), width=1, height=1) + cube_unit = flux_unit / angle_unit + + # get strings of input units + flux_unit_str = flux_unit.to_string() + angle_unit_str = angle_unit.to_string() + cube_unit_str = cube_unit.to_string() + new_flux_unit_str = new_flux_unit.to_string() - cubeviz_helper.load_data(mjy_sr_cube, data_label="test") - cubeviz_helper.plugins['Subset Tools'].import_region([aper, bg], combination_mode='new') + # load cube with specified unit + cube = spectrum1d_cube_custom_fluxunit(fluxunit=cube_unit, shape=(5, 5, 4), + with_uncerts=True) + cubeviz_helper.load_data(cube, data_label="test") + # get plugins + st = cubeviz_helper.plugins['Subset Tools'] ap = cubeviz_helper.plugins['Aperture Photometry']._obj + uc = cubeviz_helper.plugins['Unit Conversion'] + + # load aperture + aper = RectanglePixelRegion(center=PixCoord(x=2, y=3), width=1, height=1) + st.import_region(aper, combination_mode='new') + # select dataset and aperture in plugin ap.dataset_selected = "test[FLUX]" ap.aperture_selected = "Subset 1" - ap.background_selected = "Subset 2" - ap.vue_do_aper_phot() - uc = cubeviz_helper.plugins['Unit Conversion']._obj + # equivalencies for unit conversion, we only need u.spectral_density because + # no flux<>sb conversions will occur in this plugin + equiv = u.spectral_density(ap._cube_wave) - # check that initial units are synced between plugins - assert uc.flux_unit.selected == 'MJy' - assert uc.angle_unit.selected == 'sr' - assert ap.display_unit == 'MJy / sr' - assert ap.flux_scaling_display_unit == 'MJy' + # check initial unit traitlets are synced between ap. phot and unit conv. plugins + assert uc.flux_unit.selected == ap.flux_scaling_display_unit == flux_unit_str + assert uc.angle_unit.selected == angle_unit_str + assert ap.display_unit == cube_unit_str + assert ap.flux_scaling_display_unit == flux_unit_str - # and defaults for inputs are in the correct unit - assert_allclose(ap.flux_scaling, 0.003631) - assert_allclose(ap.background_value, 49) + # set background to manual and background/flux scaling to 1 to make it + # easier to compare between unit conversions + ap.background_selected == 'Manual' + ap.background_value = 1. + ap.flux_scaling = 1. - # output table in original units to compare to - # outputs after converting units + # do aperture photometry with inital cube units to compare original results + # to results after flux unit conversion + ap.vue_do_aper_phot() orig_tab = Table(ap.results) - # change units, which will change the numerator of the current SB unit - uc.flux_unit.selected = 'Jy' + # set to new unit + uc.flux_unit.selected = new_flux_unit_str - # make sure inputs were re-computed in new units - # after the unit change - assert_allclose(ap.flux_scaling, 3631) - assert_allclose(ap.background_value, 4.9e7) - - # re-do photometry and make sure table is in new units - # and consists of the same results as before converting units - ap.vue_do_aper_phot() - new_tab = Table(ap.results) + # make sure display units in aperture phot plugin reflect change + assert (u.Unit(ap.display_unit) * angle_unit).to_string() == new_flux_unit + assert ap.flux_scaling_display_unit == new_flux_unit_str - _compare_table_units(orig_tab, new_tab, orig_flux_unit=u.MJy, - new_flux_unit=u.Jy) + # make sure background and flux scaling were converted to new unit + assert_allclose((ap.background_value * new_flux_unit).to(flux_unit, equiv).value, 1.) + assert_allclose((ap.flux_scaling * new_flux_unit).to(flux_unit, equiv).value, 1.) - # test manual background and flux scaling option input in current - # units (Jy / sr) will be used correctly and converted to data units - ap.background_selected == 'Manual' - ap.background_value = 1.0e7 - ap.flux_scaling = 1000 ap.vue_do_aper_phot() - orig_tab = Table(ap.results) - - # change units back to MJy/sr from Jy/sr - uc.flux_unit.selected = 'MJy' + new_tab = Table(ap.results) - # make sure background input in Jy/sr is now in MJy/sr - assert_allclose(ap.background_value, 10) - assert_allclose(ap.flux_scaling, 0.001) + # if ap. phot silently fails, then 'new_tab' will just be the last + # calculated one, so make sure this didn't happen + assert not np.all(orig_tab == new_tab) - # and that photometry results match those before unit converson, - # but with units converted - ap.vue_do_aper_phot() - new_tab = Table(ap.results) + # compare output tables row by row between original and new unit + _compare_table_units(orig_tab, new_tab, orig_flux_unit=flux_unit, + new_flux_unit=new_flux_unit_str, + equivalencies=equiv) - _compare_table_units(orig_tab, new_tab, orig_flux_unit=u.Jy, - new_flux_unit=u.MJy) + # todo: figure out how to test radial profile and curve of growth plots + # related ticket: JDAT 4962 diff --git a/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_unit_conversion.py b/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_unit_conversion.py index 48109f27f0..ab7919ec46 100644 --- a/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_unit_conversion.py +++ b/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_unit_conversion.py @@ -6,7 +6,7 @@ from regions import PixCoord, CirclePixelRegion, RectanglePixelRegion from specutils import Spectrum1D -from jdaviz.core.custom_units import PIX2, SPEC_PHOTON_FLUX_DENSITY_UNITS +from jdaviz.core.custom_units_and_equivs import PIX2, SPEC_PHOTON_FLUX_DENSITY_UNITS def cubeviz_wcs_dict(): diff --git a/jdaviz/configs/cubeviz/plugins/tests/test_parsers.py b/jdaviz/configs/cubeviz/plugins/tests/test_parsers.py index a729b8a322..ea28f68c86 100644 --- a/jdaviz/configs/cubeviz/plugins/tests/test_parsers.py +++ b/jdaviz/configs/cubeviz/plugins/tests/test_parsers.py @@ -6,7 +6,7 @@ from glue_astronomy.translators.spectrum1d import PaddedSpectrumWCS from numpy.testing import assert_allclose, assert_array_equal -from jdaviz.core.custom_units import PIX2 +from jdaviz.core.custom_units_and_equivs import PIX2 from jdaviz.utils import PRIHDR_KEY diff --git a/jdaviz/configs/default/plugins/data_quality/tests/test_data_quality.py b/jdaviz/configs/default/plugins/data_quality/tests/test_data_quality.py index 05d52c0e2e..66e8ad3a8d 100644 --- a/jdaviz/configs/default/plugins/data_quality/tests/test_data_quality.py +++ b/jdaviz/configs/default/plugins/data_quality/tests/test_data_quality.py @@ -134,7 +134,7 @@ def test_data_quality_plugin(imviz_helper, tmp_path): label_mouseover_text = label_mouseover.as_text()[0] # bad pixels with flag == 0 have flux == NaN - expected_flux_label = '+nan MJy/sr' + expected_flux_label = '+nan MJy / sr' assert expected_flux_label in label_mouseover_text # check that the decomposed DQ flag is at the end of the flux label's line: diff --git a/jdaviz/configs/default/plugins/line_lists/line_lists.py b/jdaviz/configs/default/plugins/line_lists/line_lists.py index dee8400526..81bad779a1 100644 --- a/jdaviz/configs/default/plugins/line_lists/line_lists.py +++ b/jdaviz/configs/default/plugins/line_lists/line_lists.py @@ -24,7 +24,7 @@ from jdaviz.core.registries import tray_registry from jdaviz.core.template_mixin import PluginTemplateMixin from jdaviz.core.tools import ICON_DIR -from jdaviz.core.validunits import create_spectral_equivalencies_list +from jdaviz.core.unit_conversion_utils import create_equivalent_spectral_axis_units_list __all__ = ['LineListTool'] @@ -216,7 +216,7 @@ 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( + self.custom_unit_choices = create_equivalent_spectral_axis_units_list( viewer_data.spectral_axis.unit) self.custom_unit = str(viewer_data.spectral_axis.unit) diff --git a/jdaviz/configs/default/plugins/markers/tests/test_markers_plugin.py b/jdaviz/configs/default/plugins/markers/tests/test_markers_plugin.py index dbb94db819..f98644bd44 100644 --- a/jdaviz/configs/default/plugins/markers/tests/test_markers_plugin.py +++ b/jdaviz/configs/default/plugins/markers/tests/test_markers_plugin.py @@ -1,8 +1,11 @@ import os +import astropy.units as u import numpy as np from numpy.testing import assert_allclose +import pytest +from jdaviz.core.custom_units_and_equivs import PIX2, SPEC_PHOTON_FLUX_DENSITY_UNITS from jdaviz.core.marks import MarkersMark from jdaviz.configs.imviz.tests.utils import BaseImviz_WCS_NoWCS @@ -178,6 +181,67 @@ def test_markers_cubeviz(tmp_path, cubeviz_helper, spectrum1d_cube): assert len(_get_markers_from_viewer(sv).x) == 0 +@pytest.mark.parametrize("flux_unit", [u.Unit(x) for x in SPEC_PHOTON_FLUX_DENSITY_UNITS]) +@pytest.mark.parametrize("angle_unit", [u.sr, PIX2]) +@pytest.mark.parametrize("new_flux_unit", [u.Unit(x) for x in SPEC_PHOTON_FLUX_DENSITY_UNITS]) +def test_markers_cubeviz_flux_unit_conversion(cubeviz_helper, + spectrum1d_cube_custom_fluxunit, + flux_unit, angle_unit, new_flux_unit): + """ + Test the markers plugin with all possible unit conversions for + cubes in spectral/photon surface brightness units (e.g. Jy/sr, Jy/pix2). + + The markers plugin should respect the choice of flux and angle + unit selected in the Unit Conversion plugin, and inputs and results should + be converted based on selection. All conversions between units in the + flux dropdown menu in the unit conversion plugin should be supported. + """ + + if new_flux_unit == flux_unit: # skip 'converting' to same unit + return + + new_flux_unit_str = new_flux_unit.to_string() + + # load cube with specified unit + cube = spectrum1d_cube_custom_fluxunit(fluxunit=flux_unit / angle_unit, + shape=(5, 5, 4), + with_uncerts=True) + cubeviz_helper.load_data(cube, data_label="test") + + # get plugins + mp = cubeviz_helper.plugins['Markers'] + uc = cubeviz_helper.plugins['Unit Conversion']._obj + + mp.keep_active = True + + fv = cubeviz_helper.app.get_viewer('flux-viewer') + label_mouseover = cubeviz_helper.app.session.application._tools['g-coords-info'] + label_mouseover._viewer_mouse_event(fv, + {'event': 'mousemove', + 'domain': {'x': 0, 'y': 0}}) + mp._obj._on_viewer_key_event(fv, {'event': 'keydown', + 'key': 'm'}) + + # set to new unit + uc.flux_unit.selected = new_flux_unit_str + new_cube_unit_str = (new_flux_unit / angle_unit).to_string() + + # add a new marker at the same location + label_mouseover._viewer_mouse_event(fv, + {'event': 'mousemove', + 'domain': {'x': 0, + 'y': 0}}) + # mouseover should have changed to new unit + assert label_mouseover.as_dict()['value:unit'] == new_cube_unit_str + + mp._obj._on_viewer_key_event(fv, {'event': 'keydown', + 'key': 'm'}) + + # make sure last marker added to table reflects new unit selection + last_row = mp.export_table()[-1] + assert last_row['value:unit'] == new_cube_unit_str + + class TestImvizMultiLayer(BaseImviz_WCS_NoWCS): def test_markers_layer_cycle(self): label_mouseover = self.imviz.app.session.application._tools['g-coords-info'] diff --git a/jdaviz/configs/default/plugins/model_fitting/model_fitting.py b/jdaviz/configs/default/plugins/model_fitting/model_fitting.py index a579ce0856..d1cc35d5c8 100644 --- a/jdaviz/configs/default/plugins/model_fitting/model_fitting.py +++ b/jdaviz/configs/default/plugins/model_fitting/model_fitting.py @@ -7,6 +7,10 @@ from specutils.utils import QuantityModel from traitlets import Bool, List, Unicode, observe +from jdaviz.configs.default.plugins.model_fitting.fitting_backend import fit_model_to_spectrum +from jdaviz.configs.default.plugins.model_fitting.initializers import (MODELS, + initialize, + get_model_parameters) from jdaviz.core.events import SnackbarMessage, GlobalDisplayUnitChanged from jdaviz.core.registries import tray_registry from jdaviz.core.template_mixin import (PluginTemplateMixin, @@ -21,11 +25,8 @@ with_spinner) from jdaviz.core.custom_traitlets import IntHandleEmpty from jdaviz.core.user_api import PluginUserApi -from jdaviz.configs.default.plugins.model_fitting.fitting_backend import fit_model_to_spectrum -from jdaviz.configs.default.plugins.model_fitting.initializers import (MODELS, - initialize, - get_model_parameters) -from jdaviz.utils import _eqv_flux_to_sb_pixel +from jdaviz.core.unit_conversion_utils import (all_flux_unit_conversion_equivs, + flux_conversion_general) __all__ = ['ModelFitting'] @@ -507,7 +508,17 @@ def _initialize_model_component(self, model_comp, comp_label, poly_order=None): # then the model parameter has default units. We want to pass # with jdaviz default units (based on x/y units) but need to # convert the default parameter unit to these units - initial_val = default_param.quantity.to(default_units) + if default_param.unit != default_units: + pixar_sr = self.app.data_collection[0].meta.get('PIXAR_SR', 1) + viewer = self.app.get_viewer("spectrum-viewer") + cube_wave = viewer.slice_value * u.Unit(self.app._get_display_unit('spectral')) + equivs = all_flux_unit_conversion_equivs(pixar_sr, cube_wave) + + initial_val = flux_conversion_general([default_param.value], + default_param.unit, + default_units, equivs) + else: + initial_val = default_param initial_values[param_name] = initial_val @@ -538,10 +549,15 @@ def _initialize_model_component(self, model_comp, comp_label, poly_order=None): init_x = masked_spectrum.spectral_axis init_y = masked_spectrum.flux - # equivs for spectral density and flux<>flux/pix2. revisit - # when generalizing plugin UC equivs. - equivs = _eqv_flux_to_sb_pixel() + u.spectral_density(init_x) - init_y = init_y.to(self._units['y'], equivs) + if init_y.unit != self._units['y']: + # equivs for spectral density and flux<>sb + pixar_sr = masked_spectrum.meta.get('_pixel_scale_factor', 1.0) + equivs = all_flux_unit_conversion_equivs(pixar_sr, init_x) + + init_y = flux_conversion_general([init_y.value], + init_y.unit, + self._units['y'], + equivs) initialized_model = initialize( MODELS[model_comp](name=comp_label, diff --git a/jdaviz/configs/default/plugins/model_fitting/tests/test_fitting.py b/jdaviz/configs/default/plugins/model_fitting/tests/test_fitting.py index c0aa37370b..f63577c14a 100644 --- a/jdaviz/configs/default/plugins/model_fitting/tests/test_fitting.py +++ b/jdaviz/configs/default/plugins/model_fitting/tests/test_fitting.py @@ -14,7 +14,7 @@ from jdaviz.configs.default.plugins.model_fitting import fitting_backend as fb from jdaviz.configs.default.plugins.model_fitting import initializers -from jdaviz.core.custom_units import PIX2 +from jdaviz.core.custom_units_and_equivs import PIX2 SPECTRUM_SIZE = 200 # length of spectrum diff --git a/jdaviz/configs/default/plugins/viewers.py b/jdaviz/configs/default/plugins/viewers.py index 4d67156505..0ba0b1b250 100644 --- a/jdaviz/configs/default/plugins/viewers.py +++ b/jdaviz/configs/default/plugins/viewers.py @@ -24,16 +24,16 @@ from jdaviz.components.toolbar_nested import NestedJupyterToolbar from jdaviz.configs.default.plugins.data_menu import DataMenu from jdaviz.core.astrowidgets_api import AstrowidgetsImageViewerMixin +from jdaviz.core.custom_units_and_equivs import _eqv_sb_per_pixel_to_per_angle from jdaviz.core.events import SnackbarMessage from jdaviz.core.freezable_state import FreezableProfileViewerState from jdaviz.core.marks import LineUncertainties, ScatterMask, OffscreenLinesMarks from jdaviz.core.registries import viewer_registry from jdaviz.core.template_mixin import WithCache from jdaviz.core.user_api import ViewerUserApi +from jdaviz.core.unit_conversion_utils import check_if_unit_is_per_solid_angle from jdaviz.utils import (ColorCycler, get_subset_type, _wcs_only_label, - layer_is_image_data, layer_is_not_dq, - _eqv_sb_per_pixel_to_per_angle) -from jdaviz.core.validunits import check_if_unit_is_per_solid_angle + layer_is_image_data, layer_is_not_dq) uc = UnitConverter() diff --git a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py index f62e011e56..7fe5b00a91 100644 --- a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py +++ b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py @@ -15,7 +15,7 @@ from traitlets import Any, Bool, Integer, List, Unicode, observe from jdaviz.core.custom_traitlets import FloatHandleEmpty -from jdaviz.core.custom_units import PIX2 +from jdaviz.core.custom_units_and_equivs import PIX2 from jdaviz.core.events import (GlobalDisplayUnitChanged, SnackbarMessage, LinkUpdatedMessage, SliceValueUpdatedMessage) from jdaviz.core.region_translators import regions2aperture, _get_region_from_spatial_subset @@ -23,8 +23,11 @@ from jdaviz.core.template_mixin import (PluginTemplateMixin, DatasetMultiSelectMixin, SubsetSelect, ApertureSubsetSelectMixin, TableMixin, PlotMixin, MultiselectMixin, with_spinner) -from jdaviz.core.validunits import check_if_unit_is_per_solid_angle -from jdaviz.utils import PRIHDR_KEY, _eqv_flux_to_sb_pixel, _eqv_pixar_sr +from jdaviz.core.unit_conversion_utils import (all_flux_unit_conversion_equivs, + check_if_unit_is_per_solid_angle, + flux_conversion_general, + handle_squared_flux_unit_conversions) +from jdaviz.utils import PRIHDR_KEY __all__ = ['SimpleAperturePhotometry'] @@ -218,8 +221,6 @@ def _on_display_units_changed(self, event={}): prev_unit = u.Unit(prev_display_unit) new_unit = u.Unit(self.display_unit) - bg = self.background_value * prev_unit - if self.multiselect: if len(self.dataset.selected) == 1: data = self.dataset.selected_dc_item[0] @@ -228,21 +229,34 @@ def _on_display_units_changed(self, event={}): else: data = self.dataset.selected_dc_item - with u.set_enabled_equivalencies( - u.spectral() + u.spectral_density(self._cube_wave) + - _eqv_flux_to_sb_pixel() + - _eqv_pixar_sr(data.meta.get('_pixel_scale_factor', 1) - if data else 1)): - self.background_value = bg.to_value(new_unit) + if prev_unit != new_unit: + + # NOTE: I don't think all of these equivalencies are necessary, + # but I'm keeping them since they were already here. Background + # should only be converted flux<>flux or sb<>sb so only a possible + # u.spectral_density would be needed. explore removing these as a follow up + pixar_sr = data.meta.get('_pixel_scale_factor', 1.0) if data else 1.0 + equivs = all_flux_unit_conversion_equivs(pixar_sr, + self._cube_wave) + u.spectral() + + self.background_value = flux_conversion_general(self.background_value, + prev_unit, + new_unit, + equivs, + with_unit=False) # convert flux scaling to new unit if self.flux_scaling is not None: + prev_unit = u.Unit(prev_flux_scale_unit) new_unit = u.Unit(self.flux_scaling_display_unit) - - fs = self.flux_scaling * prev_unit - self.flux_scaling = fs.to_value( - new_unit, u.spectral_density(self._cube_wave)) + if prev_unit != new_unit: + equivs = u.spectral_density(self._cube_wave) + self.flux_scaling = flux_conversion_general(self.flux_scaling, + prev_unit, + new_unit, + equivs, + with_unit=False) def _set_display_unit_of_selected_dataset(self): @@ -311,7 +325,11 @@ def _get_defaults_from_metadata(self, dataset=None): # if display unit is different, translate if (self.config == 'cubeviz') and (self.display_unit != ''): disp_unit = u.Unit(self.display_unit) - mjy2abmag = (mjy2abmag * u.Unit("MJy/sr")).to_value(disp_unit) + mjy2abmag = flux_conversion_general(mjy2abmag, + u.MJy / u.sr, + disp_unit, + u.spectral_density(self._cube_wave), + with_unit=False) if 'photometry' in meta and 'pixelarea_arcsecsq' in meta['photometry']: defaults['pixel_area'] = meta['photometry']['pixelarea_arcsecsq'] @@ -493,11 +511,14 @@ def _calc_background_median(self, reg, data=None): # photutils/background/_utils.py --> nanmedian() bg_md = np.nanmedian(img_stat) # Naturally in data unit - # convert to display unit, if necessary (cubeviz only) - + # convert background median to display unit, if necessary (cubeviz only) if (self.config == 'cubeviz') and (self.display_unit != '') and comp.units: - bg_md = (bg_md * u.Unit(comp.units)).to_value( - u.Unit(self.display_unit), u.spectral_density(self._cube_wave)) + + bg_md = flux_conversion_general(bg_md, + u.Unit(comp.units), + u.Unit(self.display_unit), + u.spectral_density(self._cube_wave), + with_unit=False) return bg_md @@ -559,6 +580,7 @@ def calculate_photometry(self, dataset=None, aperture=None, background=None, ------- table row, fit results """ + if self.multiselect and (dataset is None or aperture is None): # pragma: no cover raise ValueError("for batch mode, use calculate_batch_photometry") @@ -616,9 +638,11 @@ def calculate_photometry(self, dataset=None, aperture=None, background=None, # cubeviz: background_value set in plugin is in display units # convert temporarily to image units for calculations if (self.config == 'cubeviz') and (img_unit is not None) and display_unit != '': - background_value = (background_value * display_unit).to_value( - img_unit, u.spectral_density(self._cube_wave)) - + background_value = flux_conversion_general(background_value, + display_unit, + img_unit, + u.spectral_density(self._cube_wave), + with_unit=False) elif background is None and dataset is None: # use the previously-computed value in the plugin @@ -627,8 +651,11 @@ def calculate_photometry(self, dataset=None, aperture=None, background=None, # cubeviz: background_value set in plugin is in display units # convert temporarily to image units for calculations if (self.config == 'cubeviz') and (img_unit is not None) and display_unit != '': - background_value = (background_value * display_unit).to_value( - img_unit, u.spectral_density(self._cube_wave)) + background_value = flux_conversion_general(background_value, + display_unit, + img_unit, + u.spectral_density(self._cube_wave), + with_unit=False) else: bg_reg = self.aperture._get_spatial_region(subset=background if background is not None else self.background.selected, # noqa dataset=dataset if dataset is not None else self.dataset.selected) # noqa @@ -637,8 +664,11 @@ def calculate_photometry(self, dataset=None, aperture=None, background=None, # cubeviz: computed background median will be in display units, # convert temporarily back to image units for calculations if (self.config == 'cubeviz') and (img_unit is not None) and display_unit != '': - background_value = (background_value * display_unit).to_value( - img_unit, u.spectral_density(self._cube_wave)) + background_value = flux_conversion_general(background_value, + display_unit, + img_unit, + u.spectral_density(self._cube_wave), + with_unit=False) try: bg = float(background_value) except ValueError: # Clearer error message @@ -712,9 +742,11 @@ def calculate_photometry(self, dataset=None, aperture=None, background=None, (self.flux_scaling is not None)): # convert flux_scaling from flux display unit to native flux unit - flux_scaling = (self.flux_scaling * u.Unit(self.flux_scaling_display_unit)).to_value( # noqa: E501 - img_unit * self.display_solid_angle_unit, - u.spectral_density(self._cube_wave)) + flux_scaling = flux_conversion_general(self.flux_scaling, + u.Unit(self.flux_scaling_display_unit), + img_unit * self.display_solid_angle_unit, + u.spectral_density(self._cube_wave), + with_unit=False) try: flux_scale = float(flux_scaling if flux_scaling is not None else self.flux_scaling) @@ -755,6 +787,8 @@ def calculate_photometry(self, dataset=None, aperture=None, background=None, else: pixarea = pixarea * (u.arcsec * u.arcsec / PIX2) # NOTE: Sum already has npix value encoded, so we simply apply the npix unit here. + # don't need to go though flux_conversion_general since these units + # arent per-pixel and won't need a workaround. pixarea_fac = PIX2 * pixarea.to(display_solid_angle_unit / PIX2) phot_table['sum'] = [rawsum * pixarea_fac] @@ -796,31 +830,56 @@ def calculate_photometry(self, dataset=None, aperture=None, background=None, phot_table.add_column(slice_val, name="slice_wave", index=29) - if comp.units: # convert phot. results from image unit to display unit + if comp.units: + + # convert units of output table to reflect display units + # selected in Unit Conversion plugin display_unit = u.Unit(self.display_unit) - # convert units of certain columns in aperture phot. output table - # to reflect display units (i.e if data units are MJy / sr, but - # Jy / sr is selected in Unit Conversion plugin) - if display_unit != '': - phot_table['background'] = phot_table['background'].to( - display_unit, u.spectral_density(self._cube_wave)) + # equivalencies for unit conversion, will never be flux<>sb + # so only need spectral_density + equivs = u.spectral_density(self._cube_wave) + + if display_unit != '': + if phot_table['background'].unit != display_unit: + bg_conv = flux_conversion_general(phot_table['background'].value, + phot_table['background'].unit, + display_unit, + equivs) + phot_table['background'] = bg_conv + + phot_sum = phot_table['sum'] if include_pixarea_fac: - phot_table['sum'] = phot_table['sum'].to( - (display_unit * pixarea_fac).unit, u.spectral_density(self._cube_wave)) - else: - phot_table['sum'] = phot_table['sum'].to( - display_unit, u.spectral_density(self._cube_wave)) + if phot_sum.unit != (display_unit * pixarea_fac).unit: + phot_table['sum'] = flux_conversion_general(phot_sum.value, + phot_sum.unit, + (display_unit * pixarea_fac).unit, # noqa: E501 + equivs) + + elif phot_sum.unit != display_unit: + phot_table['sum'] = flux_conversion_general(phot_sum.value, + phot_sum.unit, + display_unit, + equivs) + for key in ['min', 'max', 'mean', 'median', 'mode', 'std', 'mad_std', 'biweight_location']: - phot_table[key] = phot_table[key].to( - display_unit, u.spectral_density(self._cube_wave)) + if phot_table[key].unit != display_unit: + phot_table[key] = flux_conversion_general(phot_table[key].value, + phot_table[key].unit, + display_unit, + equivs) + for key in ['var', 'biweight_midvariance']: - try: - phot_table[key] = phot_table[key].to(display_unit**2) - # FIXME: Can fail going between per-wave and per-freq - except u.UnitConversionError: - pass + # these values will be in units of flux or surface brightness + # squared, so unit conversion is another special case if additional + # equivalencies are required + if phot_table[key].unit != display_unit**2: + conv = handle_squared_flux_unit_conversions(phot_table[key].value, + phot_table[key].unit, + display_unit**2, + equivs) + phot_table[key] = conv if add_to_table: try: @@ -1186,6 +1245,11 @@ def _radial_profile(radial_cutout, reg_bb, centroid, raw=False, (For cubeviz only to deal with display unit conversion). Desired unit for output. + equivalencies : list or None + Optional, equivalencies for unit conversion to convert radial profile + to display unit selected in the unit conversion plugin, if it differs + from the native data unit. + """ reg_ogrid = np.ogrid[reg_bb.iymin:reg_bb.iymax, reg_bb.ixmin:reg_bb.ixmax] radial_dx = reg_ogrid[1] - centroid[0] @@ -1213,7 +1277,14 @@ def _radial_profile(radial_cutout, reg_bb, centroid, raw=False, if display_unit is not None: if image_unit is None: raise ValueError('Must provide image_unit with display_unit.') - y_arr = (y_arr * u.Unit(image_unit)).to_value(u.Unit(display_unit), equivalencies) + + # convert array from native data unit to display unit, if they differ + if image_unit != display_unit: + y_arr = flux_conversion_general(y_arr, + u.Unit(image_unit), + u.Unit(display_unit), + equivalencies=equivalencies, + with_unit=False) return x_arr, y_arr @@ -1322,13 +1393,16 @@ def _curve_of_growth(data, centroid, aperture, final_sum, wcs=None, background=0 if pixarea_fac is not None: sum_arr = sum_arr * pixarea_fac if isinstance(final_sum, u.Quantity): - final_sum = final_sum.to(sum_arr.unit, equivalencies) + final_sum = flux_conversion_general(final_sum.value, final_sum.unit, + sum_arr.unit, equivalencies) sum_arr = np.append(sum_arr, final_sum) if sum_unit is None: y_label = 'Value' else: y_label = sum_unit.to_string() - sum_arr = sum_arr.to_value(sum_unit, equivalencies) # bqplot does not like Quantity + # bqplot does not like Quantity + sum_arr = flux_conversion_general(sum_arr.value, sum_arr.unit, sum_unit, + equivalencies, with_unit=False) return x_arr, sum_arr, x_label, y_label diff --git a/jdaviz/configs/imviz/plugins/coords_info/coords_info.py b/jdaviz/configs/imviz/plugins/coords_info/coords_info.py index 1958201a10..bec01c238c 100644 --- a/jdaviz/configs/imviz/plugins/coords_info/coords_info.py +++ b/jdaviz/configs/imviz/plugins/coords_info/coords_info.py @@ -13,14 +13,15 @@ MosvizProfile2DView) from jdaviz.configs.rampviz.plugins.viewers import RampvizImageView, RampvizProfileView from jdaviz.configs.specviz.plugins.viewers import SpecvizProfileView -from jdaviz.core.custom_units import PIX2 +from jdaviz.core.custom_units_and_equivs import PIX2 from jdaviz.core.events import ViewerAddedMessage, GlobalDisplayUnitChanged from jdaviz.core.helpers import data_has_valid_wcs from jdaviz.core.marks import PluginScatter, PluginLine from jdaviz.core.registries import tool_registry from jdaviz.core.template_mixin import TemplateMixin, DatasetSelectMixin -from jdaviz.core.validunits import check_if_unit_is_per_solid_angle -from jdaviz.utils import flux_conversion, _eqv_pixar_sr +from jdaviz.core.unit_conversion_utils import (all_flux_unit_conversion_equivs, + check_if_unit_is_per_solid_angle, + flux_conversion_general) __all__ = ['CoordsInfo'] @@ -487,44 +488,44 @@ def _image_viewer_update(self, viewer, x, y): dq_attribute = associated_dq_layer.state.attribute dq_data = associated_dq_layer.layer.get_data(dq_attribute) dq_value = dq_data[int(round(y)), int(round(x))] - unit = image.get_component(attribute).units + unit = u.Unit(image.get_component(attribute).units) elif isinstance(viewer, (CubevizImageView, RampvizImageView)): - skip_spectral_density_eqv = False arr = image.get_component(attribute).data - unit = image.get_component(attribute).units + unit = u.Unit(image.get_component(attribute).units) value = self._get_cube_value( image, arr, x, y, viewer ) - # We don't want to convert for things like moment maps, so check physical type - # If unit is flux per pix2, the type will be 'unknown' rather - # than surface brightness, so have to multiply the pix2 part out - # and check if the numerator is a spectral flux density + # We don't want to convert for things like moment maps, so check + # physical type If unit is flux per pix2, the type will be + # 'unknown' rather than surface brightness, so multiply out pix2 + # and check if the numerator is a spectral/photon flux density if check_if_unit_is_per_solid_angle(unit, return_unit=True) == PIX2: - un = u.Unit(unit) * PIX2 - physical_type = un.physical_type + physical_type = (unit * PIX2).physical_type else: - physical_type = u.Unit(unit).physical_type + physical_type = unit.physical_type - if str(physical_type) not in ("spectral flux density", - "surface brightness"): - skip_spectral_density_eqv = True + valid_physical_types = ["spectral flux density", + "surface brightness", + "surface brightness wav", + "photon surface brightness wav", + "photon surface brightness", + "power density/spectral flux density wav", + "photon flux density wav", + "photon flux density"] - if self.image_unit is not None and not skip_spectral_density_eqv: - if 'PIXAR_SR' in self.app.data_collection[0].meta: - # Need current slice value and associated unit to use to compute - # spectral density equivalencies that enable Flux to Flux conversions. - # This is needed for units that are not directly convertible/translatable. - slice = viewer.slice_value * u.Unit(self.app._get_display_unit('spectral')) + if str(physical_type) in valid_physical_types and self.image_unit is not None: - value = flux_conversion(value, unit, self.image_unit, - eqv=_eqv_pixar_sr(self.app.data_collection[0].meta['PIXAR_SR']), # noqa: E501 - slice=slice) - unit = self.image_unit + # Create list of potentially needed equivalencies for flux/sb unit conversions + pixar_sr = self.app.data_collection[0].meta.get('PIXAR_SR', 1) + cube_wave = viewer.slice_value * u.Unit(self.app._get_display_unit('spectral')) - elif self.image_unit.is_equivalent(unit): - value = (value * u.Unit(unit)).to_value(u.Unit(self.image_unit)) - unit = self.image_unit + equivalencies = all_flux_unit_conversion_equivs(pixar_sr, + cube_wave) + + value = flux_conversion_general(value, unit, u.Unit(self.image_unit), + equivalencies, with_unit=False) + unit = self.image_unit if associated_dq_layers is not None: associated_dq_layer = associated_dq_layers[0] @@ -627,11 +628,18 @@ 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: - disp_flux = flux_conversion(sp.flux.value, sp.flux.unit, viewer.state.y_display_unit, spec=sp) # noqa: E501 + # Create list of potentially needed equivalencies for flux/sb unit conversions + pixar_sr = self.app.data_collection[0].meta.get('PIXAR_SR', 1) + equivalencies = all_flux_unit_conversion_equivs(pixar_sr, + sp.spectral_axis) + + if sp.flux.unit is not None and viewer.state.y_display_unit is not None: + disp_flux = flux_conversion_general(sp.flux.value, + sp.flux.unit, + viewer.state.y_display_unit, + equivalencies, with_unit=False) # noqa: E501 else: - disp_flux = sp.flux.to_value(viewer.state.y_display_unit, - u.spectral_density(sp.spectral_axis)) + disp_flux = sp.flux # Out of range in spectral axis. if (self.dataset.selected != lyr.layer.label and diff --git a/jdaviz/configs/imviz/tests/test_parser.py b/jdaviz/configs/imviz/tests/test_parser.py index d84a24b8e3..9289d83c03 100644 --- a/jdaviz/configs/imviz/tests/test_parser.py +++ b/jdaviz/configs/imviz/tests/test_parser.py @@ -16,7 +16,7 @@ from jdaviz.configs.imviz.helper import split_filename_with_fits_ext from jdaviz.configs.imviz.plugins.parsers import ( parse_data, _validate_fits_image2d, _validate_bunit, _parse_image, HAS_ROMAN_DATAMODELS) -from jdaviz.core.custom_units import PIX2 +from jdaviz.core.custom_units_and_equivs import PIX2 @pytest.mark.parametrize( diff --git a/jdaviz/configs/imviz/tests/test_simple_aper_phot.py b/jdaviz/configs/imviz/tests/test_simple_aper_phot.py index 81468d7923..5cbce851f3 100644 --- a/jdaviz/configs/imviz/tests/test_simple_aper_phot.py +++ b/jdaviz/configs/imviz/tests/test_simple_aper_phot.py @@ -16,7 +16,7 @@ from jdaviz.configs.imviz.plugins.aper_phot_simple.aper_phot_simple import ( _curve_of_growth, _radial_profile) from jdaviz.configs.imviz.tests.utils import BaseImviz_WCS_WCS, BaseImviz_WCS_NoWCS -from jdaviz.core.custom_units import PIX2 +from jdaviz.core.custom_units_and_equivs import PIX2 from jdaviz.tests.test_utils import PHOTUTILS_LT_1_12_1 diff --git a/jdaviz/configs/specviz/plugins/line_analysis/line_analysis.py b/jdaviz/configs/specviz/plugins/line_analysis/line_analysis.py index b7810c877a..e986f1969a 100644 --- a/jdaviz/configs/specviz/plugins/line_analysis/line_analysis.py +++ b/jdaviz/configs/specviz/plugins/line_analysis/line_analysis.py @@ -25,7 +25,7 @@ with_spinner) from jdaviz.core.user_api import PluginUserApi from jdaviz.core.tools import ICON_DIR -from jdaviz.core.validunits import check_if_unit_is_per_solid_angle +from jdaviz.core.unit_conversion_utils import check_if_unit_is_per_solid_angle __all__ = ['LineAnalysis'] diff --git a/jdaviz/configs/specviz/plugins/line_analysis/tests/test_line_analysis.py b/jdaviz/configs/specviz/plugins/line_analysis/tests/test_line_analysis.py index 3dc7e1f059..6b077a14ca 100644 --- a/jdaviz/configs/specviz/plugins/line_analysis/tests/test_line_analysis.py +++ b/jdaviz/configs/specviz/plugins/line_analysis/tests/test_line_analysis.py @@ -8,7 +8,7 @@ from specutils import Spectrum1D, SpectralRegion from jdaviz.configs.specviz.plugins.line_analysis.line_analysis import _coerce_unit -from jdaviz.core.custom_units import PIX2 +from jdaviz.core.custom_units_and_equivs import PIX2 from jdaviz.core.events import LineIdentifyMessage from jdaviz.core.marks import LineAnalysisContinuum 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 1fff4d838d..9dbc4dd800 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 @@ -4,7 +4,7 @@ from astropy.nddata import InverseVariance from specutils import Spectrum1D -from jdaviz.core.custom_units import SPEC_PHOTON_FLUX_DENSITY_UNITS +from jdaviz.core.custom_units_and_equivs import SPEC_PHOTON_FLUX_DENSITY_UNITS # On failure, should not crash; essentially a no-op. diff --git a/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.py b/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.py index 779bc2bebb..f54a0655fb 100644 --- a/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.py +++ b/jdaviz/configs/specviz/plugins/unit_conversion/unit_conversion.py @@ -8,16 +8,16 @@ from traitlets import List, Unicode, observe, Bool from jdaviz.configs.default.plugins.viewers import JdavizProfileView +from jdaviz.core.custom_units_and_equivs import _eqv_flux_to_sb_pixel, _eqv_pixar_sr from jdaviz.core.events import GlobalDisplayUnitChanged, AddDataMessage, SliceValueUpdatedMessage from jdaviz.core.registries import tray_registry from jdaviz.core.template_mixin import (PluginTemplateMixin, UnitSelectPluginComponent, SelectPluginComponent, PluginUserApi) -from jdaviz.core.validunits import (create_spectral_equivalencies_list, - create_flux_equivalencies_list, - check_if_unit_is_per_solid_angle, - create_angle_equivalencies_list, - supported_sq_angle_units) -from jdaviz.utils import _eqv_flux_to_sb_pixel, _eqv_pixar_sr +from jdaviz.core.unit_conversion_utils import (create_equivalent_spectral_axis_units_list, + create_equivalent_flux_units_list, + check_if_unit_is_per_solid_angle, + create_equivalent_angle_units_list, + supported_sq_angle_units) __all__ = ['UnitConversion'] @@ -41,12 +41,10 @@ def _valid_glue_display_unit(unit_str, sv, axis='x'): def _flux_to_sb_unit(flux_unit, angle_unit): if angle_unit not in supported_sq_angle_units(as_strings=True): sb_unit = flux_unit - elif '(' in flux_unit: - pos = flux_unit.rfind(')') - sb_unit = flux_unit[:pos] + ' ' + angle_unit + flux_unit[pos:] else: - # append angle if there are no parentheses - sb_unit = flux_unit + ' / ' + angle_unit + # str > unit > str to remove formatting inconsistencies with + # parentheses/order of units/etc + sb_unit = (u.Unit(flux_unit) / u.Unit(angle_unit)).to_string() return sb_unit @@ -133,7 +131,7 @@ def __init__(self, *args, **kwargs): self.spectral_unit = UnitSelectPluginComponent(self, items='spectral_unit_items', selected='spectral_unit_selected') - self.spectral_unit.choices = create_spectral_equivalencies_list(u.Hz) + self.spectral_unit.choices = create_equivalent_spectral_axis_units_list(u.Hz) self.has_flux = self.config in ('specviz', 'cubeviz', 'specviz2d', 'mosviz') self.flux_unit = UnitSelectPluginComponent(self, @@ -228,14 +226,14 @@ def _on_add_data_to_viewer(self, msg): flux_unit = data_obj.flux.unit if angle_unit is None else data_obj.flux.unit * angle_unit # noqa if not self.flux_unit_selected: - self.flux_unit.choices = create_flux_equivalencies_list(flux_unit) + self.flux_unit.choices = create_equivalent_flux_units_list(flux_unit) try: self.flux_unit.selected = str(flux_unit) except ValueError: self.flux_unit.selected = '' if not self.angle_unit_selected: - self.angle_unit.choices = create_angle_equivalencies_list(angle_unit) + self.angle_unit.choices = create_equivalent_angle_units_list(angle_unit) try: if angle_unit is None: diff --git a/jdaviz/configs/specviz2d/tests/test_parsers.py b/jdaviz/configs/specviz2d/tests/test_parsers.py index 30a9840d12..2282a54431 100644 --- a/jdaviz/configs/specviz2d/tests/test_parsers.py +++ b/jdaviz/configs/specviz2d/tests/test_parsers.py @@ -91,6 +91,7 @@ def test_2d_parser_no_unit(specviz2d_helper, mos_spectrum2d): assert label_mouseover.as_text() == ('Cursor 7.20000e-06, 3.00000e+00', 'Wave 7.00000e-06 m (6 pix)', 'Flux -3.59571e+00') + assert label_mouseover.icon == 'b' diff --git a/jdaviz/core/custom_units.py b/jdaviz/core/custom_units.py deleted file mode 100644 index 7b3b6acba9..0000000000 --- a/jdaviz/core/custom_units.py +++ /dev/null @@ -1,24 +0,0 @@ -import astropy.units as u - -__all__ = ["PIX2", "SPEC_PHOTON_FLUX_DENSITY_UNITS"] - -# define custom composite units here -PIX2 = u.pix * u.pix - - -def _spectral_and_photon_flux_density_units(): - """ - This function returns an alphabetically sorted list of string representations - of spectral and photon flux density units. This list represents flux units - that the unit conversion plugin supports conversion to and from if the input - data unit is compatible with items in the list (i.e is equivalent directly - or with u.spectral_density(cube_wave)). - """ - flux_units = ['Jy', 'mJy', 'uJy', 'MJy', 'W / (Hz m2)', 'eV / (Hz s m2)', - 'erg / (Hz s cm2)', 'erg / (Angstrom s cm2)', - 'ph / (Angstrom s cm2)', 'ph / (Hz s cm2)'] - - return sorted(flux_units) - - -SPEC_PHOTON_FLUX_DENSITY_UNITS = _spectral_and_photon_flux_density_units() diff --git a/jdaviz/core/custom_units_and_equivs.py b/jdaviz/core/custom_units_and_equivs.py new file mode 100644 index 0000000000..3a5ba5405d --- /dev/null +++ b/jdaviz/core/custom_units_and_equivs.py @@ -0,0 +1,123 @@ +import astropy.units as u + +__all__ = ["PIX2", "SPEC_PHOTON_FLUX_DENSITY_UNITS", + "_eqv_pixar_sr", "_eqv_flux_to_sb_pixel", + "_eqv_sb_per_pixel_to_per_angle"] + +# define custom composite units here +PIX2 = u.pix * u.pix + + +def _spectral_and_photon_flux_density_units(freq_only=False, wav_only=False, + as_units=False): + """ + Returns an alphabetically sorted list of string representations + of flux density units supported by the unit conversion plugin. These units can + be used for conversion if the input data units are compatible (i.e., directly + equivalent or convertible using ``u.spectral_density(cube_wave)``). If both + ``freq_only`` and ``wav_only`` are False, the function returns the combined + list of all supported units. If ``as_units`` is True, return ``u.Unit`` instead + of strings. + + Parameters: + ----------- + freq_only : bool, optional + If True, returns only frequency-based flux density units. (Default=False). + + wav_only : bool, optional + If True, returns only wavelength-based flux density units. (Default=False). + + as_units : bool, optional + If True, return ``u.Unit`` instead of strings + + Returns: + -------- + list of str + A sorted list of flux density unit strings. + """ + wav_flux_units = ['erg / (Angstrom s cm2)', 'ph / (Angstrom s cm2)'] + freq_flux_units = ['Jy', 'mJy', 'uJy', 'MJy', 'W / (Hz m2)', 'eV / (Hz s m2)', + 'erg / (Hz s cm2)', 'ph / (Hz s cm2)'] + + if freq_only: + flux_units = sorted(freq_flux_units) + elif wav_only: + flux_units = sorted(wav_flux_units) + else: + flux_units = sorted(freq_flux_units + wav_flux_units) + + if as_units: + flux_units = [u.Unit(x) for x in flux_units] + + return flux_units + + +SPEC_PHOTON_FLUX_DENSITY_UNITS = _spectral_and_photon_flux_density_units() + + +def _eqv_pixar_sr(pixar_sr): + """ + Return Equivalencies to convert from flux to flux per solid + angle (aka surface brightness) using scale ratio ``pixar_sr`` + (steradians per pixel). + """ + 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), + (u.erg / (u.s * u.cm**2 * u.Angstrom * u.sr), u.erg / (u.s * u.cm**2 * u.Angstrom), converter_flux, iconverter_flux), # noqa + (u.ph / (u.Angstrom * u.s * u.cm**2 * u.sr), u.ph / (u.Angstrom * u.s * u.cm**2), converter_flux, iconverter_flux), # noqa + (u.ph / (u.Hz * u.s * u.cm**2 * u.sr), u.ph / (u.Hz * u.s * u.cm**2), converter_flux, iconverter_flux), # noqa + (u.ct / u.sr, u.ct, converter_flux, iconverter_flux) # noqa + ] + + +def _eqv_flux_to_sb_pixel(): + """ + Returns an Equivalency between ``flux_unit`` and ``flux_unit`/pix**2``. This + allows conversion between flux and flux-per-square-pixel surface brightness + e.g MJy <> MJy / pix2 + """ + + # generate an equivalency for each flux type that would need + # another equivalency for converting to/from + flux_units = [u.MJy, + u.erg / (u.s * u.cm**2 * u.Angstrom), + u.ph / (u.Angstrom * u.s * u.cm**2), + u.ph / (u.Hz * u.s * u.cm**2), + u.ct, + u.DN, + u.DN / u.s] + return [(flux_unit, flux_unit / PIX2, lambda x: x, lambda x: x) + for flux_unit in flux_units] + + +def _eqv_sb_per_pixel_to_per_angle(flux_unit, scale_factor=1): + """ + Returns an equivalency between ``flux_unit`` per square pixel and + ``flux_unit`` per solid angle to be able to compare and convert between units + like Jy/pix**2 and Jy/sr. The scale factor is assumed to be in steradians, + to follow the convention of the PIXAR_SR keyword. + Note: + To allow conversions between units like ``ph / (Hz s cm2 sr)`` and + MJy / pix2, which would require this equivalency as well as u.spectral_density, + these CAN'T be combined when converting like: + equivalencies=u.spectral_density(1 * u.m) + _eqv_sb_per_pixel_to_per_angle(u.Jy) + So additional logic is needed to compare units that need both equivalencies + (one solution being creating this equivalency for each equivalent flux-type.) + + """ + + # the two types of units we want to define a conversion between + flux_solid_ang = flux_unit / u.sr + flux_sq_pix = flux_unit / PIX2 + + pix_to_solid_angle_equiv = [(flux_solid_ang, flux_sq_pix, + lambda x: x * scale_factor, + lambda x: x / scale_factor)] + + return pix_to_solid_angle_equiv diff --git a/jdaviz/core/marks.py b/jdaviz/core/marks.py index f86100f111..870da67412 100644 --- a/jdaviz/core/marks.py +++ b/jdaviz/core/marks.py @@ -5,12 +5,14 @@ from bqplot.marks import Lines, Label, Scatter from glue.core import HubListener from specutils import Spectrum1D -from jdaviz.utils import _eqv_pixar_sr, _eqv_flux_to_sb_pixel from jdaviz.core.events import GlobalDisplayUnitChanged from jdaviz.core.events import (SliceToolStateMessage, LineIdentifyMessage, SpectralMarksChangedMessage, RedshiftMessage) +from jdaviz.core.unit_conversion_utils import (all_flux_unit_conversion_equivs, + flux_conversion_general) + __all__ = ['OffscreenLinesMarks', 'BaseSpectrumVerticalLine', 'SpectralLine', 'SliceIndicatorMarks', 'ShadowMixin', 'ShadowLine', 'ShadowLabelFixedY', @@ -36,11 +38,13 @@ def __init__(self, viewer): handler=self._update_counts) self.left = Label(text=[''], x=[0.02], y=[0.8], - scales={'x': LinearScale(min=0, max=1), 'y': LinearScale(min=0, max=1)}, + scales={'x': LinearScale(min=0, max=1), + 'y': LinearScale(min=0, max=1)}, colors=['gray'], default_size=12, align='start') self.right = Label(text=[''], x=[0.98], y=[0.8], - scales={'x': LinearScale(min=0, max=1), 'y': LinearScale(min=0, max=1)}, + scales={'x': LinearScale(min=0, max=1), + 'y': LinearScale(min=0, max=1)}, colors=['gray'], default_size=12, align='end') @@ -127,21 +131,16 @@ def set_y_unit(self, unit=None): if self.yunit is not None and not np.all([s == 0 for s in self.y.shape]): if self.viewer.default_class is Spectrum1D: - # used to obtain spectral density equivalencies with previous data and units - eqv = u.spectral_density(self.x*self.xunit) spec = self.viewer.state.reference_data.get_object(cls=Spectrum1D) - if ('_pixel_scale_factor' in spec.meta): - eqv += _eqv_pixar_sr(spec.meta['_pixel_scale_factor']) + pixar_sr = spec.meta.get('PIXAR_SR', 1) + cube_wave = self.x * self.xunit + equivs = all_flux_unit_conversion_equivs(pixar_sr, cube_wave) - # add equiv for flux <> flux/pix2 - eqv += _eqv_flux_to_sb_pixel() + y = flux_conversion_general(self.y, self.yunit, unit, equivs, + with_unit=False) - y = (self.y * self.yunit).to_value(unit, equivalencies=eqv) - else: - y = (self.y * self.yunit).to_value(unit) - self.yunit = unit self.y = y self.yunit = unit @@ -157,12 +156,6 @@ def _on_global_display_unit_changed(self, msg): return axis = axis_map.get(msg.axis, None) if axis is not None: - scale = self.scales.get(axis, None) - # if PluginMark mark is LinearScale(0, 1), prevent it from entering unit conversion - # machinery so it maintains it's position in viewer. - if isinstance(scale, LinearScale) and (scale.min, scale.max) == (0, 1): - return - getattr(self, f'set_{axis}_unit')(msg.unit) def clear(self): diff --git a/jdaviz/core/tests/test_unit_conversion_utils.py b/jdaviz/core/tests/test_unit_conversion_utils.py new file mode 100644 index 0000000000..bf5d6ef16c --- /dev/null +++ b/jdaviz/core/tests/test_unit_conversion_utils.py @@ -0,0 +1,97 @@ +import astropy.units as u +from itertools import combinations +import numpy as np +import pytest + +from jdaviz.core.custom_units_and_equivs import PIX2, SPEC_PHOTON_FLUX_DENSITY_UNITS +from jdaviz.core.unit_conversion_utils import (all_flux_unit_conversion_equivs, + check_if_unit_is_per_solid_angle, + combine_flux_and_angle_units, + flux_conversion_general, + handle_squared_flux_unit_conversions) + + +@pytest.mark.parametrize("unit, is_solid_angle", [ + ('erg / sr', True), ('erg / (deg * deg)', True), ('erg / (deg * arcsec)', True), + ('erg / (deg**2)', True), ('erg deg**-2', True), ('erg sr^-1', True), + ('erg / degree', False), ('erg sr^-2', False)]) +def test_check_if_unit_is_per_solid_angle(unit, is_solid_angle): + # test function that tests if a unit string or u.Unit is per solid angle + assert check_if_unit_is_per_solid_angle(unit) == is_solid_angle + + # turn string into u.Unit, and make sure it also passes + assert check_if_unit_is_per_solid_angle(u.Unit(unit)) == is_solid_angle + + +def test_general_flux_conversion(): + """ + This test function tests that the `flux_conversion_general` function is able + to convert between all available flux units in the unit conversion plugin + for data in spectral/photon flux density (e.g Jy, erg/s/cm2/A) or surface + brightness. `flux_conversion_general` handles special cases where flux and + surface brightness can't be converted directly, so it is used + in place of astropy.units.to() across the application to handle flux/sb unit + conversions. This test ensures that all advertised conversions are supported + and correct. + + Also tests the `handle_squared_flux_unit_conversions` function, which is used + to convert units for the aperture photometry table. + """ + + # required equivalencies for all flux<>flux, flux<>sb, sb<>sb conversions + equivalencies = all_flux_unit_conversion_equivs(pixar_sr=4, cube_wave=1*u.nm) + + # first test all conversions between flux units and surface brightness units + # (per steradian). (just test that it returns without error) + sr_sbs = combine_flux_and_angle_units(SPEC_PHOTON_FLUX_DENSITY_UNITS, u.sr) + all_convertable_units_sr = SPEC_PHOTON_FLUX_DENSITY_UNITS + sr_sbs + for combo in combinations(all_convertable_units_sr, 2): + original_unit, target_unit = (u.Unit(x) for x in combo) + converted = flux_conversion_general([1, 2, 3], original_unit, + target_unit, equivalencies) + assert len(converted) == 3 + + # next test all conversions between flux and surface brightness units (per + # square pixel), omitting the flux<>flux conversions already covered above + pix_sbs = combine_flux_and_angle_units(SPEC_PHOTON_FLUX_DENSITY_UNITS, PIX2) + all_convertable_units_pix = SPEC_PHOTON_FLUX_DENSITY_UNITS + pix_sbs + all_convertable_units_pix = set(all_convertable_units_pix) - set(all_convertable_units_sr) + for combo in combinations(all_convertable_units_pix, 2): + original_unit, target_unit = (u.Unit(x) for x in combo) + converted = flux_conversion_general([1, 2, 3], original_unit, + target_unit, equivalencies) + assert len(converted) == 3 + + # test that a unit combination passed in without the correct equivalency + # raises the correct error + msg = 'Could not convert Jy / pix2 to Jy / sr with provided equivalencies.' + with pytest.raises(u.UnitConversionError, match=msg): + converted = flux_conversion_general([1, 2, 3], u.Jy / PIX2, u.Jy / u.sr) + + # and finally, numerically verify a subset of possible unit conversion combos + # a case of each 'type' of conversion is covered here + units_and_expected = [(u.Jy / u.sr, u.MJy, 4.0e-6), + (u.Jy, u.MJy, 1.0e-6), + (u.Jy, u.MJy / u.sr, 2.5e-7), + (u.Jy, u.MJy / PIX2, 1.0e-6), + (u.Jy / PIX2, u.MJy / PIX2, 1.0e-6), + (u.MJy, u.erg / (u.s * u.cm**2 * u.AA), 0.29979246), + (u.MJy / PIX2, u.erg / (u.s * u.cm**2 * u.AA), 0.29979246), + (u.MJy, u.erg / (u.s * u.cm**2 * u.AA * PIX2), 0.29979246), + (u.MJy / PIX2, u.erg / (u.s * u.cm**2 * u.AA * PIX2), 0.29979246), + (u.MJy / u.sr, u.erg / (u.s * u.cm**2 * u.AA * u.sr), 0.29979246), + (u.MJy / u.sr, u.erg / (u.s * u.cm**2 * u.AA), 1.1991698), + (u.MJy / u.sr, u.erg / (u.s * u.cm**2 * u.AA), 1.1991698), + (u.MJy, u.erg / (u.s * u.cm**2 * u.AA * u.sr), 0.07494811)] + + equivalencies = all_flux_unit_conversion_equivs(pixar_sr=4, cube_wave=1*u.nm) + for orig, targ, truth in units_and_expected: + converted_value = flux_conversion_general([1], orig, targ, equivalencies) + np.testing.assert_allclose(converted_value[0].value, truth) + assert converted_value.unit == targ + + # as a bonus, also test the function that converts squared flux units + # (relevant in aperture photometry) + sq = handle_squared_flux_unit_conversions(1, orig**2, targ**2, equivalencies) + np.testing.assert_allclose(sq.value, truth**2, rtol=1e-06) + assert sq.unit == targ ** 2 diff --git a/jdaviz/core/tests/test_validunits.py b/jdaviz/core/tests/test_validunits.py deleted file mode 100644 index e5d4575fd6..0000000000 --- a/jdaviz/core/tests/test_validunits.py +++ /dev/null @@ -1,16 +0,0 @@ -import astropy.units as u -import pytest - -from jdaviz.core.validunits import check_if_unit_is_per_solid_angle - - -@pytest.mark.parametrize("unit, is_solid_angle", [ - ('erg / sr', True), ('erg / (deg * deg)', True), ('erg / (deg * arcsec)', True), - ('erg / (deg**2)', True), ('erg deg**-2', True), ('erg sr^-1', True), - ('erg / degree', False), ('erg sr^-2', False)]) -def test_check_if_unit_is_per_solid_angle(unit, is_solid_angle): - # test function that tests if a unit string or u.Unit is per solid angle - assert check_if_unit_is_per_solid_angle(unit) == is_solid_angle - - # turn string into u.Unit, and make sure it also passes - assert check_if_unit_is_per_solid_angle(u.Unit(unit)) == is_solid_angle diff --git a/jdaviz/core/unit_conversion_utils.py b/jdaviz/core/unit_conversion_utils.py new file mode 100644 index 0000000000..ebad68b367 --- /dev/null +++ b/jdaviz/core/unit_conversion_utils.py @@ -0,0 +1,466 @@ +import itertools + +from astropy import units as u +import numpy as np + +from jdaviz.core.custom_units_and_equivs import (PIX2, + SPEC_PHOTON_FLUX_DENSITY_UNITS, + _eqv_pixar_sr, + _eqv_flux_to_sb_pixel, + _spectral_and_photon_flux_density_units) + +__all__ = ["all_flux_unit_conversion_equivs", "check_if_unit_is_per_solid_angle", + "combine_flux_and_angle_units", "convert_integrated_sb_unit", + "create_equivalent_angle_units_list", + "create_equivalent_flux_units_list", + "create_equivalent_spectral_axis_units_list", + "flux_conversion_general", "handle_squared_flux_unit_conversions", + "supported_sq_angle_units", "units_to_strings"] + + +def all_flux_unit_conversion_equivs(pixar_sr=None, cube_wave=None): + """ + Combines commonly used flux unit conversion equivalencies for + translations between flux units and between flux and surface brightness + units. + + - Flux to flux per square pixel + - Flux to flux per steradian if ``pixar_sr`` is provided. + - Spectral density conversions (e.g. Jy to erg/s/cm2/A), if ``cube_wave`` + is provided + + Parameters + ---------- + pixar_sr : float, optional + Pixel scale factor in steradians. + cube_wave : `~astropy.units.Quantity`, optional + A reference wavelength or frequency value(s). + + Returns + ------- + equivs : list + List of equivalencies. + """ + + equivs = _eqv_flux_to_sb_pixel() + + if pixar_sr is not None: + equivs += _eqv_pixar_sr(pixar_sr) + + if cube_wave is not None: + equivs += u.spectral_density(cube_wave) + + return equivs + + +def check_if_unit_is_per_solid_angle(unit, return_unit=False): + """ + Check if a given Unit or unit string (that can be converted to + a Unit) represents some unit per solid angle. If 'return_unit' + is True, then a Unit of the solid angle will be returned (or + None if no solid angle is present in the denominator). + + Parameters + ---------- + unit : str or u.Unit + u.Unit object or string representation of unit. + return_unit : bool + If True, the u.Unit of the solid angle unit will + be returned (or None if unit is not a solid angle). + + Returns + ------- + result : `~astropy.units.Unit`, bool, or `None` + See explanation in ``return_unit``. + + Raises + ------ + ValueError + Invalid input. + + Examples + -------- + >>> check_if_unit_is_per_solid_angle('erg / (s cm^2 sr)') + True + >>> check_if_unit_is_per_solid_angle('erg / s cm^2') + False + >>> check_if_unit_is_per_solid_angle('Jy * sr^-1') + True + + """ + + # first, convert string to u.Unit obj. + # this will take care of some formatting consistency like + # turning something like Jy / (degree*degree) to Jy / deg**2 + # and erg sr^1 to erg / sr + if isinstance(unit, (u.core.Unit, u.core.CompositeUnit, + u.core.IrreducibleUnit)): + unit_str = unit.to_string() + elif isinstance(unit, str): + # convert string>unit>string to remove any formatting inconsistencies + unit = u.Unit(unit) + unit_str = unit.to_string() + else: + raise ValueError('Unit must be u.Unit, or string that can be converted into a u.Unit') + + if '/' in unit_str: + # input unit might be comprised of several units in denom. so check all. + denom = unit_str.split('/')[-1].split() + + # find all combos of one or two units, to catch cases where there are + # two different units of angle in the denom that might comprise a solid + # angle when multiplied. + for i in [combo for length in (1, 2) for combo in itertools.combinations(denom, length)]: + # turn tuple of 1 or 2 units into a string, and turn that into a u.Unit + # to check type + new_unit_str = ' '.join(i).translate(str.maketrans('', '', '()')) + new_unit = u.Unit(new_unit_str) + if new_unit.physical_type == 'solid angle': + if return_unit: # area units present and requested to be returned + return new_unit + return True # area units present but not requested to be returned + # square pixel should be considered a square angle unit + if new_unit == PIX2: + if return_unit: + return new_unit + return True + + # in the case there are no area units, but return units were requested + if return_unit: + return None + + # and if there are no area units, and return units were NOT requested. + return False + + +def combine_flux_and_angle_units(flux_units, angle_units): + """ + Combine (list of) flux_units and angle_units to create a list of string + representations of surface brightness units. The returned strings will be in + the same format as the astropy unit to_string() of the unit, for consistency. + """ + if not isinstance(flux_units, list): + flux_units = [flux_units] + if not isinstance(angle_units, list): + angle_units = [angle_units] + + return [(u.Unit(flux) / u.Unit(angle)).to_string() for flux in flux_units + for angle in angle_units] + + +def create_equivalent_angle_units_list(solid_angle_unit): + + """ + Return valid angles that ``solid_angle_unit`` (which should be a solid angle + physical type, or square pixel), can be translated to in the unit conversion + plugin. These options will populate the dropdown menu for 'angle unit' in + the Unit Conversion plugin. + + Parameters + ---------- + solid_angle_unit : str or u.Unit + Unit object or string representation of unit that is a ``solid angle`` + or square pixel physical type. + + Returns + ------- + equivalent_angle_units : list of str + String representation of units that ``solid_angle_unit`` can be + translated to. + + """ + + if solid_angle_unit is None or solid_angle_unit is PIX2: + # if there was no solid angle in the unit when calling this function + # can only represent that unit as per square pixel + return ['pix^2'] + + # cast to unit then back to string to account for formatting inconsistencies + # in strings that represent units + if isinstance(solid_angle_unit, str): + solid_angle_unit = u.Unit(solid_angle_unit) + unit_str = solid_angle_unit.to_string() + + # uncomment and expand this list once translating between solid + # angles and between solid angle and solid pixel is enabled + # equivalent_angle_units = ['sr', 'pix^2'] + equivalent_angle_units = [] + if unit_str not in equivalent_angle_units: + equivalent_angle_units += [unit_str] + + return equivalent_angle_units + + +def create_equivalent_flux_units_list(flux_unit): + """ + Get all possible conversions for flux from flux_unit, to populate 'flux' + dropdown menu in the unit conversion plugin. + + If flux_unit is a spectral or photon density (i.e., convertable to units in + SPEC_PHOTON_FLUX_DENSITY_UNITS), then the loaded unit and all of the + units in SPEC_PHOTON_FLUX_DENSITY_UNITS. + + If the loaded flux unit is count, dimensionless_unscaled, DN, e/s, then + there will be no additional items available for unit conversion and the + only item in the dropdown will be the native unit. + """ + + flux_unit_str = flux_unit.to_string() + + # if flux_unit is a spectral or photon flux density unit, then the flux unit + # dropdown options should be the loaded unit (which may have a different + # prefix e.g nJy) in addition to items in SPEC_PHOTON_FLUX_DENSITY_UNITS + equiv = u.spectral_density(1 * u.m) # unit doesn't matter, not evaluating + for un in SPEC_PHOTON_FLUX_DENSITY_UNITS: + if flux_unit.is_equivalent(un, equiv): + if flux_unit_str not in SPEC_PHOTON_FLUX_DENSITY_UNITS: + return SPEC_PHOTON_FLUX_DENSITY_UNITS + [flux_unit_str] + else: + return SPEC_PHOTON_FLUX_DENSITY_UNITS + + else: + # for any other units, including counts, DN, e/s, DN /s, etc, + # no other conversions between flux units available as we only support + # conversions to and from spectral and photon flux density flux unit. + # dropdown will only contain one item (the input unit) + return [flux_unit_str] + + +def create_equivalent_spectral_axis_units_list(spectral_axis_unit, + exclude=[u.jupiterRad, u.earthRad, + u.solRad, 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 [spectral_axis_unit] + + # Get unit equivalencies. + 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', '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)) + + # 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. + return sorted(units_to_strings(local_units)) + spectral_axis_unit_equivalencies_titles + + +def flux_conversion_general(values, original_unit, target_unit, + equivalencies=None, with_unit=True): + """ + Converts ``values`` from ``original_unit`` to ``target_unit`` using the + provided ``equivalencies`` while handling special cases where direct unit + conversion is not possible. This function is designed to account for + scenarios like conversions involving flux to surface brightness that + also require a ``u.spectral_density`` equivalency, conversions between + per-square pixel surface brightnesses that don't convert directly, and other + flux to surface brightness conversions. + + This function should be used for unit conversions when possible instead of + directly using Astropy's ``unit.to()``, as it handles additional logic for + special cases. + + Parameters + ---------- + values : array-like or float + The numerical values to be converted. + original_unit : `~astropy.units.Unit` or str + The unit of the input values. + target_unit : `~astropy.units.Unit` or str + The desired unit to convert to. + equivalencies : list of equivalencies, optional + Unit equivalencies to apply during the conversion. + with_unit : bool, optional + If True, the returned value retains its unit. If False, only the + numerical values are returned. + + Returns + ------- + converted_values : `~astropy.units.Quantity` or float + The converted values, with or without units based on ``with_unit``. + + Raises + ------ + astropy.units.UnitConversionError + If the conversion between ``original_unit`` and ``target_unit`` fails + despite the provided equivalencies. + + """ + + if original_unit == target_unit: + if not with_unit: + return values + return values * original_unit + + solid_angle_in_orig = check_if_unit_is_per_solid_angle(original_unit, + return_unit=True) + solid_angle_in_targ = check_if_unit_is_per_solid_angle(target_unit, + return_unit=True) + + with u.set_enabled_equivalencies(equivalencies): + + # first possible case we want to catch before trying to translate: both + # the original and target unit are per-pixel-squared SB units + # and also require an additional equivalency, so we need to multiply out + # the pix2 before conversion and re-apply. if this doesn't work, something else + # is going on (missing equivalency, etc) + if solid_angle_in_orig == solid_angle_in_targ == PIX2: + converted_values = (values * original_unit * PIX2).to(target_unit * PIX2) + converted_values = converted_values / PIX2 # re-apply pix2 unit + else: + try: + # if units can be converted straight away with provided + # equivalencies, return converted values + converted_values = (values * original_unit).to(target_unit) + except u.UnitConversionError: + # the only other case where units with the correct equivs wouldn't + # convert directly is if one unit is a flux and one is a sb and + # they also require an additional equivalency + if not bool(solid_angle_in_targ) == bool(solid_angle_in_orig): + converted_values = (values * original_unit * (solid_angle_in_orig or 1)).to(target_unit * (solid_angle_in_targ or 1)) # noqa + converted_values = (converted_values / (solid_angle_in_orig or 1)).to(target_unit) # noqa + else: + raise u.UnitConversionError(f'Could not convert {original_unit} to {target_unit} with provided equivalencies.') # noqa + + if not with_unit: + return converted_values.value + return converted_values + + +def handle_squared_flux_unit_conversions(value, original_unit=None, + target_unit=None, equivalencies=None): + """ + Handles conversions between squared flux or surface brightness units + that cannot be directly converted, even with the correct equivalencies. + + This function is specifically designed to address cases where squared + units, such as (MJy/sr)**2 to (Jy/sr)**2, appear in contexts like + variance columns of aperture photometry output tables. When additional + equivalencies are required, direct conversion may fail, so this workaround. + is required. + + Parameters + ---------- + value : array or float + The numerical values to be converted. + original_unit : `astropy.units.Unit` or str + The unit of the input values before conversion. + target_unit : `astropy.units.Unit` or str + The desired unit for the converted values. + equivalencies : list of equivalencies + Unit equivalencies to apply during the conversion. + + Returns + ------- + converted : `~astropy.units.Quantity` + The converted values, expressed in the ``target_unit``. + """ + + # get scale factor between non-squared units + converted = flux_conversion_general(1., + original_unit ** 0.5, + target_unit ** 0.5, + equivalencies, + with_unit=False) + + # square conversion factor and re-apply squared unit + converted = converted ** 2 * value * target_unit + + return converted + + +def supported_sq_angle_units(as_strings=False): + """ + Returns a list of squared angle units supported by the app. If a new + solid angle is added into unit conversion logic (e.g., square degree), it + should be added here. + """ + + units = [PIX2, u.sr] + if as_strings: + units = units_to_strings(units) + return units + + +def units_to_strings(unit_list): + """Convert equivalencies into readable versions of the units. + + Parameters + ---------- + unit_list : list + List of either `astropy.units.Unit` or strings that can be converted + to `astropy.units.Unit`. + + Returns + ------- + result : list + A list of the units with their best (i.e., most readable) string version. + """ + return [u.Unit(unit).to_string() for unit in unit_list] + + +def convert_integrated_sb_unit(u1, spectral_axis_unit, desired_freq_unit, desired_length_unit): + """ + Converts an integrated surface brightness unit (moment 0 unit) to a surface + brighntess unit that is compatible with the spectral axis unit that the surface + brightness was integrated over. + + This function adjusts an integrated flux unit to ensure compatibility with a given + spectral axis unit (e.g., frequency or wavelength). The function handles conversions + based on the physical type of the flux unit (per-frequency or per-wavelength) and + the provided spectral axis unit. + + Parameters: + ----------- + u1 : astropy.units.Unit + The unit of the integrated flux that needs conversion. + + spectral_axis_unit : astropy.units.Unit + The unit of the spectral axis over which the flux was integrated (e.g., Angstrom + for wavelength or Hz for frequency). + + Returns: + -------- + astropy.units.Unit + The converted flux unit compatible with the given spectral axis unit. If the + units are already compatible, the input unit `u1` is returned unchanged. + """ + + uu = u1 / spectral_axis_unit + + # multiply solid angle unit out of surface brightness to compare just flux components + flux = uu * check_if_unit_is_per_solid_angle(uu.unit, return_unit=True) + + # then check if flux unit is a per-frequency or per-wavelength flux unit + wav_units = _spectral_and_photon_flux_density_units(wav_only=True, as_units=True) + freq_units = _spectral_and_photon_flux_density_units(freq_only=True, as_units=True) + if np.any([flux.unit.is_equivalent(x) for x in wav_units]): + flux_unit_type = 'length' + elif np.any([flux.unit.is_equivalent(x) for x in freq_units]): + flux_unit_type = 'frequency' + + if (spectral_axis_unit.physical_type != flux_unit_type): + if flux_unit_type == 'length': + spec_axis_conversion_scale_factor = (1*spectral_axis_unit).to(desired_length_unit, + u.spectral()) + elif flux_unit_type == 'frequency': + spec_axis_conversion_scale_factor = (1*spectral_axis_unit).to(desired_freq_unit, + u.spectral()) + else: + return u1 # units are compatible, return input + + return uu * spec_axis_conversion_scale_factor diff --git a/jdaviz/core/validunits.py b/jdaviz/core/validunits.py deleted file mode 100644 index 75869a4942..0000000000 --- a/jdaviz/core/validunits.py +++ /dev/null @@ -1,225 +0,0 @@ -from astropy import units as u -import itertools - -from jdaviz.core.custom_units import PIX2, SPEC_PHOTON_FLUX_DENSITY_UNITS - -__all__ = ['supported_sq_angle_units', - 'combine_flux_and_angle_units', 'units_to_strings', - 'create_spectral_equivalencies_list', - 'create_flux_equivalencies_list', 'check_if_unit_is_per_solid_angle'] - - -def supported_sq_angle_units(as_strings=False): - units = [PIX2, u.sr] - if as_strings: - units = units_to_strings(units) - return units - - -def combine_flux_and_angle_units(flux_units, angle_units): - """ - Combine (list of) flux_units and angle_units to create a list of string - representations of surface brightness units. The returned strings will be in - the same format as the astropy unit to_string() of the unit, for consistency. - """ - if not isinstance(flux_units, list): - flux_units = [flux_units] - if not isinstance(angle_units, list): - angle_units = [angle_units] - - return [(u.Unit(flux) / u.Unit(angle)).to_string() for flux in flux_units for angle in angle_units] # noqa - - -def units_to_strings(unit_list): - """Convert equivalencies into readable versions of the units. - - Parameters - ---------- - unit_list : list - List of either `astropy.units` or strings that can be converted - to `astropy.units`. - - Returns - ------- - result : list - A list of the units with their best (i.e., most readable) string version. - """ - return [u.Unit(unit).to_string() for unit in unit_list] - - -def create_spectral_equivalencies_list(spectral_axis_unit, - exclude=[u.jupiterRad, u.earthRad, u.solRad, - 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 [spectral_axis_unit] - - # Get unit equivalencies. - 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', '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)) - - # 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. - return sorted(units_to_strings(local_units)) + spectral_axis_unit_equivalencies_titles - - -def create_flux_equivalencies_list(flux_unit): - """ - Get all possible conversions for flux from flux_unit, to populate 'flux' - dropdown menu in the unit conversion plugin. - - If flux_unit is a spectral or photon density (i.e convertable to units in - SPEC_PHOTON_FLUX_DENSITY_UNITS), then the loaded unit and all of the - units in SPEC_PHOTON_FLUX_DENSITY_UNITS. - - If the loaded flux unit is count, dimensionless_unscaled, DN, e/s, then - there will be no additional items available for unit conversion and the - only item in the dropdown will be the native unit. - """ - - flux_unit_str = flux_unit.to_string() - - # if flux_unit is a spectral or photon flux density unit, then the flux unit - # dropdown options should be the loaded unit (which may have a different - # prefix e.g nJy) in addition to items in SPEC_PHOTON_FLUX_DENSITY_UNITS - equiv = u.spectral_density(1 * u.m) # spec. unit doesn't matter here, we're not evaluating - for un in SPEC_PHOTON_FLUX_DENSITY_UNITS: - if flux_unit.is_equivalent(un, equiv): - if flux_unit_str not in SPEC_PHOTON_FLUX_DENSITY_UNITS: - return SPEC_PHOTON_FLUX_DENSITY_UNITS + [flux_unit_str] - else: - return SPEC_PHOTON_FLUX_DENSITY_UNITS - - else: - # for any other units, including counts, DN, e/s, DN /s, etc, - # no other conversions between flux units available as we only support - # conversions to and from spectral and photon flux density flux unit. - # dropdown will only contain one item (the input unit) - return [flux_unit_str] - - -def create_angle_equivalencies_list(solid_angle_unit): - - """ - Return valid angles that `solid_angle_unit` (which should be a solid angle - physical type, or square pixel), can be translated to in the unit conversion - plugin. These options will populate the dropdown menu for 'angle unit' in - the Unit Conversion plugin. - - Parameters - ---------- - solid_angle_unit : str or u.Unit - Unit object or string representation of unit that is a 'solid angle' - or square pixel physical type. - - Returns - ------- - equivalent_angle_units : list of str - String representation of units that `solid_angle_unit` can be - translated to. - - """ - - if solid_angle_unit is None or solid_angle_unit is PIX2: - # if there was no solid angle in the unit when calling this function - # can only represent that unit as per square pixel - return ['pix^2'] - - # cast to unit then back to string to account for formatting inconsistencies - # in strings that represent units - if isinstance(solid_angle_unit, str): - solid_angle_unit = u.Unit(solid_angle_unit) - unit_str = solid_angle_unit.to_string() - - # uncomment and expand this list once translating between solid - # angles and between solid angle and solid pixel is enabled - # equivalent_angle_units = ['sr', 'pix^2'] - equivalent_angle_units = [] - if unit_str not in equivalent_angle_units: - equivalent_angle_units += [unit_str] - - return equivalent_angle_units - - -def check_if_unit_is_per_solid_angle(unit, return_unit=False): - """ - Check if a given Unit or unit string (that can be converted to - a Unit) represents some unit per solid angle. If 'return_unit' - is True, then a Unit of the solid angle will be returned (or - None if no solid angle is present in the denominator). - - Parameters - ---------- - unit : str or u.Unit - u.Unit object or string representation of unit. - return_unit : bool - If True, the u.Unit of the solid angle unit will - be returned (or None if unit is not a solid angle). - - Examples - -------- - >>> check_if_unit_is_per_solid_angle('erg / (s cm^2 sr)') - True - >>> check_if_unit_is_per_solid_angle('erg / s cm^2') - False - >>> check_if_unit_is_per_solid_angle('Jy * sr^-1') - True - - """ - - # first, convert string to u.Unit obj. - # this will take care of some formatting consistency like - # turning something like Jy / (degree*degree) to Jy / deg**2 - # and erg sr^1 to erg / sr - if isinstance(unit, (u.core.Unit, u.core.CompositeUnit, - u.core.IrreducibleUnit)): - unit_str = unit.to_string() - elif isinstance(unit, str): - unit = u.Unit(unit) - unit_str = unit.to_string() - else: - raise ValueError('Unit must be u.Unit, or string that can be converted into a u.Unit') - - if '/' in unit_str: - # input unit might be comprised of several units in denom. so check all. - denom = unit_str.split('/')[-1].split() - - # find all combos of one or two units, to catch cases where there are - # two different units of angle in the denom that might comprise a solid - # angle when multiplied. - for i in [combo for length in (1, 2) for combo in itertools.combinations(denom, length)]: - # turn tuple of 1 or 2 units into a string, and turn that into a u.Unit - # to check type - new_unit_str = ' '.join(i).translate(str.maketrans('', '', '()')) - new_unit = u.Unit(new_unit_str) - if new_unit.physical_type == 'solid angle': - if return_unit: # area units present and requested to be returned - return new_unit - return True # area units present but not requested to be returned - # square pixel should be considered a square angle unit - if new_unit == PIX2: - if return_unit: - return new_unit - return True - - # in the case there are no area units, but return units were requested - if return_unit: - return None - - # and if there are no area units, and return units were NOT requested. - return False diff --git a/jdaviz/tests/test_utils.py b/jdaviz/tests/test_utils.py index 8569bfb0e7..bf5d6195e1 100644 --- a/jdaviz/tests/test_utils.py +++ b/jdaviz/tests/test_utils.py @@ -10,7 +10,7 @@ from numpy.testing import assert_allclose from specutils import Spectrum1D -from jdaviz.core.custom_units import PIX2 +from jdaviz.core.custom_units_and_equivs import PIX2 from jdaviz.utils import (alpha_index, download_uri_to_path, flux_conversion, _indirect_conversion, _eqv_pixar_sr) diff --git a/jdaviz/utils.py b/jdaviz/utils.py index ee279db6da..266edeab12 100644 --- a/jdaviz/utils.py +++ b/jdaviz/utils.py @@ -26,8 +26,8 @@ from glue_astronomy.spectral_coordinates import SpectralCoordinates from ipyvue import watch -from jdaviz.core.custom_units import PIX2 -from jdaviz.core.validunits import check_if_unit_is_per_solid_angle +from jdaviz.core.custom_units_and_equivs import PIX2, _eqv_pixar_sr, _eqv_flux_to_sb_pixel +from jdaviz.core.unit_conversion_utils import check_if_unit_is_per_solid_angle __all__ = ['SnackbarQueue', 'enable_hot_reloading', 'bqplot_clear_figure', 'standardize_metadata', 'ColorCycler', 'alpha_index', 'get_subset_type', @@ -326,7 +326,7 @@ def standardize_roman_metadata(data_model): def indirect_units(): - from jdaviz.core.validunits import supported_sq_angle_units + from jdaviz.core.unit_conversion_utils import supported_sq_angle_units units = [] @@ -523,74 +523,6 @@ def _indirect_conversion(values, orig_units, targ_units, eqv, return values, orig_units -def _eqv_pixar_sr(pixar_sr): - """ - Return Equivalencies to convert from flux to flux per solid - angle (aka surface brightness) using scale ratio `pixar_sr` - (steradians per pixel). - """ - 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), - (u.erg / (u.s * u.cm**2 * u.Angstrom * u.sr), u.erg / (u.s * u.cm**2 * u.Angstrom), converter_flux, iconverter_flux), # noqa - (u.ph / (u.Angstrom * u.s * u.cm**2 * u.sr), u.ph / (u.Angstrom * u.s * u.cm**2), converter_flux, iconverter_flux), # noqa - (u.ph / (u.Hz * u.s * u.cm**2 * u.sr), u.ph / (u.Hz * u.s * u.cm**2), converter_flux, iconverter_flux), # noqa - (u.ct / u.sr, u.ct, converter_flux, iconverter_flux) # noqa - ] - - -def _eqv_flux_to_sb_pixel(): - """ - Returns an Equivalency between `flux_unit` and `flux_unit`/pix**2. This - allows conversion between flux and flux-per-square-pixel surface brightness - e.g MJy <> MJy / pix2 - """ - - # generate an equivalency for each flux type that would need - # another equivalency for converting to/from - flux_units = [u.MJy, - u.erg / (u.s * u.cm**2 * u.Angstrom), - u.ph / (u.Angstrom * u.s * u.cm**2), - u.ph / (u.Hz * u.s * u.cm**2), - u.ct, - u.DN, - u.DN / u.s] - return [(flux_unit, flux_unit / PIX2, lambda x: x, lambda x: x) - for flux_unit in flux_units] - - -def _eqv_sb_per_pixel_to_per_angle(flux_unit, scale_factor=1): - """ - Returns an equivalency between `flux_unit` per square pixel and - `flux_unit` per solid angle to be able to compare and convert between units - like Jy/pix**2 and Jy/sr. The scale factor is assumed to be in steradians, - to follow the convention of the PIXAR_SR keyword. - Note: - To allow conversions between units like 'ph / (Hz s cm2 sr)' and - MJy / pix2, which would require this equivalency as well as u.spectral_density, - these CAN'T be combined when converting like: - equivalencies=u.spectral_density(1 * u.m) + _eqv_sb_per_pixel_to_per_angle(u.Jy) - So additional logic is needed to compare units that need both equivalencies - (one solution being creating this equivalency for each equivalent flux-type.) - - """ - - # the two types of units we want to define a conversion between - flux_solid_ang = flux_unit / u.sr - flux_sq_pix = flux_unit / PIX2 - - pix_to_solid_angle_equiv = [(flux_solid_ang, flux_sq_pix, - lambda x: x * scale_factor, - lambda x: x / scale_factor)] - - return pix_to_solid_angle_equiv - - def spectral_axis_conversion(values, original_units, target_units): eqv = u.spectral() + u.pixel_scale(1*u.pix) return (values * u.Unit(original_units)).to_value(u.Unit(target_units), equivalencies=eqv)