Tutorial

This tutorial is intended to serve as a guide on how to create classes using uttrs

Interactive Version

Launch Binder for an interactive version of this tutorial!

Binder

Imports

Let’s first import all necessary libraries at the top. You will generally need just three:

  • attr (attrs) is the library uttrs is based on. Attrs creates classes with less boilerplate code.

  • astropy.units is a library that contains all the machinery to deal with astronomical and physical units.

  • uttr (uttrs) is the library we will explore on this tutorial.

    Note: This tutorial assumes knowledge on the aforementioned libraries. Please refer to the reference links at the end of this notebook for more information.

[1]:
import attr  # to use the .validators module
import uttr
import astropy.units as u

The Galaxy Class

We will create a stripped-down version of the Galaxy class from the Galaxy-Chop project.

It will have only 8 attributes. The first 7 will have units attached and will be implemented with uttr.ib. These are:

  • x, y, z: The postions of the particles (typically stars) from the center of the galaxy measured in KiloParsecs (\(kpc\)).

  • vx, vy, vz: The relative velocity components of the particles measured in \(km/s\).

  • m: Masses of the particles in units of solar masses (\(M_\odot\)).

The last attribute notes is a description text about the galaxy and can be implemented with the standar attrs library.

[2]:
@uttr.s
class Galaxy:
    x = uttr.ib(unit=u.kpc)
    y = uttr.ib(unit=u.kpc)
    z = uttr.ib(unit=u.kpc)

    vx = uttr.ib(unit=u.km / u.s)
    vy = uttr.ib(unit=u.km / u.s)
    vz = uttr.ib(unit=u.km / u.s)

    m = uttr.ib(unit=u.M_sun)

    notes = attr.ib(validator=attr.validators.instance_of(str))

Galaxy with Default Units

Now that we created our class, we can go ahead and create an object of type Galaxy.

To keep it simple, let’s assume only 4 particles with totally arbitrary numbers on each attribute.

Part of uttrs power is its ability to assign default units when not provided, or to validate that the input unit is physically compatible with the given default.

Let’s see first an example in which all units are assigned automatically.

[3]:
gal = Galaxy(
    x=[1, 1, 3, 4],
    y=[10, 2, 3, 100],
    z=[1, 1, 1, 1],
    vx=[1000, 1023, 2346, 1334],
    vy=[9956, 833, 954, 1024],
    vz=[1253, 956, 1054, 3568],
    m=[200, 100, 20, 5],
    notes="A random galaxy with arbitrary numbers.",
)

Let’s verify that all attributes of the class were given the correct units.

[4]:
gal.x
[4]:
$[1,~1,~3,~4] \; \mathrm{kpc}$
[5]:
gal.y
[5]:
$[10,~2,~3,~100] \; \mathrm{kpc}$
[6]:
gal.vx
[6]:
$[1000,~1023,~2346,~1334] \; \mathrm{\frac{km}{s}}$
[7]:
gal.m
[7]:
$[200,~100,~20,~5] \; \mathrm{M_{\odot}}$
[8]:
gal.notes
[8]:
'A random galaxy with arbitrary numbers.'

Galaxy with Explicit Units

A different alternative is to provide units compatible with the default unit. In this case, we have to be mindful of the phyisical equivalence of units with the ones given at the time the class was created.

For example, we could suggest that the dimension z be given in parsecs, vy in \(km/h\) and masses in \(kg\).

[9]:
gal = Galaxy(
    x=[1, 1, 3, 4],
    y=[10, 2, 3, 100],
    z=[1000, 1000, 1000, 1000] * u.parsec,
    vx=[1000, 1023, 2346, 1334],
    vy=[9956, 833, 954, 1024] * (u.km / u.h),
    vz=[1253, 956, 1054, 3568],
    m=[200, 100, 20, 5] * u.kg,
    notes="A random galaxy with arbitrary numbers.",
)

As we note above, this works as expected without error. We can further access any of the attributes and verify that they keep the suggested units.

[10]:
gal.z  # parsecs
[10]:
$[1000,~1000,~1000,~1000] \; \mathrm{pc}$
[11]:
gal.m  # kg
[11]:
$[200,~100,~20,~5] \; \mathrm{kg}$
[12]:
gal.vx  # default km/s
[12]:
$[1000,~1023,~2346,~1334] \; \mathrm{\frac{km}{s}}$
[13]:
gal.vy # km/h
[13]:
$[9956,~833,~954,~1024] \; \mathrm{\frac{km}{h}}$

On the other hand, if we try to input a unit that is incompatible with the suggested input unit, a ValueError exception is raised.

To show this, let’s try to assign x values with units of grams (\(g\)).

[14]:
gal = Galaxy(
    x=[1, 1, 3, 4] * u.g,
    y=[10, 2, 3, 100],
    z=[1000, 1000, 1000, 1000] * u.parsec,
    vx=[1000, 1023, 2346, 1334],
    vy=[9956, 833, 954, 1024] * (u.km / u.h),
    vz=[1253, 956, 1054, 3568],
    m=[200, 100, 20, 5] * u.kg,
    notes="A random galaxy with arbitrary numbers.",
)
---------------------------------------------------------------------------
UnitConversionError                       Traceback (most recent call last)
~/proyectos/uttrs/src/uttr.py in validate_is_equivalent_unit(self, instance, attribute, value)
    131         try:
--> 132             unity.to(self.unit)
    133         except u.UnitConversionError:

~/proyectos/uttrs/lib/python3.8/site-packages/astropy/units/quantity.py in to(self, unit, equivalencies)
    688         unit = Unit(unit)
--> 689         return self._new_view(self._to_value(unit, equivalencies), unit)
    690

~/proyectos/uttrs/lib/python3.8/site-packages/astropy/units/quantity.py in _to_value(self, unit, equivalencies)
    659             equivalencies = self._equivalencies
--> 660         return self.unit.to(unit, self.view(np.ndarray),
    661                             equivalencies=equivalencies)

~/proyectos/uttrs/lib/python3.8/site-packages/astropy/units/core.py in to(self, other, value, equivalencies)
    986         else:
--> 987             return self._get_converter(other, equivalencies=equivalencies)(value)
    988

~/proyectos/uttrs/lib/python3.8/site-packages/astropy/units/core.py in _get_converter(self, other, equivalencies)
    917
--> 918             raise exc
    919

~/proyectos/uttrs/lib/python3.8/site-packages/astropy/units/core.py in _get_converter(self, other, equivalencies)
    902         try:
--> 903             return self._apply_equivalencies(
    904                 self, other, self._normalize_equivalencies(equivalencies))

~/proyectos/uttrs/lib/python3.8/site-packages/astropy/units/core.py in _apply_equivalencies(self, unit, other, equivalencies)
    885
--> 886         raise UnitConversionError(
    887             "{} and {} are not convertible".format(

UnitConversionError: 'g' (mass) and 'kpc' (length) are not convertible

During handling of the above exception, another exception occurred:

ValueError                                Traceback (most recent call last)
<ipython-input-14-ab318fb696d1> in <module>
----> 1 gal = Galaxy(
      2     x=[1, 1, 3, 4] * u.g,
      3     y=[10, 2, 3, 100],
      4     z=[1000, 1000, 1000, 1000] * u.parsec,
      5     vx=[1000, 1023, 2346, 1334],

<attrs generated init __main__.Galaxy> in __init__(self, x, y, z, vx, vy, vz, m, notes)
      9     self.notes = notes
     10     if _config._run_validators is True:
---> 11         __attr_validator_x(self, __attr_x, self.x)
     12         __attr_validator_y(self, __attr_y, self.y)
     13         __attr_validator_z(self, __attr_z, self.z)

~/proyectos/uttrs/lib/python3.8/site-packages/attr/_make.py in __call__(self, inst, attr, value)
   2721     def __call__(self, inst, attr, value):
   2722         for v in self._validators:
-> 2723             v(inst, attr, value)
   2724
   2725

~/proyectos/uttrs/src/uttr.py in validate_is_equivalent_unit(self, instance, attribute, value)
    133         except u.UnitConversionError:
    134             unit, aname, ufound = self.unit, attribute.name, value.unit
--> 135             raise ValueError(
    136                 f"Unit of attribute '{aname}' must be equivalent to '{unit}'."
    137                 f" Found '{ufound}'."

ValueError: Unit of attribute 'x' must be equivalent to 'kpc'. Found 'g'.

Automatic Cohersion of Units: Array Accessor

One powerful feauture of uttrs is the ability to easily transform all units to plain numpy.ndarray, using the default units.

This is achieved using the uttr.array_accessor() function. This allows for uniform access of attributes defined by uttrs, in a data structure that has faster access time than its counterpart with units.

By default the @uttr.s automataclly add an array accessor to decorated class. You can disabled this functionallity using the decorator like @uttr.s(aaccessor=None), or change the name of the property with @uttr.s(aaccessor="other_name").

Expanding on the previous example:

[ ]:
@uttr.s
class Galaxy:
    x = uttr.ib(unit=u.kpc)
    y = uttr.ib(unit=u.kpc)
    z = uttr.ib(unit=u.kpc)

    vx = uttr.ib(unit=u.km / u.s)
    vy = uttr.ib(unit=u.km / u.s)
    vz = uttr.ib(unit=u.km / u.s)

    m = uttr.ib(unit=u.M_sun)

    notes = attr.ib(validator=attr.validators.instance_of(str))

Let’s instantiate the class again with some parameters with custom units.

[ ]:
gal = Galaxy(
    x=[1, 1, 3, 4],
    y=[10, 2, 3, 100],
    z=[1000, 1000, 1000, 1000] * u.parsec,
    vx=[1000, 1023, 2346, 1334],
    vy=[9956, 833, 954, 1024] * (u.km / u.h),
    vz=[1253, 956, 1054, 3568],
    m=[200, 100, 20, 5] * u.kg,
    notes="A random galaxy with arbitrary numbers.",
)

If we now access z through our arr_ accessor, uttrs will convert the values in parsec units to kiloparsecs and return a uniform numpy array.

[ ]:
gal.arr_.z

While z keeps its original unit.

[ ]:
gal.arr_.z

The same applies to vy and m.

[ ]:
gal.arr_.m
[ ]:
gal.arr_.vy

If we try to access a private attribute not from uttr.ib, an AttributeError exception is raised.

[ ]:
gal.arr_.notes

Using the array_accessor

It is a known issue that Astropy units can slow down complex computations.

To avoid this, developers usually choose to uniformize units and convert the values to numpy arrays to operate on them faster; reverting back to values with units at the end of the calculation.

As a helper, array_accesor will perform the transformation in a transparent way to the user, avoiding the need to replicate information regarding units.

For example, if we wanted to program code that generates a new Galaxy object with a single particle that is the average mean of all the rest, we could do something like this:

[ ]:
@uttr.s
class Galaxy:
    x = uttr.ib(unit=u.kpc)
    y = uttr.ib(unit=u.kpc)
    z = uttr.ib(unit=u.kpc)

    vx = uttr.ib(unit=u.km / u.s)
    vy = uttr.ib(unit=u.km / u.s)
    vz = uttr.ib(unit=u.km / u.s)

    m = uttr.ib(unit=u.M_sun)

    notes = attr.ib(validator=attr.validators.instance_of(str))

    def mean(self):
        x = np.mean(self.arr_.x)
        y = np.mean(self.arr_.y)
        z = np.mean(self.arr_.z)

        vx = np.mean(self.arr_.vx)
        vy = np.mean(self.arr_.vy)
        vz = np.mean(self.arr_.vz)

        m = np.mean(self.arr_.m)

        return Galaxy(
            x=x, y=y, z=z, vx=vx, vy=vy, vz=vz, m=m, notes=self.notes
        )

We could now create a galaxy with 1 million random elements and calculate the “average” galaxy.

[ ]:
import numpy as np

# Fix random seed
random = np.random.default_rng(seed=42)

size = 1_000_000

gal = Galaxy(
    x=random.random(size=size),
    y=random.random(size=size),
    z=random.random(size=size) * u.parsec,
    vx=random.random(size=size),
    vy=random.random(size=size),
    vz=random.random(size=size) * (u.km / u.h),
    m=random.random(size=size) * u.kg,
    notes="A random galaxy with arbitrary numbers.",
)
[ ]:
gal.mean()

To complete the example, let’s see how would a mean method look like without array_accessor.

[ ]:
@uttr.s(aaccessor=None)
class Galaxy:
    x = uttr.ib(unit=u.kpc)
    y = uttr.ib(unit=u.kpc)
    z = uttr.ib(unit=u.kpc)

    vx = uttr.ib(unit=u.km / u.s)
    vy = uttr.ib(unit=u.km / u.s)
    vz = uttr.ib(unit=u.km / u.s)

    m = uttr.ib(unit=u.M_sun)

    notes = attr.ib(validator=attr.validators.instance_of(str))

    def mean(self):
        x = np.mean(self.x.to_value(u.kpc))
        y = np.mean(self.y.to_value(u.kpc))
        z = np.mean(self.z.to_value(u.kpc))

        vx = np.mean(self.vx.to_value(u.km / u.s))
        vy = np.mean(self.vy.to_value(u.km / u.s))
        vz = np.mean(self.vz.to_value(u.km / u.s))

        m = np.mean(self.m.to_value(u.M_sun))

        return Galaxy(
            x=x, y=y, z=z, vx=vx, vy=vy, vz=vz, m=m, notes=self.notes
        )
[ ]:
import datetime as dt
dt.date.today().isoformat()
[ ]: