Tutorial

Este tutorial busca servir de una guia para la creación de clases utilizando uttrs.

DRAFT!

Versión interactiva

Puede ejecutarse este mismo tutorial de manera interactiba en Binder.

Binder

Imports

En primer lugar es necesario importar todas las librerias que vamos a utilizar. En general, son solo tres:

  • attr (attrs) que es la libreria en la cual se basa uttrs para crear clases con menos boiler plate.

  • astropy.units La cual contiene todo el marco de utilidades para tratar con unidades físicas/astronómicas.

  • uttr (uttrs) La librería que corresponde este tutorial.

    Nota: Este tutorial asume un conocimiento sobre estas librerías, una serie de enlaces de referencias pueden encontrarse al final de la página.

[1]:
import attr
import uttr

import astropy.units as u

The galaxy class

La clase que vamos a crear, consiste en una simplicación de la clase Galaxia del proyecot Galaxy-Chop.

Solo tiene 8 atributos y solo los primeros 7 tienen unidades y por lo tanto seran implementadas con la funcion uttr.ib de la librería. Estos sonx, y, z son las posiciones de las particulas/estrellas en KiloParsecs (kpc) ; vx, vy, vz las velocidades correspondientes a las particulas (\(Km/s\)); m su masa en masas solares (\(M_\odot\)). Todos

Finalmente notes texto libre sobre las galaxias y pueden ser implementadas con la librería attrs estandar

[2]:
@attr.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

Finalmente con la clase ya disponible podemos proceder a crear un objeto del tipo Galaxy,

Por cuestiones de simplicidadad, vamos a asumir solo 4 particulas con numeros totalmente arbitrarios en cada atributo.

Parte de la utilidad de usar uttrs, es la capacidad que tiene la libreria para agregar unidades por defecto automáticamente, o validar que la unidad ingresada sea equivalente.

Empecemos con un objeto en el cual todas las unidades se asignan autoḿaticamente

[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 made with random numbers",
)

Si nos fijamos en cualquier atributo de la clase, vamos a ver que todas las unidades se agregaron de manera correcta

[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 made with random numbers'

Galaxy with explicit units

Otra alternativa consiste en proveer unidades compatibles con las establecidas, hay que tener en cuenta que estas unidades tienen que ser equivalentes a las propuestas en la creacion de la clase.

Por ejemplo podemos sugerir que la dimension Z este dada en parsecs, vy en \(Km/h\) y las masas en \(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 made with random numbers",
)

Como se nota en el ejemplo, esto funciona perfectamente, y ningun error aparece. Es mas podemos acceder a culquiera de los atributos presentes y todos mantienen las unidades sugeridas o explicitas

[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}}$

Por otro lado si por error cargamos un valor que tiene una unidad que no es equivalente a la sugerida, se lanza un error de valor (ValueError)

Para demostrar esto vamos a extender el ejemplo, tratando de asignar a x un valor expresado en gramos (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 made with random numbers",
)
---------------------------------------------------------------------------
UnitConversionError                       Traceback (most recent call last)
~/proyectos/uttrs/src/uttr.py in validate_is_equivalent_unit(self, instance, attribute, value)
    135         try:
--> 136             unity.to(self.unit)
    137         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-0d84924447d4> 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)
    137         except u.UnitConversionError:
    138             unit, aname, ufound = self.unit, attribute.name, value.unit
--> 139             raise ValueError(
    140                 f"Unit of attribute '{aname}' must be equivalent to '{unit}'."
    141                 f" Found '{ufound}'."

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

Automatic cohersion of units: Array Accessor

El mayor poder de uttrs es la capacidad de transformar de manera sencilla todas las unidades a numpy.ndarray planos, utilizando las unidades por defecto.

Para esto se provee de una funcion uttr.array_accessor() la cual permite acceder a los atributos definidos por uttrs de manera uniforme en una estructura de datos mas veloz que las que possen unidad.

Para agregar esta característica, se debe agregar un atributo extra a la clase que se iguale a uttr.array_accessor(). Se propone utilizar el nombre arr_

Extendiendo el ejemplo anterior

[15]:
@attr.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))

    arr_ = uttr.array_accessor()  # el accessor

Ahora volvemos a instanciar la clase con algunos parámetros con unidades custom

[16]:
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 made with random numbers",
)

ahora si accedemos a z a travez de arr_, uttrs se encargara de convertir los parsecs en kiloparsecs y luego convertirlo en un numpy array

[17]:
gal.arr_.z
[17]:
array([1., 1., 1., 1.])

Mientras que z mantiene sus unidades originales

[18]:
gal.arr_.z
[18]:
array([1., 1., 1., 1.])

Lo mismo si accedemos a vy y m

[19]:
gal.arr_.m
[19]:
array([1.00582884e-28, 5.02914422e-29, 1.00582884e-29, 2.51457211e-30])
[20]:
gal.arr_.vy
[20]:
array([2.76555556, 0.23138889, 0.265     , 0.28444444])

Tratar de acceder a un atributo privado o que no sea un uttr.ib, lanza un error del tipo AttributeError

[21]:
gal.arr_.notes
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-21-92911293967b> in <module>
----> 1 gal.arr_.notes

~/proyectos/uttrs/src/uttr.py in __getattr__(self, a)
    278             return arr
    279
--> 280         raise AttributeError(f"No uttr.Attribute '{a}'")
    281
    282

AttributeError: No uttr.Attribute 'notes'

El uso de array_accessor

Es conocido que las unidades de Astropy son lentas cuando los calculos son complejos.

Para evitar esto, los desarrolladores optan por unificar las unidades y luego convertir los valores a arrays de numpy para operarlos mas rapidamente; y al final se vuelve a asignas las unidades.

Para evitar esto, array_accesor realiza toda esta trasnformacion transparentemente para el usuario, evitando la necesidad de replicar informacion sobre las unidades.

Por ejemplo, si quisieramos programar un codigo que genere un nuevo objeto galaxia con una sola particula, promedio de las demas, el código seria el siguiente:

[22]:
@attr.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))

    arr_ = uttr.array_accessor()  # el accessor

    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
        )

Ahora podemos crear una galaxia de 1 millon de elementos aleatorios y calcular la galaxia “media”

[23]:
import numpy as np

# fijamos la semilla random
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 made with random numbers",
)
[24]:
gal.mean()
[24]:
Galaxy(x=<Quantity 0.50002648 kpc>, y=<Quantity 0.49981983 kpc>, z=<Quantity 0.00049982 kpc>, vx=<Quantity 0.49976787 km / s>, vy=<Quantity 0.50029902 km / s>, vz=<Quantity 0.00013897 km / s>, m=<Quantity 2.5120227e-31 solMass>, notes='a random galaxy made with random numbers')

Por completitud se ejemplifica a continuacion como seria el miemos codigo de mean pero sin usar array_accessor

[25]:
@attr.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))

    arr_ = uttr.array_accessor()  # el accessor

    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
        )
[26]:
import datetime as dt
dt.datetime.now()
[26]:
datetime.datetime(2020, 11, 21, 20, 52, 24, 347474)
[ ]: