hgrecco / pint

Operate and manipulate physical quantities in Python
http://pint.readthedocs.org/
Other
2.36k stars 466 forks source link

Typing in 0.22 #1770

Open hgrecco opened 1 year ago

hgrecco commented 1 year ago

This issue is to address typing related topics that we should for 0.22. The objective is not to fix every typing aspects but those exposed to the users

hgrecco commented 1 year ago

Now that I have refactored the code, I have a lot of doubts in relation to make Quantity Generic over the magnitude. I think it complicates things, and we get little in return: mainly to flag something as a scalar or array quantity, but I am not sure if it works well. Something to consider, maybe not to change now but for the future,

MichaelTiemannOSC commented 1 year ago

For my use case (sustainable finance and climate change), the use case for typing in Pint is to ensure that certain dimensions are present. For example, Emissions should be an amount of GHG gas (typically tons of CO2e). Emissions intensity should be an amount of GHG gas per unit of Production (and there are many units of production--tons of Steel, tons of Cement, square meters of buildings, petajoules, barrels of oil, therms, cubic meters of natural gas, etc, so that's like tons of CO2e per Generic). Monetary quantities should be in some form of currency (which, because of floating rates are easily typed--but not easily converted--at parsing time).

stefano-tronci commented 1 year ago

Hi! I am trying to figure out how to type hint with pint and I haven't found a solution yet. I tried some of the stuff from here but I doesn't seem to work. My use case is as follows:

I made some code that has functions that take a value and a unit. Some signatures here as an example:

import numpy as np

def func_scalar(x: float, unit: str) -> float:
    pass

def func_array(x: np.ndarray[(3, ), int], unit: str) -> np.ndarray[(3, ), int]:
    pass

But then I discovered pint and I though "that's amazing! I should just use pint!". I hoped I could do something like this:

import numpy as np
import pint

def func_scalar(x: pint.Quantity[float]) -> pint.Quantity[float]:
    pass

def func_array(x: pint.Quantity[np.ndarray[(3, ), int]]) -> pint.Quantity[np.ndarray[(3, ), int]]:
    pass

But this results in the TypeError: <class 'pint.registry.Quantity'> is not a generic class. As I understand from reading this issue, Quantity has not been made a generic class yet. And maybe it will never be.

So, my question is: is there currently a way to achieve something like my goal?

stefano-tronci commented 1 year ago

Hi guys. Sharing my solution in case anybody else searching for a solution to this bumps into this thread.

The solution I ended up with is to use Annotated. For example:

import pint
import numpy as np
from typing import Annotated, Any

# A function to compute the Sound Pressure Level (SPL) of a pressure time series.
def spl(
        pressure: Annotated[pint.Quantity, np.ndarray[(Any, ), float]],
        reference = Annotated[pint.Quantity, float]
) -> Annotated[pint.Quantity, np.ndarray[(Any, ), float]]:
    return ((np.sqrt(np.nanmean(pressure**2)) / reference)**2).to('decibel')

It doesn't give the best autocompletion in PyCharm (which is something I enjoy about type hints) but for now It might be good enough for my purposes.

MichaelTiemannOSC commented 1 year ago

xref https://github.com/pydantic/pydantic/discussions/4929

MichaelTiemannOSC commented 1 year ago

@hgrecco: Please add typing label to this issue.

MichaelTiemannOSC commented 1 year ago

Here's my attempt to create a semi-generic Quantity type that can be used to build class structures containing specific Quantity types using Pydantic 2.x:

from typing import Any
from pydantic_core import CoreSchema, core_schema
from pydantic import BaseModel, GetJsonSchemaHandler
from pydantic.json_schema import JsonSchemaValue

import pint

ureg = pint.UnitRegistry()

from pint import Quantity
from pint import Quantity as Q_

def to_Quantity(quantity: Any) -> Quantity:
    if isinstance(quantity, str):
        try:
            v, u = quantity.split(' ', 1)
            if v == 'nan' or '.' in v or 'e' in v:
                quantity = Q_(float(v), u)
            else:
                quantity = Q_(int(v), u)
        except ValueError:
            return ureg(quantity)
    elif not isinstance(quantity, Quantity):
        raise ValueError(f"{quantity} is not a Quantity")
    return quantity

def Quantity_type(units: str) -> type:
    """A method for making a pydantic compliant Pint quantity field type."""

    def validate(value, units, info):
        quantity = to_Quantity(value)
        assert quantity.is_compatible_with(units), f"Units of {value} incompatible with {units}"
        return quantity

    def __get_pydantic_core_schema__(
        source_type: Any
    ) -> CoreSchema:
        return core_schema.general_plain_validator_function(
            lambda value, info: validate(value, units, info)
        )

    @classmethod
    def __get_pydantic_json_schema__(
            cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
    ) -> JsonSchemaValue:
        json_schema = handler(core_schema)
        json_schema = handler.resolve_ref_schema(json_schema)
        json_schema['$ref'] = "#/definitions/Quantity"
        return json_schema

    return type(
        "Quantity",
        (Quantity,),
        dict(
            __get_pydantic_core_schema__=__get_pydantic_core_schema__,
            __get_pydantic_json_schema__=__get_pydantic_json_schema__,
        ),
    )

class Foo(BaseModel):
    field_int: int
    field_m: Quantity_type('m')
    field_s: Quantity_type('s')

class Bar:
    foo = Foo(
        field_int = 1,
        field_m='1 m',
        field_s=Q_(1, 's')
    )

xx = Bar()
print("=================")
print(f"xx.foo = {xx.foo}")
print("=================")

class Baz:
    foo = Foo(
        field_int = 2,
        field_m='1 gal',
        field_s=Q_(1, 'kg')
    )

yy = Baz()
print("=================")
print(f"yy.foo = {yy.foo}")
print("=================")
gh4ag commented 11 months ago

Just as a side note, the solution proposed by @stefano-tronci will only work for Python version >= 3.9 as typing.Annotated was introduced in Python 3.9.

gsakkis commented 9 months ago

I'm also trying to find with a solution that combines runtime dimensionality validation with static type safety. Sadly none of the solutions shared here or at https://github.com/hgrecco/pint/issues/1166 typechecks statically (at least with mypy).

The most promising direction I've found so far is something like the InstanceOf Pydantic validator that makes InstanceOf[Foo] appear like Foo to mypy but like Annotated[Foo, InstanceOf()] to Pydantic.

Applying this idea to pint, I tried to come up with a TypedQuantity class so that TypedQuantity['[length]'] appears like pint.Quantity to mypy and an Annotated to Pydantic. Mypy does not handle __class_getitem__ as of now so it doesn't accept strings as type items. Wrapping the string '[length]' to a Length class worked around this issue (by fooling mypy to think of TypedQuantity[Length] as generic) but made the annotation equivalent to Length instead of pint.Quantity. That's as far as I got 🤷‍♂️