Python, unidades e cerveja(?!): o pacote pint

pint_cerveja_destaque

Sempre que trabalhamos com medidas e com dados experimentais precisamos expressar nossos valores com as respectivas unidades. Nesse artigo, veremos uma biblioteca Python para trabalhar com unidades e o que isso tem a ver com cerveja (?!).

Caso prefira ver em vídeo, clique no player abaixo. O artigo completo se encontra após o vídeo.

O pacote pint permite trabalhar com quantidades físicas: produto de um valor numérico e uma unidade de medida. Possui uma ampla lista de unidades, prefixos e constantes que pode ainda ser extendida pelo usuário. É compatível com diversas operações do amplamente utilizado pacote numpy.

Caso tenha ficado curioso sobre o símbolo no site do projeto ser um copo de cerveja, leia esse artigo da Wikipedia. O nome nada mais é do que uma referência a um tradicional copo de cerveja muito comum em países Europeus e nos Estados Unidos. E, claro, cada país tem sua própria medida de volume para 1 pint conforme mostra essa tabela. Ótimo nome para um pacote de conversão de unidades, certo?

O pint requer Python 3.6+ e não possui nenhuma outra dependência.

A instalação é bem simples, conforme mostra a documentação, podendo ser feita via conda ou via pip:

pip install pint

Veja esse artigo caso queira instalar em ambientes virtuais.

O pint trabalha com o conceito de registro de unidades. Após importar o pacote, cria-se um registro que irá armazenar e avaliar as unidades de todo o código:

import pint

ureg = pint.UnitRegistry()

Obviamente que tal registro, que se trata de uma variável, pode ter qualquer nome, mas costuma-se seguir a convenção da documentação de chamá-lo ureg.

Podemos agora verificar que o pacote irá tratar de operações envolvendo unidades:

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

Perceba que na soma acima, o pacote reconheceu que 100 cm equivale a 1 m e retornou o resultado como 2 m. Vejamos com calma a utilização do pacote para entender esse comportamento.

Criando quantidades

Vamos considerar uma situação simples, onde se quer calcular uma velocidade média, dada uma variação de distância em um dado intervalo de tempo. Podemos criar uma variável com magnitude e unidade de duas formas. A primeira é pelo conceito de que uma quantidade é um valor numérico multiplicado por uma unidade:

distancia = 50 * ureg.meter
distancia
50 meter

A segunda é via um construtor de quantidades. Novamente, o construtor pode ter qualquer nome, mas a convenção criada pela documentação do pacote é chamá-lo de Q_:

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

Para calcular a velocidade média, basta seguir a definição física:

velocidade_media = distancia / tempo
velocidade_media
5.0 meter/second

Repare que a unidade da velocidade está correta considerando as unidades passadas para as variáveis relacionadas a distância e ao tempo.

Podemos verificar que, independentemente da forma como as variáveis foram definidas, todas são do tipo Quantity:

print(type(distancia))
print(type(tempo))
print(type(velocidade_media))
<class 'pint.quantity.build_quantity_class.<locals>.Quantity'>
<class 'pint.quantity.build_quantity_class.<locals>.Quantity'>
<class 'pint.quantity.build_quantity_class.<locals>.Quantity'>

Isso pode também verificado verificando a repr de cada um desses objetos:

print(repr(distancia))
print(repr(tempo))
print(repr(velocidade_media))
<Quantity(50, 'meter')>
<Quantity(10, 'second')>
<Quantity(5.0, 'meter / second')>

Uma coisa interessante que pode ser percebida pela análise do repr é que a unidade também pode ser passada como uma string. E aqui temos um grande poder do pint. Veja como poderíamos declarar a distância de diversos formas:

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

Repare que a última forma é a maneira pela qual descrevemos uma quantidade usualmente, de forma manuscrita e em trabalhos redigidos. Isso é uma grande vantagem do pint, o pacote permite o parse (transformação) de strings de forma que considero essa a forma mais fácil. Por exemplo, a constante universal dos gases, com as unidades SI e arredondada para 3 casas decimais, poderia ser declarada como:

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

Perceba o cuidado na utilização de parênteses. Unidades também seguem regras de precedência conforme o comportamento usual.

Convertendo unidades

Uma forma de converter é utilizando o método to:

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

Como já verificamos que o pacote aceita strings:

velocidade_media.to('km/hour')  # quilômetros por hora
18.0 kilometer/hour
velocidade_media.to('ft/s')  # pés por segundo
16.404199475065617 foot/second

Utilizando o to, a quantidade ligada à variável permanece com as unidades de origem:

velocidade_media
5.0 meter/second

Para que a conversão seja feita de forma definitiva, devemos utilizar o método ito (o i pode ser entendido como inplace):

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

O pint possui o método to_base_units (e o análogo ito_base_units) para que uma determinada quantidade seja convertida para as unidades do sistema de unidades definido no registro de unidades. Por padrão, o registro de unidades considera o Sistema Internacional de Unidades (SI). Podemos verificar todos os sistemas disponíveis:

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

Como o sistema padrão é o SI, podemos então pegar a velocidade média, no momento armazenada em quilômetros por hora, e solicitar a conversão para as unidades do SI:

velocidade_media.to_base_units()
5.0 meter/second

Considerando um contexto, por exemplo, de estar no Sistema Imperial, podemos definir tal sistema:

ureg.default_system = 'imperial'

Agora, a velocidade média pode ser convertida para as unidades do sistema imperial (jardas/segundo):

velocidade_media.to_base_units()
5.46806649168854 yard/second

Vamos voltar ao SI:

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

Vamos converter definitivamente de volta para as unidades SI:

velocidade_media.ito_base_units()
velocidade_media
5.0 meter/second

Conhecendo cada parte da quantidade

Conforme já descrevemos, uma quantidade física é definida por um valor, magnitude, e sua respectiva unidade. Podemos verificar cada uma dessas entidades:

velocidade_media.magnitude
5.0
velocidade_media.units
meter/second

Podemos também verificar as dimensões, para fazer uma análise dimensional:

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

Aplicando em um caso real

Em um dos últimos artigos do site, vimos o caso de um avião que ficou sem combustível durante o voo. Tal situação ocorreu por um erro de conversão de unidades, conforme descrito no post.

Vou aproveitar o caso para mostrar como trabalhar com unidades na definição de funções.

É óbvio, mas não custa ressaltar, que o caso é complexo e algumas simplificações serão feitas para manter o foco na explicação dos conceitos envolvidos. Aqueles que quiserem mais detalhes sobre medição de densidade de combustível e sobre consumo de combustível em aviação podem ler aqui e aqui.

Como vimos no artigo, a densidade que deveriam ter utilizado nas contas deveria estar em kg/l. Em uma situação real, é consultada uma tabela de densidades para verificar qual o valor a ser utilizado a depender da temperatura (ou se usa algum equipamento de medição de densidade). Aqui, simplificaremos considerando que a densidade é constante e no valor de 0,803 kg/l, valor que deveria ter sido utilizado no caso em questão:

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

Relembrando das contas do artigo:

contas_aviao

Assim, podemos pensar, em um primeiro momento, em definir um função que irá receber os valores de volume de combustível presente no tanque e a massa total de combustível necessária para a viagem. Associar, então, tais valores a unidades e retornar o valor do volume de combustível necessário para abastecer o avião. Vamos definir tal função:

def volume_abastecer_implementacao1(volume_presente, massa_total, densidade_combustivel=Q_('0.803 kg/l')):
    '''Retorna o volume de combustível que deve abastercer o avião.
    
    Parâmetros
    ----------
    volume_presente: float, espera-se valor em litro
    massa_total: float, espera-se valor em quilograma
    densidade_combustivel: pint.Quantity, valor em kg/l, padrão de 0.803 kg/l
    
    Retorno
    -------
    volume a reabastecer: float, valor em litros
    
    OBS.: Primeira implementação, não é a ideal. Verificar a implementação mais adequada adiante no artigo.
    '''
    
    volume_presente = Q_(volume_presente, 'l')
    massa_total = Q_(massa_total, 'kg')    
    massa_presente = volume_presente * densidade_combustivel
    massa_abastecer = massa_total - massa_presente
    
    return massa_abastecer / densidade_combustivel
volume_abastecer_implementacao1(7682, 22300)
20088.85927770859 liter

Repare que a resposta retornada está correta. No entanto, a implementação da função ainda não é a ideal pois a função recebe apenas valores numéricos. Por mais que a documentação (DOCUMENTE seu código!) diga que os valores de volume e de massa devam estar em litro e quilograma, respectivamente, nada impede que valores em outras unidades sejam passados por engano. Alguém poderia, por exemplo, passar inadvertidamente um volume em metros cúbicos e nada no código iria alertar que se trata de um erro.

Vamos reimplementar a função:

def volume_abastecer_implementacao2(volume_presente, massa_total, densidade_combustivel=Q_('0.803 kg/l')):
    '''Retorna o volume de combustível que deve abastercer o avião.
    
    Parâmetros
    ----------
    volume_presente: pint.Quantity, espera-se valor em litro
    massa_total: pint.Quantity, espera-se valor em quilograma
    densidade_combustível: pint.Quantity, por padrão '0.803 kg/l`
    
    
    Retorno
    -------
    volume a reabastecer: pint.Quantity, valor em litros
    
    OBS.: Implementação não ideal. Verificar a implementação mais adequada adiante no artigo.
    '''
    
    if isinstance(volume_presente, pint.Quantity) and isinstance(massa_total, pint.Quantity):            
        massa_presente = volume_presente * densidade_combustivel
        massa_abastecer = massa_total - massa_presente
        return massa_abastecer / densidade_combustivel
    else:
        raise ValueError('Forneça os valores como quantidades físicas do Pint (pint.Quantity)')

Nessa implementação, começo verificando se os parâmetros são do tipo correto. Não é uma coisa muito usual em Python, mas serve pro que queremos no momento. Se não for do tipo correto, um erro é mostrado ao usuário.

Vamos verificar agora como a função se comporta passando valores sem unidades:

volume_abastecer_implementacao2(7682, 22300)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-41-d3388580cae6> in <module>
----> 1 volume_abastecer_implementacao2(7682, 22300)

<ipython-input-40-e5d679f70da5> in volume_abastecer_implementacao2(volume_presente, massa_total, densidade_combustivel)
     21         return massa_abastecer / densidade_combustivel
     22     else:
---> 23         raise ValueError('Forneça os valores como quantidades físicas do Pint (pint.Quantity)')

ValueError: Forneça os valores como quantidades físicas do Pint (pint.Quantity)

Ótimo! Vamos agora criar então quantidades para serem passadas para a função.

vol_presente = Q_('7682 l')
m_total = Q_('22300 kg')
volume_abastecer_implementacao2(vol_presente, m_total)
20088.85927770859 liter

Ótimo! Resposta correta.

Há ainda um “efeito colateral” da implementação da forma como foi feita. Se passarmos os valores em outras unidades, o pint não se encarregará de convertê-las automaticamente para as unidades do sistema de medidas utilizado. Ou seja, se, por exemplo, passarmos a massa em libras, teremos uma resposta um pouco estranha:

m_total.ito('lb')
m_total
49163.084467227694 pound
volume_abastecer_implementacao2(vol_presente, m_total)
44288.35361077301 liter pound/kilogram

Repare que não foram unificadas as unidades libra e quilograma, mesmo sendo de uma mesma dimensão. Para resolver situações como essa, o pint possui o método to_reduced_units que combina unidades que são de uma mesma dimensionalidade. De acordo com a documentação, essa funcionalidade pode ser ativada no escopo global mas, por padrão, é desativida por questões de performance. Vamos então redefinir nossa função para resolver esse pequeno problema:

def volume_abastecer_implementacao3(volume_presente, massa_total, densidade_combustivel=Q_('0.803 kg/l')):
    '''Retorna o volume de combustível que deve abastercer o avião.
    
    Parâmetros
    ----------
    volume_presente: pint.Quantity, espera-se valor em litro
    massa_total: pint.Quantity, espera-se valor em quilograma
    densidade_combustível: pint.Quantity, por padrão '0.803 kg/l`
    
    Retorno
    -------
    volume a reabastecer: pint.Quantity, valor em litros
    
    OBS.: Implementação não ideal. Verificar a implementação mais adequada adiante no artigo.
    '''
    
    if isinstance(volume_presente, pint.Quantity) and isinstance(massa_total, pint.Quantity):            
        massa_presente = volume_presente * densidade_combustivel
        massa_abastecer = massa_total - massa_presente
        return (massa_abastecer / densidade_combustivel).to_reduced_units()
    else:
        raise ValueError('Forneça os valores como quantidades físicas do Pint (pint.Quantity)')

Verificando as unidades dos parâmetros a serem passados:

print(vol_presente)
print(m_total)
7682 liter
49163.084467227694 pound

Verificando resultado:

volume_abastecer_implementacao3(vol_presente, m_total)
20088.85927770859 liter

Ótimo! Resposta correta. Agora o usuário pode passar os parâmetros em qualquer sistema de unidade que o pint cuidará internamente de converter.

Mas ainda não estamos numa implementação ideal…

Imagine um programa real. Em um código real, essa seria apenas mais uma das dezenas de funções presentes no programa. Haveria ainda classes, outros módulos. Enfim, todo um universo de código a ser mantido. Muito provavelmente outras partes desse código também lidariam com quantidades físicas. Imagine em cada função ou método ficar verificando se o tipo correto foi passado para a função/método? Seria muita repetição de código com uma mesma funcionalidade, algo que é interessante evitar.

Sendo um pacote desenvolvido para lidar com unidades e que tem uma comunidade bem ativa no desenvolvimento, certamente que alguém já pensou em resolver esse tipo de problema. Para isso, existe o wraps.

O wraps é um decorator. O conceito de decorator provê uma maneira simples de modificar o comportamento de uma função sem necessariamente alterá-la. Nada mais é que um método para envolver (wrap em inglês) uma função, modificando seu comportamento.

No caso, o wraps recebe dois parâmetros em forma de tupla. O primeiro se refere às unidades do retorno da função envolvida e o segundo às unidades dos parâmetros da dita função. Assim, nossa nova implementação passa a ser:

@ureg.wraps(('l'), ('l', 'kg', 'kg/l'))
def volume_abastecer(volume_presente, massa_total, densidade_combustivel=Q_('0.803 kg/l')):
    '''Retorna o volume de combustível que deve abastercer o avião.
    
    Parâmetros
    ----------
    volume_presente: pint.Quantity, espera-se valor em litro
    massa_total: pint.Quantity, espera-se valor em quilograma
    densidade_combustível: pint.Quantity, por padrão '0.803 kg/l`    
    
    Retorno
    -------
    volume a reabastecer: pint.Quantity, valor em litros
    '''
    
    massa_presente = volume_presente * densidade_combustivel
    massa_abastecer = massa_total - massa_presente
    
    return massa_abastecer / densidade_combustivel

Repare nas modificações feitas: retirada da verificação isinstance e retirada do to_reduced_units. A primeira modificação se deve ao fato de que agora o wraps cuida da verificação. A segunda, se deve ao fato de que apenas valores (magnitudes) são passadas para dentro da função. As unidades são verificadas anteriormente, retiradas, as contas são feitas e as unidades são aplicadas ao retorno. Isso melhora a perfomance do programa.

Vamos verificar o comportamento quando são passados valores sem unidades:

volume_abastecer(7682, 22300)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-52-98280d6f7137> in <module>
----> 1 volume_abastecer(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)

Ótimo, apresentou erro, comportamento que desejamos.

Vamos verificar o comportamento passando quantidades:

vol_presente = Q_('7682 l')
m_total = Q_('22300 kg')
volume_abastecer(vol_presente, m_total)
20088.85927770858 liter

Show, resultado esperado novamente. Por fim, vamos verificar o que ocorre com essa nova versão quando se passa um parâmetro em outro sistema de unidade.

m_total.ito('lb')
m_total
49163.084467227694 pound
volume_abastecer(vol_presente, m_total)
20088.85927770858 liter

Ótimo!! Agora temos nossa função implementada com as melhores práticas e mais segura de ser utilizada.

Melhorando a aparência do resultado

Por último, mas não menos importante, vejamos como melhorar o aspecto de nossa resposta. No contexto em que estamos, abastecimento de um avião com milhares de litros de combustível, certamente qualquer casa decimal pode ser desconsiderada. Afinal, como vimos no artigo, usualmente os valores são arredondados. Primeiro, vamos armazenar o resultado em uma variável:

volume = volume_abastecer(vol_presente, m_total)
volume
20088.85927770858 liter

As formatações disponíveis para f-strings funcionam com o 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

Uma formatação mais visual, com os símbolos das unidades e potências de 10 (quando existentes), pode ser obtida com ~P:

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

Em algumas situações, podemos querer exportar o resultado para utilizar em algum documento. E, em ciências e engenharias, usamos muito LaTeX. Assim, podemos expressar o resultado em código LaTeX:

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

Usuários do pacote siunitx do LaTeX ficarão felizes sabendo que é possível exportar com a sintaxe do pacote:

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

Caso você tenha o pacote babel instalado, pode ter até tradução das unidades:

volume.format_babel(locale='pt_BR')
'20088.85927770858 litros'
# reutilizando a velocidade_media definida lá no início do artigo
velocidade_media.format_babel(locale='pt_BR')
'5.0 metros por segundos'

Versões dos pacotes utilizados nesse documento

Esse artigo foi escrito em um Jupyter Notebook. O Jupyter Notebook é uma interfarce gráfica que permite a edição de notebooks em um navegador web, tais como Google Chrome ou Firefox. Notebooks são documentos virtuais que permitem a execução de códigos juntamente com ferramentas para edição de textos; ou seja, além das rotinas usuais de programação, o usuário pode documentar todo o processo de produção do código. Exatamente como foi feito aqui, texto explicando cada trecho de código. Dessa forma, o notebook permite uma maneira interativa de programar. Também permite uma programação mais dinâmica, oferecendo ao usuário o output imediato do código; não havendo, assim, a necessidade de compilar ou executar todo o documento.

Já escrevi aqui no site sobre o Projeto Anaconda. Ao se instalar o Anaconda, os programas Jupyter Notebook e JupyterLab são instalados. Ambos permitem interagir com Notebooks.

É sempre importante sabermos as versões de cada pacote que utilizamos, pois algumas funcionalidades podem mudar com o tempo, durante o avanço do desenvolvimento de cada projeto. O pacote version_information ajuda essa verificação em Jupyter Notebooks mas infelizmente não é compatível no momento com o Python 3.8. Listo a seguir as informações de versões relevantes utilizadas ao escrever esse notebook:

  • Python 3.8.5
  • IPython 7.18.1
  • pint 0.15
  • babel 2.8.0
  • notebook gerado em um Linux Mint 20 com kernel Linux 5.4.0 45 generic x86_64 with glibc2.29

O Notebook está neste meu repositório do GitHub. No mesmo repositório também estão os arquivos Pipfile que permitem que você crie um ambiente virtual exatamente igual ao que criei para escrever esse artigo e os códigos exibidos. Caso queira saber mais sobre ambientes virtuais, como criá-los do zero e a partir de arquivos como os Pipfile, leia esse artigo.

Conclusão

É aí, curtiu? É um pacote bem poderoso e ainda nem vimos metade de suas funcionalidades! Mas espero que tenha percebido que não há mais desculpas para fazer códigos que envolvam quantidades físicas sem considerar suas unidades.

No próximo artigo do site irei explorar mais aspectos da utilização do pint. Afinal, como utilizá-lo com listas de valores? Arrays? Combinado com o pacote mais utilizado em ciências, o numpy? E como utilizar quando há incertezas associadas aos valores? E em gráficos? Se quiser saber, nos acompanhe nas redes sociais, sempre aviso quando há novos artigos no site.

Até a próxima!

Compartilhe:

2 comentários em “Python, unidades e cerveja(?!): o pacote pint”

  1. Bom dia, Francisco!
    Excelente conteúdo, gostaria de saber se posso utilizar na disciplina que leciono.
    Obviamente, dando os devidos créditos ao seu blog.
    Aguardo retorno. Abraços.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Rolar para cima