Tutorial¶
This tutorial is intended to serve as a guide on how to create classes using uttrs
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]:
[5]:
gal.y
[5]:
[6]:
gal.vx
[6]:
[7]:
gal.m
[7]:
[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]:
[11]:
gal.m # kg
[11]:
[12]:
gal.vx # default km/s
[12]:
[13]:
gal.vy # km/h
[13]:
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()
[ ]: