↓ Skip to main content

Python, Units, and Beer(?!): The pint Package

·3100 words·15 mins·
 Author
Author
Francisco Bustamante
Chemist, data scientist and Python programmer.
Table of Contents

Whenever we work with measurements and experimental data, we need to express our values with their respective units. In this article, we will see a Python library for working with units and what that has to do with beer.

If you prefer to watch a video (in Portuguese), click the player below. The full article follows the video.

The pint package
#

The pint package lets you work with physical quantities: the product of a numerical value and a unit of measure. It has an extensive list of units, prefixes, and constants that can also be extended by the user. It is compatible with several operations of the widely used numpy package.

If you are curious about why the symbol on the project website is a beer glass, read this Wikipedia article. The name is nothing more than a reference to a traditional beer glass, very common in European countries and in the United States. And, of course, each country has its own volume for 1 pint, as shown in this table. A great name for a unit conversion package, right?

pint requires Python 3.6+ and has no other dependencies.

Installation is quite simple, as shown in the documentation, and can be done via conda or pip:

pip install pint

See this article if you want to install it in virtual environments.

pint works with the concept of a unit registry. After importing the package, you create a registry that will store and evaluate the units of the whole code:

import pint

ureg = pint.UnitRegistry()

Of course, this registry, which is just a variable, can have any name, but the usual convention, following the documentation, is to call it ureg.

We can now check that the package will handle operations involving units:

1 * ureg.meter + 100 * ureg.cm
2.0 meter

Notice that in the sum above, the package recognized that 100 cm is equivalent to 1 m and returned the result as 2 m. Let us go through the use of the package slowly to understand this behavior.

Creating quantities
#

Let us consider a simple situation, where we want to calculate an average speed, given a change in distance over a given time interval. We can create a variable with magnitude and unit in two ways. The first is through the concept that a quantity is a numerical value multiplied by a unit:

distance = 50 * ureg.meter
distance
50 meter

The second is via a quantity constructor. Again, the constructor can have any name, but the convention created by the package documentation is to call it Q_:

Q_ = ureg.Quantity
time = Q_(10, ureg.second)
time
10 second

To calculate the average speed, just follow the physical definition:

\[\overline{v} = \frac{\Delta x}{\Delta t} \]

where \(\overline{v}\) is the average speed, \(x\) is the distance, \(t\) is the time, and the symbol \(\Delta\) indicates change.

average_speed = distance / time
average_speed
5.0 meter/second

Notice that the unit of the speed is correct, considering the units passed to the variables related to distance and time.

We can check that, regardless of how the variables were defined, they are all of type Quantity:

print(type(distance))
print(type(time))
print(type(average_speed))
<class 'pint.quantity.build_quantity_class.<locals>.Quantity'>
<class 'pint.quantity.build_quantity_class.<locals>.Quantity'>
<class 'pint.quantity.build_quantity_class.<locals>.Quantity'>

This can also be verified by checking the repr of each of these objects:

print(repr(distance))
print(repr(time))
print(repr(average_speed))
<Quantity(50, 'meter')>
<Quantity(10, 'second')>
<Quantity(5.0, 'meter / second')>

An interesting thing we can notice from the repr is that the unit can also be passed as a string. And here we have a great power of pint. See how we could declare the distance in several ways:

10 * ureg.meter
10 meter
10 * ureg.m
10 meter
10 * ureg('meter')
10 meter
10 * ureg('m')
10 meter
Q_(10, ureg.meter)
10 meter
Q_('10 m')
10 meter

Notice that the last form is the way we usually describe a quantity, by hand and in written work. This is a great advantage of pint: the package allows parsing (transforming) strings, so I consider this the easiest form. For example, the universal gas constant, in SI units and rounded to 3 decimal places, could be declared as:

Q_('8.314 J/(mol*K)')
8.314 joule/(kelvin mole)

Notice the care with parentheses. Units also follow precedence rules in the usual way.

Converting units
#

One way to convert is by using the to method:

average_speed.to(ureg.km / ureg.hour)
18.0 kilometer/hour

Since we have already seen that the package accepts strings:

average_speed.to('km/hour')  # kilometers per hour
18.0 kilometer/hour
average_speed.to('ft/s')  # feet per second
16.404199475065617 foot/second

When using to, the quantity bound to the variable keeps its original units:

average_speed
5.0 meter/second

For the conversion to be made permanent, we must use the ito method (the i can be understood as inplace):

average_speed.ito('km/hour')
average_speed
18.0 kilometer/hour

pint has the to_base_units method (and its analog ito_base_units) to convert a given quantity to the units of the system of units defined in the unit registry. By default, the unit registry uses the International System of Units (SI). We can check all the available systems:

dir(ureg.sys)
['Planck', 'SI', 'US', 'atomic', 'cgs', 'imperial', 'mks']

Since the default system is SI, we can take the average speed, currently stored in kilometers per hour, and request the conversion to SI units:

average_speed.to_base_units()
5.0 meter/second

Considering a context, for example, of being in the Imperial System, we can define that system:

ureg.default_system = 'imperial'

Now, the average speed can be converted to the units of the imperial system (yards/second):

average_speed.to_base_units()
5.46806649168854 yard/second

Let us go back to SI:

ureg.default_system = 'SI'
average_speed.to_base_units()
5.0 meter/second

Let us permanently convert back to SI units:

average_speed.ito_base_units()
average_speed
5.0 meter/second

Getting to know each part of the quantity
#

As we have already described, a physical quantity is defined by a value, the magnitude, and its respective unit. We can check each of these entities:

average_speed.magnitude
5.0
average_speed.units
meter/second

We can also check the dimensions, to do a dimensional analysis:

average_speed.dimensionality
<UnitsContainer({'[length]': 1, '[time]': -1})>

Applying to a real case
#

In one of the latest articles on the site, we saw the case of an airplane that ran out of fuel during the flight. That situation was caused by a unit conversion error, as described in the post.

I will take advantage of the case to show how to work with units when defining functions.

It is obvious, but worth stressing, that the case is complex and some simplifications will be made to keep the focus on explaining the concepts involved. Those who want more details about measuring fuel density and about fuel consumption in aviation can read here and here (both in Portuguese).

As we saw in the article, the density that should have been used in the calculations should be in kg/l. In a real situation, a density table is consulted to find the value to use depending on the temperature (or a density-measuring device is used). Here, we will simplify by assuming that the density is constant, with a value of 0.803 kg/l, the value that should have been used in the case in question:

fuel_density = Q_('0.803 kg/l')
fuel_density
0.803 kilogram/liter

Recalling the calculations from the article:

  • Fuel mass already present: \(7{,}682 \text{ L} \times 0.803 \text{ kg L}^{-1} = 6{,}169 \text{ kg}\)
  • Fuel mass to add: \(22{,}300 \text{ kg} - 6{,}169 \text{ kg} = 16{,}131 \text{ kg}\)
  • Conversion to volume: \(\dfrac{16{,}131 \text{ kg}}{0.803 \text{ kg L}^{-1}} = 20{,}088 \text{ L}\)

So, we can think, at first, of defining a function that will receive the volume of fuel present in the tank and the total mass of fuel needed for the trip. Then it associates these values with units and returns the volume of fuel needed to refuel the airplane. Let us define this function:

def refuel_volume_implementation1(current_volume, total_mass, fuel_density=Q_('0.803 kg/l')):
    '''Returns the volume of fuel that should refuel the airplane.
    
    Parameters
    ----------
    current_volume: float, a value in liters is expected
    total_mass: float, a value in kilograms is expected
    fuel_density: pint.Quantity, value in kg/l, default of 0.803 kg/l
    
    Returns
    -------
    volume to refuel: float, value in liters
    
    NOTE: First implementation, not the ideal one. See the most suitable implementation later in the article.
    '''
    
    current_volume = Q_(current_volume, 'l')
    total_mass = Q_(total_mass, 'kg')    
    current_mass = current_volume * fuel_density
    mass_to_add = total_mass - current_mass
    
    return mass_to_add / fuel_density
refuel_volume_implementation1(7682, 22300)
20088.85927770859 liter

Notice that the returned answer is correct. However, the implementation of the function is still not ideal because the function receives only numerical values. Even though the documentation (DOCUMENT your code!) says that the volume and mass values must be in liters and kilograms, respectively, nothing prevents values in other units from being passed by mistake. Someone could, for example, inadvertently pass a volume in cubic meters, and nothing in the code would warn that this is an error.

Let us reimplement the function:

def refuel_volume_implementation2(current_volume, total_mass, fuel_density=Q_('0.803 kg/l')):
    '''Returns the volume of fuel that should refuel the airplane.
    
    Parameters
    ----------
    current_volume: pint.Quantity, a value in liters is expected
    total_mass: pint.Quantity, a value in kilograms is expected
    fuel_density: pint.Quantity, by default '0.803 kg/l'
    
    
    Returns
    -------
    volume to refuel: pint.Quantity, value in liters
    
    NOTE: Not the ideal implementation. See the most suitable implementation later in the article.
    '''
    
    if isinstance(current_volume, pint.Quantity) and isinstance(total_mass, pint.Quantity):            
        current_mass = current_volume * fuel_density
        mass_to_add = total_mass - current_mass
        return mass_to_add / fuel_density
    else:
        raise ValueError('Provide the values as Pint physical quantities (pint.Quantity)')

In this implementation, I start by checking whether the parameters are of the correct type. This is not very usual in Python, but it serves what we want for now. If the type is not correct, an error is shown to the user.

Let us now check how the function behaves when passing values without units:

refuel_volume_implementation2(7682, 22300)
---------------------------------------------------------------------------

ValueError                                Traceback (most recent call last)

<ipython-input-41-d3388580cae6> in <module>
----> 1 refuel_volume_implementation2(7682, 22300)


<ipython-input-40-e5d679f70da5> in refuel_volume_implementation2(current_volume, total_mass, fuel_density)
     21         return mass_to_add / fuel_density
     22     else:
---> 23         raise ValueError('Provide the values as Pint physical quantities (pint.Quantity)')


ValueError: Provide the values as Pint physical quantities (pint.Quantity)

Great! Now let us create quantities to pass to the function.

current_vol = Q_('7682 l')
total_m = Q_('22300 kg')
refuel_volume_implementation2(current_vol, total_m)
20088.85927770859 liter

Great! Correct answer.

There is still a “side effect” of the implementation as it was done. If we pass the values in other units, pint will not take care of converting them automatically to the units of the measurement system in use. That is, if, for example, we pass the mass in pounds, we will get a somewhat strange answer:

total_m.ito('lb')
total_m
49163.084467227694 pound
refuel_volume_implementation2(current_vol, total_m)
44288.35361077301 liter pound/kilogram

Notice that the units pound and kilogram were not unified, even though they are of the same dimension. To solve situations like this, pint has the to_reduced_units method, which combines units that have the same dimensionality. According to the documentation, this feature can be enabled globally but, by default, it is disabled for performance reasons. So let us redefine our function to solve this small problem:

def refuel_volume_implementation3(current_volume, total_mass, fuel_density=Q_('0.803 kg/l')):
    '''Returns the volume of fuel that should refuel the airplane.
    
    Parameters
    ----------
    current_volume: pint.Quantity, a value in liters is expected
    total_mass: pint.Quantity, a value in kilograms is expected
    fuel_density: pint.Quantity, by default '0.803 kg/l'
    
    Returns
    -------
    volume to refuel: pint.Quantity, value in liters
    
    NOTE: Not the ideal implementation. See the most suitable implementation later in the article.
    '''
    
    if isinstance(current_volume, pint.Quantity) and isinstance(total_mass, pint.Quantity):            
        current_mass = current_volume * fuel_density
        mass_to_add = total_mass - current_mass
        return (mass_to_add / fuel_density).to_reduced_units()
    else:
        raise ValueError('Provide the values as Pint physical quantities (pint.Quantity)')

Checking the units of the parameters to be passed:

print(current_vol)
print(total_m)
7682 liter
49163.084467227694 pound

Checking the result:

refuel_volume_implementation3(current_vol, total_m)
20088.85927770859 liter

Great! Correct answer. Now the user can pass the parameters in any system of units, and pint will take care of converting internally.

But we are still not at an ideal implementation…

Imagine a real program. In real code, this would be just one of the dozens of functions in the program. There would also be classes and other modules. In short, a whole universe of code to be maintained. Most likely, other parts of this code would also deal with physical quantities. Imagine checking in every function or method whether the correct type was passed to it? That would be a lot of repeated code with the same functionality, something that is best avoided.

Since this is a package developed to deal with units and has a very active development community, surely someone has already thought of solving this kind of problem. For that, there is wraps.

wraps is a decorator. The decorator concept provides a simple way to modify the behavior of a function without necessarily changing it. It is nothing more than a method to wrap a function, modifying its behavior.

In this case, wraps receives two parameters in the form of a tuple. The first refers to the units of the return value of the wrapped function and the second to the units of the parameters of that function. So, our new implementation becomes:

@ureg.wraps(('l'), ('l', 'kg', 'kg/l'))
def refuel_volume(current_volume, total_mass, fuel_density=Q_('0.803 kg/l')):
    '''Returns the volume of fuel that should refuel the airplane.
    
    Parameters
    ----------
    current_volume: pint.Quantity, a value in liters is expected
    total_mass: pint.Quantity, a value in kilograms is expected
    fuel_density: pint.Quantity, by default '0.803 kg/l'    
    
    Returns
    -------
    volume to refuel: pint.Quantity, value in liters
    '''
    
    current_mass = current_volume * fuel_density
    mass_to_add = total_mass - current_mass
    
    return mass_to_add / fuel_density

Notice the changes made: removal of the isinstance check and removal of to_reduced_units. The first change is because wraps now takes care of the check. The second is because only values (magnitudes) are passed into the function. The units are checked beforehand and stripped, the calculations are done, and the units are applied to the return value. This improves the performance of the program.

Let us check the behavior when values without units are passed:

refuel_volume(7682, 22300)
---------------------------------------------------------------------------

ValueError                                Traceback (most recent call last)

<ipython-input-52-98280d6f7137> in <module>
----> 1 refuel_volume(7682, 22300)


~/Dropbox/work/cienciaprogramada/code/pint/.venv/lib/python3.8/site-packages/pint/registry_helpers.py in wrapper(*values, **kw)
    263             # In principle, the values are used as is
    264             # When then extract the magnitudes when needed.
--> 265             new_values, values_by_name = converter(ureg, values, strict)
    266 
    267             result = func(*new_values, **kw)


~/Dropbox/work/cienciaprogramada/code/pint/.venv/lib/python3.8/site-packages/pint/registry_helpers.py in _converter(ureg, values, strict)
    147                         )
    148                     else:
--> 149                         raise ValueError(
    150                             "A wrapped function using strict=True requires "
    151                             "quantity or a string for all arguments with not None units. "


ValueError: A wrapped function using strict=True requires quantity or a string for all arguments with not None units. (error found for l, 7682)

Great, it raised an error, which is the behavior we want.

Let us check the behavior when passing quantities:

current_vol = Q_('7682 l')
total_m = Q_('22300 kg')
refuel_volume(current_vol, total_m)
20088.85927770858 liter

Cool, the expected result again. Finally, let us check what happens with this new version when a parameter is passed in another system of units.

total_m.ito('lb')
total_m
49163.084467227694 pound
refuel_volume(current_vol, total_m)
20088.85927770858 liter

Great!! Now we have our function implemented with best practices and safer to use.

Improving the appearance of the result
#

Last but not least, let us see how to improve the look of our answer. In the context we are in, refueling an airplane with thousands of liters of fuel, surely any decimal place can be disregarded. After all, as we saw in the article about the airplane case, values are usually rounded. First, let us store the result in a variable:

volume = refuel_volume(current_vol, total_m)
volume
20088.85927770858 liter

The formatting options available for f-strings work with pint:

print(f'{volume:.2f}')
20088.86 liter
print(f'{volume:.0f}')
20089 liter
print(f'{volume:.0e}')
2e+04 liter
print(f'{volume:.2e}')
2.01e+04 liter

A more visual formatting, with unit symbols and powers of 10 (when they exist), can be obtained with ~P:

print(f'{volume:.2e~P}')
2.01×10⁴ l

In some situations, we may want to export the result for use in a document. And, in science and engineering, we use LaTeX a lot. So, we can express the result as LaTeX code:

print(f'{volume:.2e~L}')
2.01\times 10^{4}\ \mathrm{l}

Users of the LaTeX siunitx package will be happy to know that it is possible to export using the package syntax:

print(f'{volume:.2e~Lx}')
\SI[]{2.01e+04}{\liter}

If you have the babel package installed, you can even get translated units (here, into Brazilian Portuguese):

volume.format_babel(locale='pt_BR')
'20088.85927770858 litros'
# reusing the average_speed defined at the beginning of the article
average_speed.format_babel(locale='pt_BR')
'5.0 metros por segundos'

Versions of the packages used in this document
#

This article was written in a Jupyter Notebook. The Jupyter Notebook is a graphical interface that allows editing notebooks in a web browser, such as Google Chrome or Firefox. Notebooks are virtual documents that allow code execution together with tools for text editing; that is, in addition to the usual programming routines, the user can document the whole process of producing the code. Exactly as was done here, with text explaining each piece of code. In this way, the notebook allows an interactive way of programming. It also allows more dynamic programming, offering the user the immediate output of the code; thus, there is no need to compile or run the whole document.

I have already written here on the site about the Anaconda Project. When installing Anaconda, the Jupyter Notebook and JupyterLab programs are installed. Both allow you to interact with notebooks.

It is always important to know the versions of each package we use, because some features may change over time as each project’s development advances. The version_information package helps with this check in Jupyter Notebooks but unfortunately is not currently compatible with Python 3.8. Below I list the relevant version information used when writing this notebook:

  • Python 3.8.5
  • IPython 7.18.1
  • pint 0.15
  • babel 2.8.0
  • notebook generated on Linux Mint 20 with Linux kernel 5.4.0 45 generic x86_64 with glibc2.29

The notebook is in this repository of mine on GitHub. The same repository also has the Pipfile files that allow you to create a virtual environment exactly like the one I created to write this article and the code shown. If you want to know more about virtual environments, how to create them from scratch and from files like Pipfile, read this article.

Conclusion
#

So, did you like it? It is a very powerful package and we have not even seen half of its features! But I hope you have realized that there are no more excuses for writing code that involves physical quantities without considering their units.

In the next article on the site, I will explore more aspects of using pint. After all, how do we use it with lists of values? Arrays? Combined with the package most used in science, numpy? And how do we use it when there are uncertainties associated with the values? And in plots? Find out here in this article

See you next time!

Related

Studying Our Atmosphere with Python

Colorless, made of invisible constituents, and odorless (we hope!). We often forget that we are surrounded by the gases that make up our atmosphere. In this article, we will see how to easily get a list of the main constituents of air with Python and web scraping. We will also do some calculations, such as density and mean molar mass, and, of course, present everything in spectacular plots.

NASA Loses Millions of Dollars to Unit Errors

·3144 words·15 mins
In this article we will see how NASA has lost multimillion-dollar projects to simple unit conversion errors. We will also see which system of units took humans to the Moon in the Apollo Program, by looking at the source code of the onboard computer.