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 pintSee 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.cm2.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
distance50 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)
time10 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_speed5.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.meter10 meter
10 * ureg.m10 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 hour18.0 kilometer/hour
average_speed.to('ft/s') # feet per second16.404199475065617 foot/second
When using to, the quantity bound to the variable keeps its original units:
average_speed5.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_speed18.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_speed5.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.magnitude5.0
average_speed.unitsmeter/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_density0.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_densityrefuel_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_m49163.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_densityNotice 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_m49163.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)
volume20088.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!