Source code for autoprotocol.unit

"""
Module containing a Units library

    :copyright: 2021 by The Autoprotocol Development Team, see AUTHORS
        for more details.
    :license: BSD, see LICENSE for more details

"""
from collections import defaultdict
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from math import ceil, floor
from numbers import Number
from typing import Any, List, Optional, Tuple, Union

from pint import UnitRegistry
from pint.errors import UndefinedUnitError
from pint.quantity import _Quantity
from pint.util import UnitsContainer


def to_decimal(number):
    """
    Casts a number to a Decimal safely.

    Parameters
    ----------
    number: Number
        number to be cast to a decimal

    Returns
    -------
    Decimal
        decimal representation of the input number

    Raises
    ------
    ValueError
        Number is not of a type that's castable to decimal
    """
    if isinstance(number, Decimal):
        decimal = number
    elif isinstance(number, Number):
        decimal = Decimal(str(number))
    else:
        raise ValueError(
            f"Tried to cast {number} to decimal but it was of non-numeric type "
            f"{type(number)}."
        )
    return decimal


# pragma pylint: disable=protected-access
class DecimalUnitRegistry(UnitRegistry):
    """
    Redefines builtin UnitRegistry methods for doing conversions to use Decimals
    instead of floats to eliminate floating point imprecision, particularly
    converting .to("new_unit").
    """

    def _get_root_units(self, input_units, check_nonmult=True):
        if not input_units:
            return Decimal("1"), UnitsContainer()

        # The cache is only done for check_nonmult=True
        if check_nonmult and input_units in self._root_units_cache:
            return self._root_units_cache[input_units]

        accumulators = [Decimal("1"), defaultdict(Decimal)]
        self._get_root_units_recurse(input_units, Decimal("1"), accumulators)

        factor = accumulators[0]
        units = UnitsContainer(
            dict((k, v) for k, v in accumulators[1].items() if v != Decimal("0"))
        )

        if check_nonmult:
            for unit in units.keys():
                if not self._units[unit].converter.is_multiplicative:
                    return None, units

        if check_nonmult:
            self._root_units_cache[input_units] = factor, units

        return factor, units

    def _get_root_units_recurse(self, ref, exp, accumulators):
        for key in sorted(ref):
            exp2 = to_decimal(exp) * to_decimal(ref[key])
            key = self.get_name(key)
            reg = self._units[key]
            if reg.is_base:
                accumulators[1][key] += exp2
            else:
                accumulators[0] *= to_decimal(reg._converter.scale) ** exp2
                if reg.reference is not None:
                    self._get_root_units_recurse(reg.reference, exp2, accumulators)


# Preload UnitRegistry (Use default Pints definition file as a base)
_UnitRegistry = DecimalUnitRegistry()

"""Map string representation of Pint units over to Autoprotocol format"""
# Map Temperature Unit names
_UnitRegistry._units["degC"]._name = "celsius"
_UnitRegistry._units["celsius"]._name = "celsius"
_UnitRegistry._units["degF"]._name = "fahrenheit"
_UnitRegistry._units["fahrenheit"]._name = "fahrenheit"
_UnitRegistry._units["degR"]._name = "rankine"
_UnitRegistry._units["rankine"]._name = "rankine"
# Map Speed Unit names
_UnitRegistry._units["revolutions_per_minute"]._name = "rpm"

"""Add support for Molarity Unit"""
_UnitRegistry.define("molar = mole/liter = M")


# pragma pylint: enable=protected-access


class UnitError(Exception):
    """
    Exceptions from creating new Unit instances with bad inputs.
    """

    message_text = "Unit error for %s"

    def __init__(self, value):
        super(UnitError, self).__init__(self.message_text % value)
        self.value = value


class UnitStringError(UnitError):
    message_text = (
        "Invalid format '%s'; when building a Unit from a string "
        "it must be formatted as '1:meter'."
    )


class UnitValueError(UnitError):
    message_text = "Invalid value '%s'; when building a Unit the value must be numeric."


class UnitUnitsError(UnitError):
    message_text = (
        "Invalid value '%s'; when building a Unit "
        "the units must be in the UnitRegistry."
    )


[docs]@dataclass(eq=False) class Unit(_Quantity): """ A representation of a measure of physical quantities such as length, mass, time and volume. Uses Pint's Quantity as a base class for implementing units and inherits functionalities such as conversions and proper unit arithmetic. Note that the magnitude is stored as a double-precision float, so there are inherent issues when dealing with extremely large/small numbers as well as numerical rounding for non-base 2 numbers. Example ------- .. code-block:: python vol_1 = Unit(10, 'microliter') vol_2 = Unit(10, 'liter') print(vol_1 + vol_2) time_1 = Unit(1, 'second') speed_1 = vol_1/time_1 print (speed_1) print (speed_1.to('liter/hour')) Returns ------- Unit unit object .. code-block:: none 10000010.0:microliter 10.0:microliter / second 0.036:liter / hour """ value: Union[int, float, str] units: Optional[str] = None def __new__(cls, value, units=None): cls._REGISTRY = _UnitRegistry cls.force_ndarray = False # Automatically return Unit if Unit is provided if isinstance(value, Unit): return value # Automatically parse String if no units provided if not units: if isinstance(value, str): try: value, units = value.split(":") except ValueError as e: raise UnitStringError(value) from e elif isinstance(value, dict): try: value, units = value["value"], value["units"] except ValueError as e: raise UnitUnitsError(value) from e try: return super(Unit, cls).__new__(cls, Decimal(str(value)), units) except (ValueError, InvalidOperation) as e: raise UnitValueError(value) from e except UndefinedUnitError as e: raise UnitUnitsError(units) from e def __post_init__(self): super(Unit, self).__init__() self.value = float(self.magnitude) self.unit = self._units.__str__() self.units = self._units.__str__()
[docs] def __str__(self, ndigits=12): """ Parameters ---------- ndigits: int, optional Number of decimal places to round to, useful for numerical precision reasons Returns ------- str This rounds the string presentation to 12 decimal places by default to account for the majority of numerical precision issues """ rounded_magnitude = round(self.magnitude, ndigits) normalized_magnitude = to_decimal(rounded_magnitude).normalize() unit_repr = self.unit.replace("**", "^").replace(" ", "") return f"{normalized_magnitude:f}:{unit_repr:s}"
def __repr__(self): return f"Unit({self.magnitude:f}, '{self.units:s}')" def __ceil__(self): return self.__class__(ceil(self.magnitude), self.units) def __floor__(self): return self.__class__(floor(self.magnitude), self.units) def _mul_div(self, other, magnitude_op, units_op=None): """ Extends Pint's base _Quantity multiplication/division implementation by checking for dimensionality and casting Numbers to Decimals """ if isinstance(other, Unit): if self.dimensionality == other.dimensionality: other = other.to(self.units) else: other = to_decimal(other) return super(Unit, self)._mul_div(other, magnitude_op, units_op) def _imul_div(self, other, magnitude_op, units_op=None): """ Extends Pint's base _Quantity multiplication/division implementation by checking for dimensionality and casting Numbers to Decimals """ if isinstance(other, Unit): if self.dimensionality == other.dimensionality: other = other.to(self.units) else: other = to_decimal(other) return super(Unit, self)._imul_div(other, magnitude_op, units_op) @property def magnitude(self): return self._magnitude @magnitude.setter def magnitude(self, magnitude): try: self._magnitude = to_decimal(magnitude) except ValueError as e: raise RuntimeError( f"Tried to set Unit's magnitude {magnitude} but it was of type " f"{type(magnitude)}. Magnitudes must be numeric." ) from e @staticmethod def fromstring(s): return Unit(s)
[docs] def ceil(self): """ Equivalent of math.ceil(Unit) for python 2 compatibility Returns ------- Unit ceil of Unit """ return self.__ceil__()
[docs] def floor(self): """ Equivalent of math.floor(Unit) for python 2 compatibility Returns ------- Unit floor of Unit """ return self.__floor__()
[docs] def round(self, ndigits): """ Equivalent of round(Unit) for python 2 compatibility Parameters ---------- ndigits: int number of decimal places to be rounded to Returns ------- Unit rounded unit """ return self.__round__(ndigits)
def unit_as_strings_factory(data: List[Tuple[str, Any]]): """ Used as a dict_factory parameter in the dataclasses.asdict function, to return a string instead of a dictionary when a Unit object is parsed. """ unit_fieldnames = ["value", "units"] if unit_fieldnames == [t[0] for t in data]: unit_str = str(Unit(**{k: v for (k, v) in data})) return unit_str else: return {field: value for field, value in data}