From cc7aa0ed8daf042df7bed5827ebc8a1d8930ba38 Mon Sep 17 00:00:00 2001 From: Maximos Nikiforakis Date: Tue, 20 Feb 2024 20:44:29 +0200 Subject: [PATCH] WIP Implement converter types --- src/property_utils/tests/units/data.py | 32 ++ .../tests/units/test_converter_types.py | 86 ++++ src/property_utils/units/converter_types.py | 370 ++++++++++++++++++ 3 files changed, 488 insertions(+) create mode 100644 src/property_utils/tests/units/test_converter_types.py create mode 100644 src/property_utils/units/converter_types.py diff --git a/src/property_utils/tests/units/data.py b/src/property_utils/tests/units/data.py index 032fb43..ba9ff34 100644 --- a/src/property_utils/tests/units/data.py +++ b/src/property_utils/tests/units/data.py @@ -6,11 +6,19 @@ GenericDimension, GenericCompositeDimension, ) +from property_utils.units.converter_types import ( + register_converter, + AbsoluteUnitConverter, + RelativeUnitConverter, + ExponentiatedPhysicalPropertyUnitConverter, + CompositePhysicalPropertyUnitConverter, +) class Unit1(MeasurementUnit): A = "A" a = "a" + A2 = "A2" @classmethod def si(cls) -> "Unit1": @@ -35,6 +43,30 @@ def si(cls) -> "Unit3": return cls.c +class UnregisteredConverter(AbsoluteUnitConverter): ... + + +@register_converter(Unit1) +class Unit1Converter(AbsoluteUnitConverter): + reference_unit = Unit1.A + conversion_map = {Unit1.A: 1, Unit1.a: 10} + + +@register_converter(Unit2) +class Unit2Converter(RelativeUnitConverter): + reference_unit = Unit2.B + conversion_map = {Unit2.B: lambda u: u, Unit2.b: lambda u: (2 * u) + 3} + reference_conversion_map = {Unit2.B: lambda u: u, Unit2.b: lambda u: (u - 3) / 2} + + +@register_converter(Unit1**3.14) +class Unit1_314Converter(ExponentiatedPhysicalPropertyUnitConverter): ... + + +@register_converter(Unit1 * Unit2) +class Unit1Unit2Converter(CompositePhysicalPropertyUnitConverter): ... + + def dimension_1(power: float = 1) -> Dimension: """ A^power diff --git a/src/property_utils/tests/units/test_converter_types.py b/src/property_utils/tests/units/test_converter_types.py new file mode 100644 index 0000000..ff498f6 --- /dev/null +++ b/src/property_utils/tests/units/test_converter_types.py @@ -0,0 +1,86 @@ +from unittest import TestSuite, TextTestRunner + +from unittest_extensions import TestCase, args + +from property_utils.units.converter_types import ( + get_converter, + register_converter, + AbsoluteUnitConverter, + RelativeUnitConverter, + CompositePhysicalPropertyUnitConverter, +) +from property_utils.exceptions.base import ( + PropertyUtilsTypeError, + PropertyUtilsValueError, +) +from property_utils.exceptions.units.converter_types import UndefinedConverter +from property_utils.tests.utils import add_to +from property_utils.tests.units.data import ( + Unit1, + Unit2, + Unit3, + Unit1Converter, + UnregisteredConverter, + Unit1_314Converter, + Unit2Converter, + Unit1Unit2Converter, +) + +converter_types_test_suite = TestSuite() + +converter_types_test_suite.addTests( + [ + (TestGetConverter_test_suite := TestSuite()), + (TestRegisterConverter_test_suite := TestSuite()), + ] +) + + +@add_to(TestGetConverter_test_suite) +class TestGetConverter(TestCase): + def subject(self, generic): + return get_converter(generic) + + @args({"generic": Unit1.A}) + def test_with_measurement_unit(self): + self.assertResultRaises(PropertyUtilsTypeError) + + @args({"generic": Unit3}) + def test_with_unregistered_generic(self): + self.assertResultRaises(UndefinedConverter) + + @args({"generic": Unit2}) + def test_with_measurement_unit_type(self): + self.assertResult(Unit2Converter) + + @args({"generic": Unit1**3.14}) + def test_with_generic_dimension(self): + self.assertResult(Unit1_314Converter) + + @args({"generic": Unit1 * Unit2}) + def test_with_generic_composite_dimension(self): + self.assertResult(Unit1Unit2Converter) + + +@add_to(TestRegisterConverter_test_suite) +class TestRegisterConverter(TestCase): + def subject(self, generic): + return register_converter(generic)(UnregisteredConverter) + + @args({"generic": Unit1.A}) + def test_with_measurement_unit(self): + self.assertResultRaises(PropertyUtilsTypeError) + + @args({"generic": Unit1}) + def test_with_registered_converter(self): + self.assertResultRaises(PropertyUtilsValueError) + + @args({"generic": Unit3}) + def test_with_unregistered_converter(self): + self.assertResult(UnregisteredConverter) + self.assertEqual(self.cachedResult().generic_unit_descriptor, Unit3) + + +if __name__ == "__main__": + runner = TextTestRunner() + runner.run(converter_types_test_suite) diff --git a/src/property_utils/units/converter_types.py b/src/property_utils/units/converter_types.py new file mode 100644 index 0000000..3af3f4c --- /dev/null +++ b/src/property_utils/units/converter_types.py @@ -0,0 +1,370 @@ +from abc import ABCMeta +from typing import Protocol, Type, Callable, TypeAlias + +from property_utils.units.descriptors import ( + MeasurementUnit, + MeasurementUnitType, + GenericDimension, + GenericCompositeDimension, + UnitDescriptor, + GenericUnitDescriptor, + Dimension, + CompositeDimension, +) +from property_utils.exceptions.units.converters import InvalidUnitConversion +from property_utils.exceptions.units.converter_types import UndefinedConverter +from property_utils.exceptions.base import ( + PropertyUtilsTypeError, + PropertyUtilsValueError, +) + +ConverterType: TypeAlias = Type["PhysicalPropertyUnitConverter"] + +_converters: dict[GenericUnitDescriptor, ConverterType] = {} + + +def get_converter(generic: GenericUnitDescriptor) -> ConverterType: + """ + Get converter for given generic descriptor. + + Raises PropertyUtilsTypeError if argument is not a generic unit descriptor. + + Raises UndefinedConverter if a converter has not been defined for the given generic. + """ + if not isinstance( + generic, (MeasurementUnitType, GenericDimension, GenericCompositeDimension) + ): + raise PropertyUtilsTypeError( + f"cannot get converter; argument: {generic} is not a generic unit descriptor. " + ) + try: + return _converters[generic] + except KeyError as exc: + raise UndefinedConverter( + f"a converter has not been defined for {generic}", exc + ) from None + + +def register_converter( + generic: GenericUnitDescriptor, +) -> Callable[[ConverterType], ConverterType]: + """ + Decorate a converter class to register the generic descriptor of the units it + operates on. + This decorator also sets the 'generic_unit_descriptor' attribute of the decorated + class. + + Raises PropertyUtilsTypeError if argument is not a generic unit descriptor. + + Raises PropertyUtilsValueError if generic has already a converter registered. + """ + if not isinstance( + generic, (MeasurementUnitType, GenericDimension, GenericCompositeDimension) + ): + raise PropertyUtilsTypeError( + f"cannot get converter; argument: {generic} is not a generic unit descriptor. " + ) + + if generic in _converters: + raise PropertyUtilsValueError( + f"cannot register converter twice; {generic} has already got a converter. " + ) + + def wrapper(cls: ConverterType) -> ConverterType: + _converters[generic] = cls + cls.generic_unit_descriptor = generic + return cls + + return wrapper + + +class PhysicalPropertyUnitConverter(Protocol): + """Protocol of classes that convert a value from one unit to another.""" + + generic_unit_descriptor: GenericUnitDescriptor + + @classmethod + def convert( + cls, + value: float, + from_descriptor: UnitDescriptor, + to_descriptor: UnitDescriptor, + ) -> float: + """ + Convert a value from a unit descriptor to its' corresponding value in a + different unit descriptor. + """ + + +class AbsoluteUnitConverter(metaclass=ABCMeta): + """ + Base converter class for measurement units that are absolute, i.e. not relative. + + e.g. + Pressure units are absolute because the following applies: + unit_i = unit_j * constant, + where unit_i and unit_j can be any pressure units. + + Temperature units are not absolute because the above equation does not apply when + converting from a relative temperature to an absolute temperature (e.g. from Celcius + to Kelvin, or Fahrenheit to Rankine). + """ + + generic_unit_descriptor: MeasurementUnitType + reference_unit: MeasurementUnit + conversion_map: dict[MeasurementUnit, float] + + @classmethod + def convert( + cls, + value: float, + from_descriptor: UnitDescriptor, + to_descriptor: UnitDescriptor, + ) -> float: + """ + Convert a value from an absolute unit to another absolute unit. + Raises 'InvalidUnitConversion' if 'from_descriptor' or 'to_descriptor' are not + an instance of the generic that is registered with the converter or if 'value' + is not a numeric. + """ + if not isinstance(value, (float, int)): + raise InvalidUnitConversion(f"invalid 'value': {value}; expected numeric. ") + return value * cls.get_factor(from_descriptor, to_descriptor) + + @classmethod + def get_factor( + cls, from_descriptor: UnitDescriptor, to_descriptor: UnitDescriptor + ) -> float: + """ + Get the multiplication factor for the conversion from 'from_descriptor' to + 'to_descriptor'. + Raises 'InvalidUnitConversion' if 'from_descriptor' or 'to_descriptor' are not + an instance of the generic that is registered with the converter. + """ + if not from_descriptor.isinstance(cls.generic_unit_descriptor): + raise InvalidUnitConversion( + f"invalid 'from_descriptor; expected an instance of {cls.generic_unit_descriptor}. " + ) + if not to_descriptor.isinstance(cls.generic_unit_descriptor): + raise InvalidUnitConversion( + f"invalid 'to_descriptor'; expected an instance of {cls.generic_unit_descriptor}. " + ) + from_unit = MeasurementUnit.from_descriptor(from_descriptor) + to_unit = MeasurementUnit.from_descriptor(to_descriptor) + try: + return cls._to_reference(from_unit) * cls.conversion_map[to_unit] + except KeyError as exc: + raise InvalidUnitConversion( + f"cannot convert to {to_unit}; unit is not registered in {cls.__name__}'s conversion map. ", + exc, + ) from None + + @classmethod + def _to_reference(cls, from_unit: MeasurementUnit) -> float: + try: + return 1 / cls.conversion_map[from_unit] + except KeyError as exc: + raise InvalidUnitConversion( + f"cannot convert from {from_unit}; unit is not registered in {cls.__name__}'s conversion map. ", + exc, + ) from None + + +class RelativeUnitConverter(metaclass=ABCMeta): + """ + Base converter class for measurement units that are relative. + + e.g. Temperature units are relative because conversion from one unit to another + is not necessarily performed with multiplication with a single factor; + """ + + generic_unit_descriptor: MeasurementUnitType + reference_unit: MeasurementUnit + reference_conversion_map: dict[MeasurementUnit, Callable[[float], float]] + conversion_map: dict[MeasurementUnit, Callable[[float], float]] + + @classmethod + def convert( + cls, + value: float, + from_descriptor: UnitDescriptor, + to_descriptor: UnitDescriptor, + ) -> float: + """ + Convert a value from a relative unit to another relative unit. + Raises 'InvalidUnitConversion' if 'from_descriptor' or 'to_descriptor' are not + an instance of the generic that is registered with the converter or if 'value' + is not a numeric. + """ + if not isinstance(value, (float, int)): + raise InvalidUnitConversion(f"invalid 'value': {value}; expected numeric. ") + return cls._from_reference( + cls._to_reference(value, from_descriptor), to_descriptor + ) + + @classmethod + def _to_reference(cls, value: float, from_descriptor: UnitDescriptor) -> float: + if not from_descriptor.isinstance(cls.generic_unit_descriptor): + raise InvalidUnitConversion( + f"invalid 'from_descriptor; expected an instance of {cls.generic_unit_descriptor}. " + ) + from_unit = MeasurementUnit.from_descriptor(from_descriptor) + return cls.conversion_map[from_unit](value) + + @classmethod + def _from_reference(cls, value: float, to_descriptor: UnitDescriptor) -> float: + if not to_descriptor.isinstance(cls.generic_unit_descriptor): + raise InvalidUnitConversion( + f"invalid 'to_descriptor'; expected an instance of {cls.generic_unit_descriptor}. " + ) + to_unit = MeasurementUnit.from_descriptor(to_descriptor) + return cls.reference_conversion_map[to_unit](value) + + +class ExponentiatedPhysicalPropertyUnitConverter(metaclass=ABCMeta): + """ + Base converter for exponentiated absolute measurement units. + """ + + generic_unit_descriptor: GenericDimension + + @classmethod + def convert( + cls, + value: float, + from_descriptor: UnitDescriptor, + to_descriptor: UnitDescriptor, + ) -> float: + """ + Convert a value from an absolute exponentiated unit to another absolute + exponentiated unit. + Raises 'InvalidUnitConversion' if 'from_descriptor' or 'to_descriptor' are not + an instance of the generic that is registered with the converter or if 'value' + is not a numeric. + """ + if not isinstance(value, (float, int)): + raise InvalidUnitConversion(f"invalid 'value': {value}; expected numeric. ") + return value * cls.get_factor(from_descriptor, to_descriptor) + + @classmethod + def get_factor( + cls, from_descriptor: UnitDescriptor, to_descriptor: UnitDescriptor + ) -> float: + """ + Get the multiplication factor for the conversion from 'from_descriptor' to + 'to_descriptor'. + Raises 'InvalidUnitConversion' if 'from_descriptor' or 'to_descriptor' are not + an instance of the generic that is registered with the converter. + """ + if not from_descriptor.isinstance(cls.generic_unit_descriptor): + raise InvalidUnitConversion( + f"invalid 'from_descriptor; expected an instance of {cls.generic_unit_descriptor}. " + ) + if not to_descriptor.isinstance(cls.generic_unit_descriptor): + raise InvalidUnitConversion( + f"invalid 'to_descriptor'; expected an instance of {cls.generic_unit_descriptor}. " + ) + from_dimension = Dimension.from_descriptor(from_descriptor) + to_dimension = Dimension.from_descriptor(to_descriptor) + converter = get_converter(cls.generic_unit_descriptor.unit_type) + if not issubclass(converter, AbsoluteUnitConverter): + raise InvalidUnitConversion( + f"{cls.generic_unit_descriptor} is a relative unit;" + " conversion between exponentiated relative units is invalid. " + ) + factor = converter.get_factor(from_dimension.unit, to_dimension.unit) + return factor**to_dimension.power + + +class CompositePhysicalPropertyUnitConverter(metaclass=ABCMeta): + """ + Base class for converters that implement conversions for composite units. + Composite unit conversions are absolute, that is the below applies: + + unit_i = unit_j * constant, + where unit_i and unit_j can be any composite units of the same type. + + The `get_factor` method returns the above constant, which is the result of the + division between the numerator factor and the denominator factor. + + e.g.\n + kJ * m^3 / min^2 / K = J * m^3 / s^2 / K * ( 1000 / 60^2 )\n + 1000 is the numerator factor extracted from converting kJ to J.\n + 60^2 is the denominator factor extracted from converting min^2 to s^2. + """ + + generic_unit_descriptor: GenericUnitDescriptor + + @classmethod + def convert( + cls, + value: float, + from_descriptor: UnitDescriptor, + to_descriptor: UnitDescriptor, + ) -> float: + """ + Convert a value from a composite unit to another composite unit. + Raises 'InvalidUnitConversion' if 'from_descriptor' or 'to_descriptor' are not + an instance of the generic that is registered with the converter or if 'value' + is not a numeric. + """ + if not isinstance(value, (float, int)): + raise InvalidUnitConversion(f"invalid 'value': {value}; expected numeric. ") + return value * cls.get_factor(from_descriptor, to_descriptor) + + @classmethod + def get_factor( + cls, from_descriptor: UnitDescriptor, to_descriptor: UnitDescriptor + ) -> float: + """ + Get the multiplication factor for the conversion from 'from_descriptor' to + 'to_descriptor'. + Raises 'InvalidUnitConversion' if 'from_descriptor' or 'to_descriptor' are not + an instance of the generic that is registered with the converter. + """ + if not from_descriptor.isinstance(cls.generic_unit_descriptor): + raise InvalidUnitConversion( + f"invalid 'from_descriptor; expected an instance of {cls.generic_unit_descriptor}. " + ) + if not to_descriptor.isinstance(cls.generic_unit_descriptor): + raise InvalidUnitConversion( + f"invalid 'to_descriptor'; expected an instance of {cls.generic_unit_descriptor}. " + ) + from_dimension = CompositeDimension.from_descriptor(from_descriptor) + to_dimension = CompositeDimension.from_descriptor(to_descriptor) + return cls._get_numerator_factor( + from_dimension, to_dimension + ) / cls._get_denominator_factor(from_dimension, to_dimension) + + @staticmethod + def _get_numerator_factor( + from_dimension: CompositeDimension, to_dimension: CompositeDimension + ) -> float: + numerator_factor = 1.0 + for from_d in from_dimension.numerator: + to_d = to_dimension.get_numerator(from_d.to_generic()) + if to_d is None: + raise InvalidUnitConversion( + f"cannot convert from {from_dimension} to {to_dimension}" + ) + converter = get_converter(type(from_d.unit)) + if issubclass(converter, AbsoluteUnitConverter): + numerator_factor *= converter.get_factor(from_d.unit, to_d.unit) + return numerator_factor + + @staticmethod + def _get_denominator_factor( + from_dimension: CompositeDimension, to_dimension: CompositeDimension + ) -> float: + denominator_factor = 1.0 + + for from_d in from_dimension.denominator: + to_d = to_dimension.get_denominator(from_d.to_generic()) + if to_d is None: + raise InvalidUnitConversion( + f"cannot convert from {from_dimension} to {to_dimension}" + ) + converter = get_converter(type(from_d.unit)) + if issubclass(converter, AbsoluteUnitConverter): + denominator_factor *= converter.get_factor(from_d.unit, to_d.unit) + return denominator_factor