Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added ability to convert units in profile viewer #2296

Merged
merged 18 commits into from
Jan 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions doc/customizing_guide/units.rst
Original file line number Diff line number Diff line change
@@ -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).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks backward compatibility -- See failures in https://github.com/spacetelescope/jdaviz/actions/runs/4053913069/jobs/6975103932

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, will have a think if there is a way we can avoid breaking backward compatibility

By default, glue uses `astropy.units <https://docs.astropy.org/en/stable/units/index.html>`_ package
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you just wanna use intersphinx here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

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::
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, this is a way to bypass built-in astropy equivalencies? 👀

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes


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'
1 change: 1 addition & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------------
Expand Down
34 changes: 33 additions & 1 deletion glue/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
4 changes: 2 additions & 2 deletions glue/core/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
12 changes: 10 additions & 2 deletions glue/core/coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions glue/core/subset_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions glue/core/tests/test_coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]])
Expand Down
112 changes: 112 additions & 0 deletions glue/core/tests/test_units.py
Original file line number Diff line number Diff line change
@@ -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']
Loading