diff --git a/doc/customizing_guide/units.rst b/doc/customizing_guide/units.rst new file mode 100644 index 000000000..88e6ee9a8 --- /dev/null +++ b/doc/customizing_guide/units.rst @@ -0,0 +1,71 @@ +Unit conversion in glue +======================= + +.. note:: Support for automatic unit conversion in glue is experimental - at the moment + the ability to select units for the x and y axes is only available in the profile viewer. + +Data components can be assigned units as a string (or `None` to indicate no known units). +By default, glue uses `astropy.units `_ package +to carry out unit conversions based on these units. However, it is possible to customize the +unit conversion machinery, either to use a different unit transformation machinery, or to specify, +e.g., equivalencies in the astropy unit conversion. To customize the unit conversion behavior, you +will need to define a unit converter as shown below:: + + from astropy import units as u + from glue.core.units import unit_converter + + @unit_converter('custom-name') + class MyCustomUnitConverter: + + def equivalent_units(self, data, cid, units): + # Given a glue data object (data), a component ID (cid), and the units + # of that component in the data object (units), this method should + # return a flat list of units (as strings) that the data could be + # converted to. This is used to construct the drop-down menus with the + # available units to convert to. + + def to_unit(self, data, cid, values, original_units, target_units): + # Given a glue data object (data), a component ID (cid), the values + # to convert, and the original and target units of the values, this method + # should return the converted values. Note that original_units + # gives the units of the values array, which might not be the same + # as the original native units of the component in the data. + +In both methods, the data and cid are passed in not to get values or units (those should be +used from the other arguments to the methods) but rather to allow logic for the unit +conversion that might depend on which component is being converted. An example of +a simple unit converter based on `astropy.units`_ would be:: + + from astropy import units as u + from glue.core.units import unit_converter + + @unit_converter('example-1') + class ExampleUnitConverter: + + def equivalent_units(self, data, cid, units): + return map(u.Unit(units).find_equivalent_units(include_prefix_units=True), str) + + def to_unit(self, data, cid, values, original_units, target_units): + return (values * u.Unit(original_units)).to_value(target_units) + +This does not actually make use of ``data`` and ``cid``. An example that does would be:: + + from astropy import units as u + from glue.core.units import unit_converter + + @unit_converter('example-2') + class ExampleUnitConverter: + + def equivalent_units(self, data, cid, units): + equivalencies = u.temperature() if 'temp' in cid.label.lower() else None + return map(u.Unit(units).find_equivalent_units(include_prefix_units=True, equivalencies=equivalencies), str) + + def to_unit(self, data, cid, values, original_units, target_units): + equivalencies = u.temperature() if 'temp' in cid.label.lower() else None + return (values * u.Unit(original_units)).to_value(target_units, equivalencies=equivalencies) + +Once you have defined a unit conversion class, you can then opt-in to using it in glue by adjusting +the following setting:: + + from glue.config import settings + settings.UNIT_CONVERTER = 'example-2' diff --git a/doc/index.rst b/doc/index.rst index 0838a86cf..78ebd8931 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -100,6 +100,7 @@ Customizing/Hacking Glue customizing_guide/custom_viewer.rst python_guide/liveupdate.rst customizing_guide/fitting.rst + customizing_guide/units.rst Advanced customization ---------------------- diff --git a/glue/config.py b/glue/config.py index 98024eaa9..8f7e99b6a 100644 --- a/glue/config.py +++ b/glue/config.py @@ -26,7 +26,8 @@ 'SessionPatchRegistry', 'session_patch', 'AutoLinkerRegistry', 'autolinker', 'DataTranslatorRegistry', 'data_translator', - 'SubsetDefinitionTranslatorRegistry', 'subset_state_translator'] + 'SubsetDefinitionTranslatorRegistry', 'subset_state_translator', + 'UnitConverterRegistry', 'unit_converter'] CFG_DIR = os.path.join(os.path.expanduser('~'), '.glue') @@ -608,6 +609,25 @@ def get_handler_for(self, format): raise ValueError("Invalid subset state handler format '{0}' - should be one of:".format(format) + format_choices(all_formats)) +class UnitConverterRegistry(DictRegistry): + """ + Stores unit converters, which are classes that can be used to determine + conversion between units and find equivalent units to other units. + """ + + def add(self, label, converter_cls): + if label in self.members: + raise ValueError("Unit converter class '{0}' already registered".format(label)) + else: + self.members[label] = converter_cls + + def __call__(self, label): + def adder(converter_cls): + self.add(label, converter_cls) + return converter_cls + return adder + + class QtClientRegistry(Registry): """ Stores QT widgets to visualize data. @@ -978,6 +998,9 @@ def __iter__(self): data_translator = DataTranslatorRegistry() subset_state_translator = SubsetDefinitionTranslatorRegistry() +# Units +unit_converter = UnitConverterRegistry() + # Backward-compatibility single_subset_action = layer_action @@ -1067,3 +1090,12 @@ def _default_search_order(): settings.add('SHOW_WARN_PROFILE_DUPLICATE', True, validator=bool) settings.add('FONT_SIZE', -1.0, validator=float) settings.add('AUTOLINK', {}, validator=dict) + + +def check_unit_converter(value): + if value != 'default' and value not in unit_converter.members: + raise KeyError(f'Unit converter {value} is not defined') + return value + + +settings.add('UNIT_CONVERTER', 'default', validator=check_unit_converter) diff --git a/glue/core/component.py b/glue/core/component.py index f69788d66..9410a9f51 100644 --- a/glue/core/component.py +++ b/glue/core/component.py @@ -59,7 +59,7 @@ def units(self): @units.setter def units(self, value): if value is None: - self._units = '' + self._units = None else: self._units = str(value) @@ -519,7 +519,7 @@ def units(self): @units.setter def units(self, value): if value is None: - self._units = '' + self._units = None else: self._units = str(value) diff --git a/glue/core/coordinates.py b/glue/core/coordinates.py index cf770bb6c..63f280daa 100644 --- a/glue/core/coordinates.py +++ b/glue/core/coordinates.py @@ -173,14 +173,22 @@ def pixel_to_world_values(self, *pixel): pixel = np.array(np.broadcast_arrays(*(list(pixel) + [np.ones(np.shape(pixel[0]))]))) pixel = np.moveaxis(pixel, 0, -1) world = np.matmul(pixel, self._matrix.T) - return tuple(np.moveaxis(world, -1, 0))[:-1] + world = tuple(np.moveaxis(world, -1, 0))[:-1] + if self._matrix.shape[0] == 2: # 1D + return world[0] + else: + return world def world_to_pixel_values(self, *world): scalar = np.all([np.isscalar(w) for w in world]) world = np.array(np.broadcast_arrays(*(list(world) + [np.ones(np.shape(world[0]))]))) world = np.moveaxis(world, 0, -1) pixel = np.matmul(world, self._matrix_inv.T) - return tuple(np.moveaxis(pixel, -1, 0))[:-1] + pixel = tuple(np.moveaxis(pixel, -1, 0))[:-1] + if self._matrix.shape[0] == 2: # 1D + return pixel[0] + else: + return pixel @property def world_axis_names(self): diff --git a/glue/core/subset_group.py b/glue/core/subset_group.py index b7cf8dff9..dc6241996 100644 --- a/glue/core/subset_group.py +++ b/glue/core/subset_group.py @@ -14,6 +14,8 @@ It should *not* call :func:`~glue.core.data.BaseData.add_subset` or :func:`~glue.core.data.BaseData.new_subset` directly """ + +import uuid from warnings import warn from glue.core.contracts import contract @@ -57,6 +59,12 @@ def __init__(self, data, group): self.data = data self.label = group.label # trigger disambiguation + # We assign a UUID which can then be used for example in equations + # for derived components - the idea is that this doesn't change over + # the life cycle of glue, so it is a more reliable way to refer to + # components in strings than using labels + self._uuid = str(uuid.uuid4()) + @property def style(self): return self.group.style diff --git a/glue/core/tests/test_coordinates.py b/glue/core/tests/test_coordinates.py index 58c12e498..5f650cb73 100644 --- a/glue/core/tests/test_coordinates.py +++ b/glue/core/tests/test_coordinates.py @@ -381,6 +381,20 @@ def test_pixel2world_single_axis_1d(): assert_allclose(pixel2world_single_axis(coord, x.reshape((3, 1)), world_axis=0), expected.reshape((3, 1))) +def test_pixel2world_single_axis_affine_1d(): + + # Regression test for issues that occurred for 1D AffineCoordinates + + coord = AffineCoordinates(np.array([[2, 0], [0, 1]])) + + x = np.array([0.2, 0.4, 0.6]) + expected = np.array([0.4, 0.8, 1.2]) + + assert_allclose(pixel2world_single_axis(coord, x, world_axis=0), expected) + assert_allclose(pixel2world_single_axis(coord, x.reshape((1, 3)), world_axis=0), expected.reshape((1, 3))) + assert_allclose(pixel2world_single_axis(coord, x.reshape((3, 1)), world_axis=0), expected.reshape((3, 1))) + + def test_affine(): matrix = np.array([[2, 3, -1], [1, 2, 2], [0, 0, 1]]) diff --git a/glue/core/tests/test_units.py b/glue/core/tests/test_units.py new file mode 100644 index 000000000..e302b50e4 --- /dev/null +++ b/glue/core/tests/test_units.py @@ -0,0 +1,112 @@ +from glue.core.units import UnitConverter, find_unit_choices +from glue.config import unit_converter, settings +from glue.core import Data + + +def setup_function(func): + func.ORIGINAL_UNIT_CONVERTER = settings.UNIT_CONVERTER + + +def teardown_function(func): + settings.UNIT_CONVERTER = func.ORIGINAL_UNIT_CONVERTER + + +def setup_module(): + unit_converter.add('test-custom', SimpleCustomUnitConverter) + + +def teardown_module(): + unit_converter._members.pop('test-custom') + + +def test_unit_converter_default(): + + data1 = Data(a=[1, 2, 3], b=[4, 5, 6]) + data1.get_component('a').units = 'm' + + uc = UnitConverter() + assert 'km' in uc.equivalent_units(data1, data1.id['a']) + + assert uc.to_unit(data1, data1.id['a'], 2000, 'km') == 2 + assert uc.to_native(data1, data1.id['a'], 2, 'km') == 2000 + + assert uc.to_unit(data1, data1.id['a'], 2000, None) == 2000 + assert uc.to_native(data1, data1.id['a'], 2, None) == 2 + + assert uc.equivalent_units(data1, data1.id['b']) == [] + + assert uc.to_unit(data1, data1.id['b'], 2000, 'km') == 2000 + assert uc.to_native(data1, data1.id['b'], 2, 'km') == 2 + + +def test_find_unit_choices_default(): + + assert find_unit_choices([]) == [] + + units1 = find_unit_choices([(None, None, 'm')]) + assert 'km' in units1 + assert 'yr' not in units1 + + units2 = find_unit_choices([(None, None, 'm'), (None, None, 's')]) + assert 'km' in units2 + assert 'yr' in units2 + + +class SimpleCustomUnitConverter: + + def equivalent_units(self, data, cid, units): + # We want to make sure we properly test data and cid so we make it + # so that if cid contains 'fixed' we return only the original unit + # and if the data label contains 'bilingual' then we return the full + # set of units + if cid.label == 'fixed': + return [units] + elif data.label == 'bilingual': + return ['one', 'two', 'three', 'dix', 'vingt', 'trente'] + elif units in ['one', 'two', 'three']: + return ['one', 'two', 'three'] + elif units in ['dix', 'vingt', 'trente']: + return ['dix', 'vingt', 'trente'] + else: + raise ValueError(f'Unrecongized unit: {units}') + + numerical = { + 'one': 1, + 'two': 2, + 'three': 3, + 'dix': 10, + 'vingt': 20, + 'trente': 30 + } + + def to_unit(self, data, cid, values, original_units, target_units): + return values * self.numerical[target_units] / self.numerical[original_units] + + +def test_unit_converter_custom(): + + settings.UNIT_CONVERTER = 'test-custom' + + data1 = Data(a=[1, 2, 3]) + data1.get_component('a').units = 'two' + + uc = UnitConverter() + assert uc.equivalent_units(data1, data1.id['a']) == ['one', 'two', 'three'] + + assert uc.to_unit(data1, data1.id['a'], 4, 'three') == 6 + assert uc.to_native(data1, data1.id['a'], 6, 'three') == 4 + + +def test_find_unit_choices_custom(): + + settings.UNIT_CONVERTER = 'test-custom' + + data1 = Data(fixed=[1, 2, 3], a=[2, 3, 4], b=[3, 4, 5], label='data1') + data2 = Data(c=[4, 5, 6], d=[5, 6, 7], label='bilingual') + + assert find_unit_choices([]) == [] + + assert find_unit_choices([(data1, data1.id['fixed'], 'one')]) == ['one'] + assert find_unit_choices([(data1, data1.id['a'], 'one')]) == ['one', 'two', 'three'] + assert find_unit_choices([(data1, data1.id['a'], 'dix')]) == ['dix', 'vingt', 'trente'] + assert find_unit_choices([(data2, data2.id['c'], 'one')]) == ['one', 'two', 'three', 'dix', 'vingt', 'trente'] diff --git a/glue/core/units.py b/glue/core/units.py new file mode 100644 index 000000000..576772f6e --- /dev/null +++ b/glue/core/units.py @@ -0,0 +1,71 @@ +from astropy import units as u + +from glue.config import unit_converter, settings +from glue.core import Subset + +__all__ = ['UnitConverter', 'find_unit_choices'] + + +class UnitConverter: + + def __init__(self): + self.converter_helper = unit_converter.members[settings.UNIT_CONVERTER]() + + def equivalent_units(self, data, cid): + """ + Returns a list of units (as strings) equivalent to the ones for the + data and component ID specified. + """ + units = self._get_units(data, cid) + if units: + equivalent_units = self.converter_helper.equivalent_units(data, cid, units) + else: + equivalent_units = [] + return equivalent_units + + def to_unit(self, data, cid, values, target_units): + if target_units is None: + return values + original_units = self._get_units(data, cid) + if original_units: + return self.converter_helper.to_unit(data, cid, values, original_units, target_units) + else: + return values + + def to_native(self, data, cid, values, original_units): + if original_units is None: + return values + target_units = self._get_units(data, cid) + if target_units: + return self.converter_helper.to_unit(data, cid, values, original_units, target_units) + else: + return values + + def _get_units(self, data, cid): + data = data.data if isinstance(data, Subset) else data + return data.get_component(cid).units + + +@unit_converter('default') +class SimpleAstropyUnitConverter: + + def equivalent_units(self, data, cid, units): + return map(str, u.Unit(units).find_equivalent_units(include_prefix_units=True)) + + def to_unit(self, data, cid, values, original_units, target_units): + return (values * u.Unit(original_units)).to_value(target_units) + + +def find_unit_choices(data_cid_units): + equivalent_units = [] + converter_helper = unit_converter.members[settings.UNIT_CONVERTER]() + for data, cid, unit_string in data_cid_units: + try: + if unit_string not in equivalent_units: + equivalent_units.append(unit_string) + for x in converter_helper.equivalent_units(data, cid, unit_string): + if x not in equivalent_units: + equivalent_units.append(str(x)) + except ValueError: + pass + return equivalent_units diff --git a/glue/main.py b/glue/main.py index 855a6e05a..e2fbec3f4 100755 --- a/glue/main.py +++ b/glue/main.py @@ -265,6 +265,7 @@ def main(argv=sys.argv): 'glue.viewers.image.qt', 'glue.viewers.scatter.qt', 'glue.viewers.histogram.qt', + 'glue.viewers.profile.qt', 'glue.viewers.table.qt'] diff --git a/glue/viewers/profile/layer_artist.py b/glue/viewers/profile/layer_artist.py index 9402bae2d..66a2e83ff 100644 --- a/glue/viewers/profile/layer_artist.py +++ b/glue/viewers/profile/layer_artist.py @@ -131,7 +131,8 @@ def _update_profile(self, force=False, **kwargs): # of updated properties is up to date after this method has been called. changed = self.pop_changed_properties() - if force or any(prop in changed for prop in ('layer', 'x_att', 'attribute', 'function', 'normalize', 'v_min', 'v_max', 'visible')): + if force or any(prop in changed for prop in ('layer', 'x_att', 'attribute', 'function', 'normalize', + 'v_min', 'v_max', 'visible', 'x_display_unit', 'y_display_unit')): self._calculate_profile(reset=force) force = True diff --git a/glue/viewers/profile/qt/options_widget.ui b/glue/viewers/profile/qt/options_widget.ui index aa4f34f9a..b46fa5967 100644 --- a/glue/viewers/profile/qt/options_widget.ui +++ b/glue/viewers/profile/qt/options_widget.ui @@ -6,7 +6,7 @@ 0 0 - 269 + 293 418 @@ -57,25 +57,6 @@ 5 - - - - - 75 - true - - - - reference - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - @@ -83,6 +64,19 @@ + + + + Qt::Vertical + + + + 20 + 40 + + + + @@ -115,14 +109,7 @@ - - - - - - - - + Qt::Horizontal @@ -135,7 +122,7 @@ - + @@ -151,7 +138,14 @@ - + + + + QComboBox::AdjustToMinimumContentsLengthWithIcon + + + + color: rgb(255, 33, 28) @@ -167,25 +161,69 @@ - - - - QComboBox::AdjustToMinimumContentsLengthWithIcon + + + + + + + - - - - Qt::Vertical + + + + + 75 + true + - - - 20 - 40 - + + reference - + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 75 + true + + + + x unit + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 75 + true + + + + y_unit + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + diff --git a/glue/viewers/profile/qt/tests/test_data_viewer.py b/glue/viewers/profile/qt/tests/test_data_viewer.py index fe1ad5baf..1780743ec 100644 --- a/glue/viewers/profile/qt/tests/test_data_viewer.py +++ b/glue/viewers/profile/qt/tests/test_data_viewer.py @@ -5,6 +5,8 @@ import pytest import numpy as np +from astropy.wcs import WCS + from numpy.testing import assert_equal, assert_allclose from glue.core import Data @@ -17,6 +19,7 @@ from glue.viewers.profile.tests.test_state import SimpleCoordinates from glue.core.tests.test_state import clone from glue.core.state import GlueUnSerializer +from glue.plugins.wcs_autolinking.wcs_autolinking import WCSLink from ..data_viewer import ProfileViewer @@ -318,3 +321,91 @@ def test_layer_visibility(self): assert self.viewer.layers[0].mpl_artists[0].get_visible() is True self.viewer.state.layers[0].visible = False assert self.viewer.layers[0].mpl_artists[0].get_visible() is False + + +def test_unit_conversion(): + + wcs1 = WCS(naxis=1) + wcs1.wcs.ctype = ['FREQ'] + wcs1.wcs.crval = [1] + wcs1.wcs.cdelt = [1] + wcs1.wcs.crpix = [1] + wcs1.wcs.cunit = ['GHz'] + + d1 = Data(f1=[1, 2, 3]) + d1.get_component('f1').units = 'Jy' + d1.coords = wcs1 + + wcs2 = WCS(naxis=1) + wcs2.wcs.ctype = ['WAVE'] + wcs2.wcs.crval = [10] + wcs2.wcs.cdelt = [10] + wcs2.wcs.crpix = [1] + wcs2.wcs.cunit = ['cm'] + + d2 = Data(f2=[2000, 1000, 3000]) + d2.get_component('f2').units = 'mJy' + d2.coords = wcs2 + + app = GlueApplication() + session = app.session + + data_collection = session.data_collection + data_collection.append(d1) + data_collection.append(d2) + + data_collection.add_link(WCSLink(d1, d2)) + + viewer = app.new_data_viewer(ProfileViewer) + viewer.add_data(d1) + viewer.add_data(d2) + + assert viewer.layers[0].enabled + assert viewer.layers[1].enabled + + x, y = viewer.state.layers[0].profile + assert_allclose(x, [1.e9, 2.e9, 3.e9]) + assert_allclose(y, [1, 2, 3]) + + x, y = viewer.state.layers[1].profile + assert_allclose(x, 299792458 / np.array([0.1, 0.2, 0.3])) + assert_allclose(y, [2000, 1000, 3000]) + + assert viewer.state.x_min == 1.e9 + assert viewer.state.x_max == 3.e9 + assert viewer.state.y_min == 1. + assert viewer.state.y_max == 3. + + roi = XRangeROI(1.4e9, 2.1e9) + viewer.apply_roi(roi) + + assert len(d1.subsets) == 1 + assert_equal(d1.subsets[0].to_mask(), [0, 1, 0]) + + assert len(d2.subsets) == 1 + assert_equal(d2.subsets[0].to_mask(), [0, 1, 0]) + + viewer.state.x_display_unit = 'GHz' + viewer.state.y_display_unit = 'mJy' + + x, y = viewer.state.layers[0].profile + assert_allclose(x, [1, 2, 3]) + assert_allclose(y, [1000, 2000, 3000]) + + x, y = viewer.state.layers[1].profile + assert_allclose(x, 2.99792458 / np.array([1, 2, 3])) + assert_allclose(y, [2000, 1000, 3000]) + + assert viewer.state.x_min == 1. + assert viewer.state.x_max == 3. + assert viewer.state.y_min == 1000. + assert viewer.state.y_max == 3000. + + roi = XRangeROI(0.5, 1.2) + viewer.apply_roi(roi) + + assert len(d1.subsets) == 1 + assert_equal(d1.subsets[0].to_mask(), [1, 0, 0]) + + assert len(d2.subsets) == 1 + assert_equal(d2.subsets[0].to_mask(), [0, 0, 1]) diff --git a/glue/viewers/profile/state.py b/glue/viewers/profile/state.py index 0d9cd6ba0..283ae3513 100644 --- a/glue/viewers/profile/state.py +++ b/glue/viewers/profile/state.py @@ -10,10 +10,12 @@ DeferredDrawCallbackProperty as DDCProperty, DeferredDrawSelectionCallbackProperty as DDSCProperty) from glue.core.data_combo_helper import ManualDataComboHelper, ComponentIDComboHelper -from glue.utils import defer_draw +from glue.utils import defer_draw, avoid_circular +from glue.core.data import BaseData from glue.core.link_manager import is_convertible_to_single_pixel_cid from glue.core.exceptions import IncompatibleDataException from glue.core.message import SubsetUpdateMessage +from glue.core.units import find_unit_choices, UnitConverter __all__ = ['ProfileViewerState', 'ProfileLayerState'] @@ -36,6 +38,9 @@ class ProfileViewerState(MatplotlibDataViewerState): x_att = DDSCProperty(docstring='The component ID giving the pixel or world component ' 'shown on the x axis') + x_display_unit = DDSCProperty(docstring='The units to use to display the x-axis.') + y_display_unit = DDSCProperty(docstring='The units to use to display the y-axis') + reference_data = DDSCProperty(docstring='The dataset that is used to define the ' 'available pixel/world components, and ' 'which defines the coordinate frame in ' @@ -57,6 +62,8 @@ def __init__(self, **kwargs): self.add_callback('layers', self._layers_changed) self.add_callback('reference_data', self._reference_data_changed, echo_old=True) self.add_callback('x_att', self._update_att) + self.add_callback('x_display_unit', self._reset_x_limits) + self.add_callback('y_display_unit', self._reset_y_limits_if_changed, echo_old=True) self.add_callback('normalize', self._reset_y_limits) self.add_callback('function', self._reset_y_limits) @@ -67,8 +74,23 @@ def __init__(self, **kwargs): ProfileViewerState.function.set_choices(self, list(FUNCTIONS)) ProfileViewerState.function.set_display_func(self, FUNCTIONS.get) + def format_unit(unit): + if unit is None: + return 'Native units' + else: + return unit + + ProfileViewerState.x_display_unit.set_display_func(self, format_unit) + ProfileViewerState.y_display_unit.set_display_func(self, format_unit) + self.update_from_dict(kwargs) + def _reset_y_limits_if_changed(self, old, new): + # This is needed because otherwise reset_y_limits gets called even if + # just the choices in the y units change but not the selected value. + if old != new: + self._reset_y_limits() + def _update_combo_ref_data(self): self.ref_data_helper.set_multiple_data(self.layers_data) @@ -116,6 +138,11 @@ def _reset_x_limits(self, *event): axis_values = data[self.x_att, tuple(axis_view)] x_min, x_max = np.nanmin(axis_values), np.nanmax(axis_values) + converter = UnitConverter() + x_min, x_max = converter.to_unit(self.reference_data, + self.x_att, np.array([x_min, x_max]), + self.x_display_unit) + with delay_callback(self, 'x_min', 'x_max'): self.x_min = x_min self.x_max = x_max @@ -153,8 +180,42 @@ def flip_x(self): self.x_min, self.x_max = self.x_max, self.x_min @defer_draw + @avoid_circular def _layers_changed(self, *args): - self._update_combo_ref_data() + # By default if any of the state properties change, this triggers a + # callback on anything listening to changes on self.layers - but here + # we just want to know if any layers have been removed/added so we keep + # track of the UUIDs of the layers and check this before continuing. + current_layers = [layer_state.layer.uuid for layer_state in self.layers] + if not hasattr(self, '_last_layers') or self._last_layers != current_layers: + self._update_combo_ref_data() + self._update_y_display_unit_choices() + self._last_layers = current_layers + + def _update_x_display_unit_choices(self): + + if self.reference_data is None: + ProfileViewerState.x_display_unit.set_choices(self, []) + return + + component = self.reference_data.get_component(self.x_att) + if component.units: + x_choices = find_unit_choices([(self.reference_data, self.x_att, component.units)]) + else: + x_choices = [''] + ProfileViewerState.x_display_unit.set_choices(self, x_choices) + self.x_display_unit = component.units + + def _update_y_display_unit_choices(self): + + component_units = set() + for layer_state in self.layers: + if isinstance(layer_state.layer, BaseData): + component = layer_state.layer.get_component(layer_state.attribute) + if component.units: + component_units.add((layer_state.layer, layer_state.attribute, component.units)) + y_choices = [None] + find_unit_choices(component_units) + ProfileViewerState.y_display_unit.set_choices(self, y_choices) @defer_draw def _reference_data_changed(self, before=None, after=None): @@ -190,6 +251,7 @@ def _reference_data_changed(self, before=None, after=None): self._update_att() self.reset_limits() + self._update_x_display_unit_choices() def _update_priority(self, name): if name == 'layers': @@ -298,6 +360,8 @@ def update_profile(self, update_limits=True): if not self._viewer_callbacks_set: self.viewer_state.add_callback('x_att', self.reset_cache, priority=100000) + self.viewer_state.add_callback('x_display_unit', self.reset_cache, priority=100000) + self.viewer_state.add_callback('y_display_unit', self.reset_cache, priority=100000) self.viewer_state.add_callback('function', self.reset_cache, priority=100000) if self.is_callback_property('attribute'): self.add_callback('attribute', self.reset_cache, priority=100000) @@ -338,6 +402,13 @@ def update_profile(self, update_limits=True): axis_view[pix_cid.axis] = slice(None) axis_values = data[self.viewer_state.x_att, tuple(axis_view)] + converter = UnitConverter() + axis_values = converter.to_unit(self.viewer_state.reference_data, + self.viewer_state.x_att, axis_values, + self.viewer_state.x_display_unit) + profile_values = converter.to_unit(data, self.attribute, profile_values, + self.viewer_state.y_display_unit) + self._profile_cache = axis_values, profile_values if update_limits: diff --git a/glue/viewers/profile/viewer.py b/glue/viewers/profile/viewer.py index 3817a6480..6fc3cc68c 100644 --- a/glue/viewers/profile/viewer.py +++ b/glue/viewers/profile/viewer.py @@ -1,3 +1,6 @@ +import numpy as np + +from glue.core.units import UnitConverter from glue.core.subset import roi_to_subset_state __all__ = ['MatplotlibProfileMixin'] @@ -8,6 +11,7 @@ class MatplotlibProfileMixin(object): def setup_callbacks(self): self.state.add_callback('x_att', self._update_axes) self.state.add_callback('normalize', self._update_axes) + self.state.add_callback('y_display_unit', self._update_axes) def _update_axes(self, *args): @@ -17,7 +21,10 @@ def _update_axes(self, *args): if self.state.normalize: self.state.y_axislabel = 'Normalized data values' else: - self.state.y_axislabel = 'Data values' + if self.state.y_display_unit: + self.state.y_axislabel = f'Data values [{self.state.y_display_unit}]' + else: + self.state.y_axislabel = 'Data values' self.axes.figure.canvas.draw_idle() @@ -33,5 +40,11 @@ def apply_roi(self, roi, override_mode=None): if len(self.layers) == 0: return + # Apply inverse unit conversion, converting from display to native units + converter = UnitConverter() + roi.min, roi.max = converter.to_native(self.state.reference_data, + self.state.x_att, np.array([roi.min, roi.max]), + self.state.x_display_unit) + subset_state = roi_to_subset_state(roi, x_att=self.state.x_att) self.apply_subset_state(subset_state, override_mode=override_mode) diff --git a/glue/viewers/table/qt/data_viewer.py b/glue/viewers/table/qt/data_viewer.py index ee1c7510e..d975fc9d9 100644 --- a/glue/viewers/table/qt/data_viewer.py +++ b/glue/viewers/table/qt/data_viewer.py @@ -73,7 +73,7 @@ def headerData(self, section, orientation, role): if orientation == Qt.Horizontal: column_name = self.columns[section].label units = self._data.get_component(self.columns[section]).units - if units != '': + if units is not None and units != '': column_name += "\n{0}".format(units) return column_name elif orientation == Qt.Vertical: