Sempre que trabalhamos com medidas e com dados experimentais, precisamos expressar nossos valores com as respectivas unidades. Neste 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 #
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:
\[\overline{v} = \frac{\Delta x}{\Delta t} \]
onde \(\overline{v}\) é a velocidade média, \(x\) é a distância, \(t\) é o tempo e o símbolo \(\Delta\) indica variação.
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:
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, é desativada
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 sobre o caso do avião, 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? Descubra aqui nesse artigo
Até a próxima!