Skip to content
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ New Features

- Added furlong to imperial units.

- Added support for functional units, in particular the logarithmic ones
``Magnitude``, ``Decibel``, and ``Dex``. [#1894]

- ``astropy.utils``

- ``astropy.visualization``
Expand Down
3 changes: 3 additions & 0 deletions astropy/units/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@

from .equivalencies import *

from .function import (MagUnit, DecibelUnit, DexUnit,
Magnitude, Decibel, Dex, STmag, ABmag)

del bases

# Enable the set of default units. This notably does *not* include
Expand Down
118 changes: 68 additions & 50 deletions astropy/units/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
add_powers)
from . import format as unit_format

# TODO: Support functional units, e.g. log(x), ln(x)
# TODO: Support function units, e.g. log(x), ln(x)

__all__ = [
'UnitsError', 'UnitsWarning', 'UnitBase', 'NamedUnit',
Expand Down Expand Up @@ -95,8 +95,8 @@ def _normalize_equivalencies(equivalencies):
else:
raise ValueError(
"Invalid equivalence entry {0}: {1!r}".format(i, equiv))
if not (isinstance(funit, UnitBase) and
(isinstance(tunit, UnitBase) or tunit is None) and
if not (funit is Unit(funit) and
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you explain this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Initially, I tried to base my functional units on UnitBase (or CompositeUnit), but I found I needed to make lots of changes to core to get it to work (e.g., in all of the arithmetic; much cleaner if normal units just do not know how to deal with functional ones).

Hence, functional units are is not a UnitBase subclass, but for the equivalencies they have to behave like a unit in some respects. So, I'm trying to ducktype it, but I thought it was best to limit that to only a few places, hence here I use that Unit will very quickly pass through unchanged something that it considers to be a unit already.

In the end, I think it may be better to have a minimal Abstract Base Class for units; this may also be good if we ever would like to get the different python units packages to talk to each other.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see. I think what's here probably makes sense for now.

(tunit is None or tunit is Unit(tunit)) and
six.callable(a) and
six.callable(b)):
raise ValueError(
Expand Down Expand Up @@ -533,7 +533,6 @@ def names(self):
raise AttributeError(
"Can not get names from unnamed units. "
"Perhaps you meant to_string()?")
return self._names

@property
def name(self):
Expand Down Expand Up @@ -633,24 +632,30 @@ def __div__(self, m):
return self
return CompositeUnit(1, [self, m], [1, -1], _error_check=False)

# Cannot handle this as Unit, re-try as Quantity
from .quantity import Quantity
return Quantity(1, self) / m
try:
# Cannot handle this as Unit, re-try as Quantity
from .quantity import Quantity
return Quantity(1, self) / m
except TypeError:
return NotImplemented
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice.


def __rdiv__(self, m):
if isinstance(m, (bytes, six.text_type)):
return Unit(m) / self

# Cannot handle this as Unit. Here, m cannot be a Quantity,
# so we make it into one, fasttracking when it does not have a unit,
# for the common case of <array> / <unit>.
from .quantity import Quantity
if hasattr(m, 'unit'):
result = Quantity(m)
result /= self
return result
else:
return Quantity(m, self**(-1))
try:
# Cannot handle this as Unit. Here, m cannot be a Quantity,
# so we make it into one, fasttracking when it does not have a unit,
# for the common case of <array> / <unit>.
from .quantity import Quantity
if hasattr(m, 'unit'):
result = Quantity(m)
result /= self
return result
else:
return Quantity(m, self**(-1))
except TypeError:
return NotImplemented

__truediv__ = __div__

Expand All @@ -668,8 +673,11 @@ def __mul__(self, m):
return CompositeUnit(1, [self, m], [1, 1], _error_check=False)

# Cannot handle this as Unit, re-try as Quantity.
from .quantity import Quantity
return Quantity(1, self) * m
try:
from .quantity import Quantity
return Quantity(1, self) * m
except TypeError:
return NotImplemented

def __rmul__(self, m):
if isinstance(m, (bytes, six.text_type)):
Expand All @@ -678,13 +686,16 @@ def __rmul__(self, m):
# Cannot handle this as Unit. Here, m cannot be a Quantity,
# so we make it into one, fasttracking when it does not have a unit
# for the common case of <array> * <unit>.
from .quantity import Quantity
if hasattr(m, 'unit'):
result = Quantity(m)
result *= self
return result
else:
return Quantity(m, self)
try:
from .quantity import Quantity
if hasattr(m, 'unit'):
result = Quantity(m)
result *= self
return result
else:
return Quantity(m, self)
except TypeError:
return NotImplemented

def __hash__(self):
# This must match the hash used in CompositeUnit for a unit
Expand All @@ -699,6 +710,12 @@ def __eq__(self, other):
other = Unit(other, parse_strict='silent')
except (ValueError, UnitsError, TypeError):
return False

# Other is Unit-like, but the test below requires it is a UnitBase
# instance; if it is not, give up (so that other can try).
if not isinstance(other, UnitBase):
return NotImplemented

try:
return is_effectively_unity(self._to(other))
except UnitsError:
Expand Down Expand Up @@ -750,8 +767,8 @@ def is_equivalent(self, other, equivalencies=[]):
if isinstance(other, tuple):
return any(self.is_equivalent(u, equivalencies=equivalencies)
for u in other)
else:
other = Unit(other, parse_strict='silent')

other = Unit(other, parse_strict='silent')

return self._is_equivalent(other, equivalencies)

Expand Down Expand Up @@ -786,7 +803,7 @@ def _is_equivalent(self, other, equivalencies=[]):

return False

def _apply_equivalences(self, unit, other, equivalencies):
def _apply_equivalencies(self, unit, other, equivalencies):
"""
Internal function (used from `_get_converter`) to apply
equivalence pairs.
Expand All @@ -796,16 +813,14 @@ def convert(v):
return func(_condition_arg(v) / scale1) * scale2
return convert

orig_unit = unit
orig_other = other

unit = self.decompose()
other = other.decompose()
if hasattr(other, 'equivalencies'):
equivalencies += other.equivalencies
Copy link
Contributor

Choose a reason for hiding this comment

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

Have we grown equivalencies as members of Units again? I can't see where these actually get assigned here, though...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Normal units do not have these assigned at all, but my functional units do. It is important to allow that here so that one can do

physical_unit.to(functional_unit)

without explicitly giving the equivalency built in the functional unit.

Copy link
Contributor

Choose a reason for hiding this comment

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

Where does that happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It doesn't happen explicitly in the code, but needs to be supported to make the functional units helpful; see test_logarithmic.py, starting at line 71 (not sure how to give a link!); more explicitly, people should be able to do

u.W.to(lu.DecibelUnit(u.mW))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry, on your actual question: equivalencies get assigned in functional/core.py, l. 115.


for funit, tunit, a, b in equivalencies:
if tunit is None:
try:
ratio_in_funit = (other/unit).decompose([funit])
ratio_in_funit = (other.decompose() /
unit.decompose()).decompose([funit])
return make_converter(ratio_in_funit.scale, a, 1.)
except UnitsError:
pass
Expand Down Expand Up @@ -833,8 +848,8 @@ def get_err_str(unit):
unit_str = "'{0}'".format(unit_str)
return unit_str

unit_str = get_err_str(orig_unit)
other_str = get_err_str(orig_other)
unit_str = get_err_str(unit)
other_str = get_err_str(other)

raise UnitsError(
"{0} and {1} are not convertible".format(
Expand All @@ -846,7 +861,7 @@ def _get_converter(self, other, equivalencies=[]):
try:
scale = self._to(other)
except UnitsError:
return self._apply_equivalences(
return self._apply_equivalencies(
self, other, self._normalize_equivalencies(equivalencies))
return lambda val: scale * _condition_arg(val)

Expand Down Expand Up @@ -897,17 +912,20 @@ def _to(self, other):
if self is other:
return 1.0

self_decomposed = self.decompose()
other_decomposed = other.decompose()

# Check quickly whether equivalent. This is faster than
# `is_equivalent`, because it doesn't generate the entire
# physical type list of both units. In other words it "fails
# fast".
if(self_decomposed.powers == other_decomposed.powers and
all(self_base is other_base for (self_base, other_base)
in zip(self_decomposed.bases, other_decomposed.bases))):
return self_decomposed.scale / other_decomposed.scale
# Don't presume decomposition is possible; e.g.,
# conversion to function units is through equivalencies.
if isinstance(other, UnitBase):
self_decomposed = self.decompose()
other_decomposed = other.decompose()

# Check quickly whether equivalent. This is faster than
# `is_equivalent`, because it doesn't generate the entire
# physical type list of both units. In other words it "fails
# fast".
if(self_decomposed.powers == other_decomposed.powers and
all(self_base is other_base for (self_base, other_base)
in zip(self_decomposed.bases, other_decomposed.bases))):
return self_decomposed.scale / other_decomposed.scale

raise UnitsError(
"'{0!r}' is not a scaled version of '{1!r}'".format(self, other))
Expand Down Expand Up @@ -1719,7 +1737,7 @@ def __call__(self, s, represents=None, format=None, namespace=None,
doc=None, parse_strict='raise'):

# Short-circuit if we're already a unit
if isinstance(s, UnitBase):
if hasattr(s, '_get_physical_type_id'):
return s

# turn possible Quantity input for s or represents into a Unit
Expand Down
9 changes: 9 additions & 0 deletions astropy/units/function/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Licensed under a 3-clause BSD style license - see LICENSE.rst

"""
This subpackage contains classes and functions for defining and converting
between different function units and quantities, i.e., using units which
are some function of a physical unit, such as magnitudes and decibels.
"""
from .core import *
from .logarithmic import *
Loading