"""Various interoperable units classes and some related helper functions."""
from __future__ import annotations
import decimal
from typing import Any, Optional, Type, TypeVar, Union, cast
TUnit = TypeVar("TUnit", bound="Unit")
[docs]class Unit:
"""An immutable graphical distance with a unit.
Unit objects enable easy conversion from one unit to another and convenient
operations between them.
Common operators (``+``, ``-``, ``/``, etc.) are supported between them. Return
values from these operations are given in the type on the left.
>>> from neoscore.core.units import Inch, Mm
>>> print(Inch(1) + Mm(1))
Inch(1.039)
To facilitate easy comparison between equivalent values in different units, equality
is checked with a tolerance of ``Unit(0.001)``.
>>> from neoscore.core.units import Inch, Mm
>>> assert Inch(Mm(1)) == Mm(1)
>>> assert Inch(Mm(1)) >= Mm(1)
>>> assert Inch(Mm(1)) <= Mm(1)
The base ``Unit`` type is a graphical unit corresponding to 1/72nd of an inch.
Internally, this is significant because it is the unit used by the graphics backend,
Qt. For most purposes, you probably want to use a more descriptive unit type.
"""
__slots__ = {
"base_value": "The underlying float value in base units.",
"_display_value": "",
}
CONVERSION_RATE: float = 1
"""The ratio of this class to fundamental ``Unit``\ s.
Subclasses should override this.
"""
[docs] def __init__(self, value: Unit | float, _raw_base_value=None):
"""Create a unit from another unit or a raw number."""
if _raw_base_value is not None:
# Short-circuiting constructor for internal use
self.base_value = _raw_base_value
self._display_value = None
else:
base_value = getattr(value, "base_value", None)
if base_value is not None:
self.base_value = base_value
self._display_value = None
else:
self.base_value = value * self.CONVERSION_RATE
self._display_value = value
@property
def display_value(self) -> float:
"""A human-friendly unit value.
If the unit was constructed with a simple number, this will return the exact
given argument value. If the unit was constructed from another unit, this will
return the converted value rounded to 3 decimal places.
"""
if self._display_value:
return self._display_value
return round(self.base_value / self.CONVERSION_RATE, 3)
@property
def rounded_base_value(self) -> float:
"""The base value rounded to 2 decimal places.
This is useful for things like hash keys for caching purposes
"""
return round(self.base_value, 2)
def __repr__(self):
return "{}({})".format(type(self).__name__, self.display_value)
_CMP_POS_EPSILON = 0.001
_CMP_NEG_EPSILON = -0.001
def __lt__(self, other: Unit):
return self.base_value - other.base_value < Unit._CMP_NEG_EPSILON
def __le__(self, other: Unit):
return self.base_value - other.base_value < Unit._CMP_POS_EPSILON
def __eq__(self, other: Any):
"""Two units are equal if the difference between their base
values is less than 0.001, or if both are infinite.
"""
return hasattr(other, "base_value") and (
# Check strict equality first for speed and coverage of infinity values
self.base_value == other.base_value
or abs(self.base_value - other.base_value) < Unit._CMP_POS_EPSILON
)
def __gt__(self, other: Unit):
return self.base_value - other.base_value > Unit._CMP_POS_EPSILON
def __ge__(self, other: Unit):
return self.base_value - other.base_value > Unit._CMP_NEG_EPSILON
def __add__(self: TUnit, other: TUnit) -> TUnit:
return type(self)(None, _raw_base_value=self.base_value + other.base_value)
def __sub__(self: TUnit, other: Unit) -> TUnit:
return type(self)(None, _raw_base_value=self.base_value - other.base_value)
def __mul__(self: TUnit, other: float) -> TUnit:
if hasattr(other, "base_value"):
raise TypeError
return type(self)(None, _raw_base_value=self.base_value * other)
def __rmul__(self: TUnit, other: float) -> TUnit:
# __rmul__ behaves identically to __mul__
if hasattr(other, "base_value"):
raise TypeError
return type(self)(None, _raw_base_value=self.base_value * other)
def __truediv__(self: TUnit, other: Union[Unit, float]) -> Union[TUnit, float]:
if hasattr(other, "base_value"):
# Unit / Unit -> Float
return self.base_value / (cast(Unit, other)).base_value
# Unit / Float -> Unit
return type(self)(None, _raw_base_value=self.base_value / other)
def __pow__(self: TUnit, other: float, modulo: Optional[int] = None) -> TUnit:
return type(self)(Unit(pow(self.base_value, other, modulo)))
def __neg__(self: TUnit) -> TUnit:
return type(self)(None, _raw_base_value=-self.base_value)
def __abs__(self: TUnit) -> TUnit:
return type(self)(None, _raw_base_value=abs(self.base_value))
[docs]class Inch(Unit):
"""An inch."""
CONVERSION_RATE = 72
[docs]class Mm(Unit):
"""A millimeter."""
CONVERSION_RATE = float(decimal.Decimal("0.0393700787") * Inch.CONVERSION_RATE)
ZERO = Unit(0)
"""Shorthand for a zero unit"""
[docs]def make_unit_class(name: str, conversion_rate: float) -> Type[Unit]:
"""Create a ``Unit`` subclass with a name and base unit conversion rate"""
return cast(
Type[Unit],
type(
name,
(Unit,),
{"CONVERSION_RATE": conversion_rate},
),
)
def _convert_all_to_unit_out_of_place(
collection: Union[tuple, set], unit: Type[Unit]
) -> Union[tuple, set]:
mutable_iterable: List[Any] = list(collection)
convert_all_to_unit(mutable_iterable, unit)
return type(collection)(mutable_iterable)
[docs]def convert_all_to_unit(collection: Union[list, dict], unit: Type[Unit]):
"""Recursively convert all numbers found in a list or dict to a unit in place.
This function works in place. Tuples and sets found within ``collection`` will be
replaced.
In dictionaries, only values are converted.
"""
if isinstance(collection, dict):
for key, value in collection.items():
if isinstance(value, (int, float, Unit)):
collection[key] = unit(value)
elif isinstance(value, (list, dict)):
convert_all_to_unit(collection[key], unit)
elif isinstance(value, (tuple, set)):
collection[key] = _convert_all_to_unit_out_of_place(
collection[key], unit
)
# (else: continue --- nothing to do here)
elif isinstance(collection, list):
for i in range(len(collection)):
if isinstance(collection[i], (int, float, Unit)):
collection[i] = unit(collection[i])
elif isinstance(collection[i], (list, dict)):
convert_all_to_unit(collection[i], unit)
elif isinstance(collection[i], (tuple, set)):
collection[i] = _convert_all_to_unit_out_of_place(collection[i], unit)
# (else: continue --- nothing to do here)
else:
raise TypeError