From bdf8932c31de50dadabeca74338128fac26f10ed Mon Sep 17 00:00:00 2001 From: Maximos Nikiforakis Date: Mon, 1 Apr 2024 17:20:49 +0300 Subject: [PATCH 1/9] Introduce analyse and analysed methods in GenericCompositeDimension --- .../tests/units/test_descriptors.py | 53 ++++++++++++++ src/property_utils/units/descriptors.py | 69 +++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/src/property_utils/tests/units/test_descriptors.py b/src/property_utils/tests/units/test_descriptors.py index dc23497..f0a5f30 100644 --- a/src/property_utils/tests/units/test_descriptors.py +++ b/src/property_utils/tests/units/test_descriptors.py @@ -1121,6 +1121,59 @@ def assert_result(self, result_str): self.assertSequenceEqual(str(self.cachedResult()), result_str, str) +@add_to(GenericCompositeDimension_test_suite) +class TestGenericCompositeDimensionAnalyse(TestDescriptor): + produced_type = GenericCompositeDimension + + def subject(self, generic): + generic.analyse() + return generic + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1()], [generic_dimension_2()] + ) + } + ) + def test_already_analysed_composite(self): + self.assert_result("Unit1 / Unit2") + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_5()], [generic_dimension_2()] + ) + } + ) + def test_with_simple_alias_generic_dimension(self): + self.assert_result("Unit1 / (Unit4^2) / Unit2") + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_5()], [generic_dimension_3()] + ) + } + ) + def test_with_multiple_alias_generic_dimensions(self): + self.assert_result("Unit1 / (Unit1^3) / (Unit4^2)") + + +@add_to(GenericCompositeDimension_test_suite) +class TestGenericCompositeDimensionAnalysed(TestGenericCompositeDimensionAnalyse): + """ + Run all tests in TestGenericCompositeDimensionAnalyse but with a different subject. + """ + + def subject(self, generic): + return generic.analysed() + + def assert_result(self, result_str): + self.assertResultIsNot(self._subjectKwargs["generic"]) + self.assertSequenceEqual(str(self.cachedResult()), result_str, str) + + @add_to(GenericCompositeDimension_test_suite) class TestGenericCompositeDimensionInverseGeneric(TestDescriptor): def test_inverse_generic(self): diff --git a/src/property_utils/units/descriptors.py b/src/property_utils/units/descriptors.py index 1bab339..2b56762 100644 --- a/src/property_utils/units/descriptors.py +++ b/src/property_utils/units/descriptors.py @@ -866,6 +866,75 @@ def simplified(self) -> "GenericCompositeDimension": copy.simplify() return copy + def analyse(self) -> None: + """ + Analyse this composite by replacing its alias units with their aliased units. + + Examples: + >>> class MassUnit(MeasurementUnit): ... + >>> class LengthUnit(MeasurementUnit): ... + >>> class TimeUnit(MeasurementUnit): ... + + >>> class PressureUnit(AliasMeasurementUnit): + ... @classmethod + ... def aliased_generic_descriptor(cls) -> GenericCompositeDimension: + ... return MassUnit / LengthUnit / (TimeUnit**2) + + >>> composite = PressureUnit / LengthUnit + >>> composite + + + >>> composite.analyse() + >>> composite + + """ + for n in self.numerator: + if issubclass(n.unit_type, AliasMeasurementUnit): + aliased = n.unit_type.aliased_generic_descriptor() ** n.power + if isinstance(aliased, GenericDimension): + self.numerator.append(aliased) + elif isinstance(aliased, GenericCompositeDimension): + self.numerator.extend(aliased.numerator) + self.denominator.extend(aliased.denominator) + + self.numerator.remove(n) + + for d in self.denominator: + if issubclass(d.unit_type, AliasMeasurementUnit): + aliased = d.unit_type.aliased_generic_descriptor() ** d.power + if isinstance(aliased, GenericDimension): + self.denominator.append(aliased) + elif isinstance(aliased, GenericCompositeDimension): + self.denominator.extend(aliased.numerator) + self.numerator.extend(aliased.denominator) + + self.denominator.remove(d) + + def analysed(self) -> "GenericCompositeDimension": + """ + Returns an analysed version of this composite generic as a new object. + + Examples: + >>> class MassUnit(MeasurementUnit): ... + >>> class LengthUnit(MeasurementUnit): ... + >>> class TimeUnit(MeasurementUnit): ... + + >>> class PressureUnit(AliasMeasurementUnit): + ... @classmethod + ... def aliased_generic_descriptor(cls) -> GenericCompositeDimension: + ... return MassUnit / LengthUnit / (TimeUnit**2) + + >>> composite = PressureUnit / LengthUnit + >>> composite + + + >>> composite.analysed() + + """ + copy = replace(self) + copy.analyse() + return copy + def inverse_generic(self): """ Create a generic composite with inverse units. From 8c791576f0f142afdad92eef89dc979d3ef6ed06 Mon Sep 17 00:00:00 2001 From: Maximos Nikiforakis Date: Mon, 1 Apr 2024 17:29:18 +0300 Subject: [PATCH 2/9] Add test data utils --- src/property_utils/tests/data.py | 145 +++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/src/property_utils/tests/data.py b/src/property_utils/tests/data.py index 78b1596..7d97f42 100644 --- a/src/property_utils/tests/data.py +++ b/src/property_utils/tests/data.py @@ -1,4 +1,5 @@ from property_utils.units.descriptors import ( + GenericUnitDescriptor, MeasurementUnit, AliasMeasurementUnit, Dimension, @@ -52,11 +53,67 @@ class Unit3(AliasMeasurementUnit): C = "C" c = "c" + @classmethod + def aliased_generic_descriptor(cls) -> GenericUnitDescriptor: + return Unit1**3 + @classmethod def si(cls) -> "Unit3": return cls.c +class Unit5(AliasMeasurementUnit): + E = "E" + e = "e" + + @classmethod + def aliased_generic_descriptor(cls) -> GenericCompositeDimension: + return Unit1 / (Unit4**2) + + @classmethod + def si(cls) -> "Unit5": + return cls.e + + +class Unit6(AliasMeasurementUnit): + F = "F" + f = "f" + + @classmethod + def aliased_generic_descriptor(cls) -> Dimension: + return Unit1**2 + + @classmethod + def si(cls) -> "Unit6": + return cls.f + + +class Unit7(AliasMeasurementUnit): + G = "G" + g = "g" + + @classmethod + def aliased_generic_descriptor(cls) -> CompositeDimension: + return Unit1 / (Unit4**2) / Unit2 # Unit5 / Unit2 + + @classmethod + def si(cls) -> "Unit7": + return cls.g + + +class Unit8(AliasMeasurementUnit): + H = "H" + h = "h" + + @classmethod + def aliased_generic_descriptor(cls) -> CompositeDimension: + return Unit1**2 / (Unit4**2) # Unit6 / Unit4^2 + + @classmethod + def si(cls) -> "Unit8": + return cls.h + + class UnregisteredConverter(AbsoluteUnitConverter): ... @@ -87,6 +144,30 @@ class Unit4Converter(AbsoluteUnitConverter): conversion_map = {Unit4.D: 1, Unit4.d: 5} +@register_converter(Unit5) +class Unit5Converter(AbsoluteUnitConverter): + reference_unit = Unit5.E + conversion_map = {Unit5.E: 1, Unit5.e: 15} + + +@register_converter(Unit6) +class Unit6Converter(AbsoluteUnitConverter): + reference_unit = Unit6.F + conversion_map = {Unit6.F: 1, Unit6.f: 2} + + +@register_converter(Unit7) +class Unit7Converter(AbsoluteUnitConverter): + reference_unit = Unit7.G + conversion_map = {Unit7.G: 1, Unit7.g: 3} + + +@register_converter(Unit8) +class Unit8Converter(AbsoluteUnitConverter): + reference_unit = Unit8.H + conversion_map = {Unit8.H: 1, Unit8.h: 4} + + @register_converter(Unit1**2) class Unit1_2Converter(ExponentiatedUnitConverter): ... @@ -119,6 +200,14 @@ class Unit1Unit4Converter(CompositeUnitConverter): ... class Unit1Unit4FractionConverter(CompositeUnitConverter): ... +@register_converter(Unit1 / (Unit4**2)) +class Unit1Unit4_2Converter(CompositeUnitConverter): ... + + +@register_converter(Unit6 / (Unit4**2)) +class Unit6Unit4_2Converter(CompositeUnitConverter): ... + + @register_converter(Unit1**2 / Unit4**3) class Unit1_2Unit4_3Converter(CompositeUnitConverter): ... @@ -144,6 +233,34 @@ def dimension_3(power: float = 1) -> Dimension: return Dimension(Unit3.C, power) +def dimension_4(power: float = 1) -> Dimension: + """ + D^power + """ + return Dimension(Unit4.D, power) + + +def dimension_5(power: float = 1) -> Dimension: + """ + E^power + """ + return Dimension(Unit5.E, power) + + +def dimension_6(power: float = 1) -> Dimension: + """ + F^power + """ + return Dimension(Unit6.F, power) + + +def dimension_7(power: float = 1) -> Dimension: + """ + G^power + """ + return Dimension(Unit7.G, power) + + def generic_dimension_1(power: float = 1) -> GenericDimension: """ Unit1^power @@ -165,6 +282,34 @@ def generic_dimension_3(power: float = 1) -> GenericDimension: return GenericDimension(Unit3, power) +def generic_dimension_4(power: float = 1) -> GenericDimension: + """ + Unit4^power + """ + return GenericDimension(Unit4, power) + + +def generic_dimension_5(power: float = 1) -> GenericDimension: + """ + Unit5^power + """ + return GenericDimension(Unit5, power) + + +def generic_dimension_6(power: float = 1) -> GenericDimension: + """ + Unit6^power + """ + return GenericDimension(Unit6, power) + + +def generic_dimension_7(power: float = 1) -> GenericDimension: + """ + Unit7^power + """ + return GenericDimension(Unit7, power) + + def composite_dimension() -> CompositeDimension: """ (A^2) * B / (C^3) From c4d2905fe54aa50b8d13ae3d0e9d54815f0cf4d5 Mon Sep 17 00:00:00 2001 From: Maximos Nikiforakis Date: Mon, 1 Apr 2024 17:28:28 +0300 Subject: [PATCH 3/9] Adjust isinstance methods to work with aliased units --- .../tests/units/test_descriptors.py | 238 +++++++++++++++++- src/property_utils/units/descriptors.py | 87 ++++++- 2 files changed, 322 insertions(+), 3 deletions(-) diff --git a/src/property_utils/tests/units/test_descriptors.py b/src/property_utils/tests/units/test_descriptors.py index f0a5f30..04c1332 100644 --- a/src/property_utils/tests/units/test_descriptors.py +++ b/src/property_utils/tests/units/test_descriptors.py @@ -1,3 +1,4 @@ +from typing import Any from unittest import TestSuite, TextTestRunner from operator import mul, truediv @@ -24,12 +25,21 @@ Unit1, Unit2, Unit3, + Unit5, + Unit6, + Unit7, dimension_1, dimension_2, dimension_3, + dimension_4, + dimension_5, generic_dimension_1, generic_dimension_2, generic_dimension_3, + generic_dimension_4, + generic_dimension_5, + generic_dimension_6, + generic_dimension_7, composite_dimension, generic_composite_dimension, ) @@ -423,7 +433,7 @@ def test_with_generic_composite_dimension(self): @add_to(AliasMeasurementUnit_test_suite) class TestAliasMeasurementUnitIsInstance(TestDescriptor): def subject(self, generic): - return Unit3.C.isinstance(generic) + return Unit5.E.isinstance(generic) @args({"generic": Unit1.A}) def test_with_measurement_unit(self): @@ -441,7 +451,17 @@ def test_with_composite_dimension(self): def test_with_measurement_unit_type(self): self.assertResultFalse() - @args({"generic": Unit3}) + @args({"generic": Unit5}) + def test_with_alias_measurement_unit_type(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1()], [generic_dimension_4(2)] + ) + } + ) def test_with_aliased_measurement_unit_type(self): self.assertResultTrue() @@ -895,6 +915,106 @@ def test_with_generic_composite_dimension(self): self.assertResultFalse() +@add_to(Dimension_test_suite) +class TestAliasDimensionIsInstance(TestDescriptor): + def subject(self, generic): + return dimension_5().isinstance(generic) + + @args({"generic": generic_dimension_5()}) + def test_with_generic_dimension(self): + self.assertResultTrue() + + @args({"generic": Unit5}) + def test_with_measurement_unit_type(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1()], [generic_dimension_4(2)] + ) + } + ) + def test_with_aliased_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1(2)], [generic_dimension_4(4)] + ) + } + ) + def test_with_wrong_aliased_composite_dimension(self): + self.assertResultFalse() + + +@add_to(Dimension_test_suite) +class TestExponentiatedAliasDimensionIsInstance(TestDescriptor): + def subject(self, generic): + return dimension_5(2).isinstance(generic) + + @args({"generic": generic_dimension_5(2)}) + def test_with_generic_dimension(self): + self.assertResultTrue() + + @args({"generic": Unit5}) + def test_with_measurement_unit_type(self): + self.assertResultFalse() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1(2)], [generic_dimension_4(4)] + ) + } + ) + def test_with_aliased_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1()], [generic_dimension_4(2)] + ) + } + ) + def test_with_wrong_aliased_composite_dimension(self): + self.assertResultFalse() + + +@add_to(Dimension_test_suite) +class TestAliasedDimensionIsInstance(TestDescriptor): + def subject(self, generic) -> Any: + return dimension_1(2).isinstance(generic) + + @args({"generic": generic_dimension_1(2)}) + def test_with_dimension(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_6()}) + def test_with_alias_dimension(self): + self.assertResultTrue() + + @args({"generic": Unit6}) + def test_with_alias_measurement_unit_type(self): + self.assertResultTrue() + + +@add_to(Dimension_test_suite) +class TestExponentiatedAliasedDimensionIsInstance(TestDescriptor): + def subject(self, generic) -> Any: + return dimension_1(4).isinstance(generic) + + @args({"generic": generic_dimension_1(4)}) + def test_with_dimension(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_6(2)}) + def test_with_alias_dimension(self): + self.assertResultTrue() + + @add_to(Dimension_test_suite) class TestDimensionToGeneric(TestDescriptor): def test_dimension_to_generic(self): @@ -1555,6 +1675,120 @@ def test_with_generic_dimension(self): self.assertResultFalse() +@add_to(CompositeDimension_test_suite) +class TestAliasedCompositeDimensionIsInstance(TestDescriptor): + def subject(self, generic): + return self.build_descriptor().isinstance(generic) + + def build_descriptor(self): + return CompositeDimension([dimension_1()], [dimension_4(2)]) + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1()], [generic_dimension_4(2)] + ) + } + ) + def test_with_generic_composite_dimension(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_5()}) + def test_with_alias_dimension(self): + self.assertResultTrue() + + +@add_to(CompositeDimension_test_suite) +class TestTwiceAliasedCompositeDimensionIsInstance(TestDescriptor): + def subject(self, generic): + return self.build_descriptor().isinstance(generic) + + def build_descriptor(self): + return CompositeDimension([dimension_5()], [dimension_2()]) + + @args({"generic": Unit7}) + def test_with_alias_unit(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_7()}) + def test_with_alias_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_5()], [generic_dimension_2()] + ) + } + ) + def test_with_generic_composite_dimension(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_5()}) + def test_with_other_generic_dimension(self): + self.assertResultFalse() + + +@add_to(CompositeDimension_test_suite) +class TestNumeratorAliasCompositeDimensionIsInstance(TestDescriptor): + def subject(self, generic): + return self.build_descriptor().isinstance(generic) + + def build_descriptor(self): + return CompositeDimension([dimension_5()], [dimension_2(2)]) + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_5()], [generic_dimension_2(2)] + ) + } + ) + def test_with_generic_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1()], + [generic_dimension_2(2), generic_dimension_4(2)], + ) + } + ) + def test_with_aliased_generic_composite_dimension(self): + self.assertResultTrue() + + +@add_to(CompositeDimension_test_suite) +class TestDenominatorAliasCompositeDimensionIsInstance(TestDescriptor): + def subject(self, generic): + return self.build_descriptor().isinstance(generic) + + def build_descriptor(self): + return CompositeDimension([dimension_2()], [dimension_5()]) + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_2()], [generic_dimension_5()] + ) + } + ) + def test_with_generic_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_2(), generic_dimension_4(2)], + [generic_dimension_1()], + ) + } + ) + def test_with_aliased_generic_composite_dimension(self): + self.assertResultTrue() + + @add_to(CompositeDimension_test_suite) class TestCompositeDimensionToGeneric(TestDescriptor): def test_to_generic(self): diff --git a/src/property_utils/units/descriptors.py b/src/property_utils/units/descriptors.py index 2b56762..04094f9 100644 --- a/src/property_utils/units/descriptors.py +++ b/src/property_utils/units/descriptors.py @@ -411,6 +411,21 @@ def from_descriptor(descriptor: UnitDescriptor) -> MeasurementUnit: f"cannot create AliasMeasurementUnit from descriptor {descriptor}" ) + def isinstance(self, generic: GenericUnitDescriptor) -> bool: + """ + Returns True if the AliasMeasurementUnit is an instance of the generic or if + its generic descriptor equals the generic, False otherwise. + + Examples: + >>> class PowerUnit(AliasMeasurementUnit): + ... NEWTON = "N" + ... def aliased_generic_descriptor(cls) -> GenericCompositeDimension: + ... return EnergyUnit + """ + return super().isinstance(generic) or ( + self.aliased_generic_descriptor() == generic + ) + @classmethod def aliased_generic_descriptor(cls) -> GenericUnitDescriptor: """ @@ -623,12 +638,17 @@ def isinstance(self, generic: GenericUnitDescriptor) -> bool: >>> Dimension(TemperatureUnit.CELCIUS).isinstance(TemperatureUnit**2) False """ + if self._isinstance_aliased(generic) or self._isinstance_alias(generic): + return True + if isinstance(generic, MeasurementUnitType): generic = GenericDimension(generic) if not isinstance(generic, GenericDimension): return False + if isinstance(self.unit, generic.unit_type) and self.power == generic.power: return True + return False def to_generic(self) -> GenericDimension: @@ -654,6 +674,42 @@ def inverse(self) -> "CompositeDimension": """ return CompositeDimension([], [replace(self)]) + def _isinstance_aliased(self, generic: GenericUnitDescriptor) -> bool: + """ + Returns True if the generic is the aliased unit descriptor of this Dimension, + False otherwise. + + Only applicable if this Dimension's unit is of type AliasMeasurementUnit. + """ + return ( + isinstance(self.unit, AliasMeasurementUnit) + and (self.unit.aliased_generic_descriptor() ** self.power) == generic + ) + + def _isinstance_alias(self, generic: GenericUnitDescriptor) -> bool: + """ + Returns True if this Dimension's unit is an instance of the aliased unit + descriptor of the generic, False otherwise. + + Only applicable if generic is an AliasMeasurementUnit. + """ + if isinstance(generic, MeasurementUnitType): + generic = GenericDimension(generic) + + if not isinstance(generic, GenericDimension): + return False + + if not issubclass(generic.unit_type, AliasMeasurementUnit): + return False + + if ( + generic.unit_type.aliased_generic_descriptor() ** generic.power + == self.to_generic() + ): + return True + + return False + def __mul__(self, descriptor: "UnitDescriptor") -> "CompositeDimension": """ Defines multiplication between Dimension(s) and other unit descriptors. @@ -1131,9 +1187,17 @@ def isinstance(self, generic: GenericUnitDescriptor) -> bool: >>> (TemperatureUnit.CELCIUS * LengthUnit.METER).isinstance(TemperatureUnit**2) False """ + if self._isinstance_alias(generic): + return True + if not isinstance(generic, GenericCompositeDimension): return False - return self.to_generic() == generic + + return ( + self.to_generic() == generic + or self.to_generic().analysed().simplified() + == generic.analysed().simplified() + ) def to_generic(self) -> GenericCompositeDimension: """ @@ -1307,6 +1371,27 @@ def inverse(self) -> "CompositeDimension": """ return CompositeDimension(self._denominator_copy(), self._numerator_copy()) + def _isinstance_alias(self, generic: GenericUnitDescriptor) -> bool: + """ + Returns True if the generic is an alias for this CompositeDimension's generic + descriptor, False otherwise. + + Only applicable if generic is an AliasMeasurementUnit. + """ + if isinstance(generic, MeasurementUnitType): + generic = GenericDimension(generic) + + if not isinstance(generic, GenericDimension): + return False + + if not issubclass(generic.unit_type, AliasMeasurementUnit): + return False + + return ( + self.to_generic().analysed().simplified() + == generic.unit_type.aliased_generic_descriptor() ** generic.power + ) + def _numerator_copy(self) -> List[Dimension]: return [replace(n) for n in self.numerator] From 016e7bfdf8b6c4047299889cf44f49cb80af4bce Mon Sep 17 00:00:00 2001 From: Maximos Nikiforakis Date: Tue, 2 Apr 2024 09:52:12 +0300 Subject: [PATCH 4/9] Implement si methods for unit descriptors --- .../tests/units/test_descriptors.py | 48 +++++++++++++++++++ src/property_utils/units/descriptors.py | 45 +++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/property_utils/tests/units/test_descriptors.py b/src/property_utils/tests/units/test_descriptors.py index 04c1332..1f21da1 100644 --- a/src/property_utils/tests/units/test_descriptors.py +++ b/src/property_utils/tests/units/test_descriptors.py @@ -877,6 +877,30 @@ def test_with_generic_composite_dimension(self): self.assertResultRaises(UnitDescriptorTypeError) +@add_to(Dimension_test_suite) +class TestDimensionSi(TestDescriptor): + produced_type = Dimension + + def subject(self, dimension) -> Any: + return dimension.si() + + @args({"dimension": dimension_1(3)}) + def test_with_exponentiated_dimension(self): + self.assert_result("(a^3)") + + @args({"dimension": dimension_1()}) + def test_with_simple_dimension(self): + self.assert_result("a") + + @args({"dimension": Dimension(Unit1.a, 2)}) + def test_with_exponentiated_si_dimension(self): + self.assert_result("(a^2)") + + @args({"dimension": Dimension(Unit1.a)}) + def test_with_si_dimension(self): + self.assert_result("a") + + @add_to(Dimension_test_suite) class TestDimensionIsInstance(TestDescriptor): def subject(self, generic): @@ -1617,6 +1641,30 @@ def test_with_generic_composite_dimension(self): self.assertResultRaises(UnitDescriptorTypeError) +@add_to(CompositeDimension_test_suite) +class TestCompositeDimensionSi(TestDescriptor): + produced_type = CompositeDimension + + def subject(self, composite): + return composite.si() + + @args({"composite": CompositeDimension([dimension_1()], [dimension_2()])}) + def test_with_simple_composite(self): + self.assert_result("a / b") + + @args({"composite": CompositeDimension([dimension_3(2)], [dimension_2(3)])}) + def test_with_composite(self): + self.assert_result("(c^2) / (b^3)") + + @args({"composite": CompositeDimension([Dimension(Unit1.a)], [Dimension(Unit2.b)])}) + def test_with_si_composite(self): + self.assert_result("a / b") + + @args({"composite": CompositeDimension([Dimension(Unit1.a)], [dimension_2()])}) + def test_with_partial_si_composite(self): + self.assert_result("a / b") + + @add_to(CompositeDimension_test_suite) class TestCompositeDimensionIsInstance(TestDescriptor): def subject(self, generic): diff --git a/src/property_utils/units/descriptors.py b/src/property_utils/units/descriptors.py index 04094f9..1c2aa87 100644 --- a/src/property_utils/units/descriptors.py +++ b/src/property_utils/units/descriptors.py @@ -66,6 +66,11 @@ class UnitDescriptor(Protocol): Descriptor for a property unit that has a specific unit, e.g. cm^2 or ft^2. """ + def si(self) -> "UnitDescriptor": + """ + Returns this descriptor with SI units. + """ + def isinstance(self, generic: GenericUnitDescriptor) -> bool: """ Returns True if the UnitDescriptor is an instance of the generic, False @@ -624,6 +629,22 @@ def from_descriptor(descriptor: UnitDescriptor) -> "Dimension": f"cannot create Dimension from descriptor: {descriptor}" ) + def si(self) -> "Dimension": + """ + Returns this dimension in SI units. + + Examples: + >>> class LengthUnit(MeasurementUnit): + ... METER = "m" + ... FOOT = "ft" + ... @classmethod + ... def si(cls): return cls.METER + + >>> (LengthUnit.FOOT**2).si() + + """ + return Dimension(self.unit.si(), self.power) + def isinstance(self, generic: GenericUnitDescriptor) -> bool: """ Returns True if the Dimension is an instance of the generic, False @@ -1170,6 +1191,30 @@ def from_descriptor(descriptor: UnitDescriptor) -> "CompositeDimension": ) return descriptor + def si(self) -> "CompositeDimension": + """ + Returns this composite dimension in SI units. + + Examples: + >>> class TemperatureUnit(MeasurementUnit): + ... KELVIN = "K" + ... RANKINE = "R" + ... @classmethod + ... def si(cls): return cls.KELVIN + + >>> class LengthUnit(MeasurementUnit): + ... METER = "m" + ... FOOT = "ft" + ... @classmethod + ... def si(cls): return cls.METER + + >>> (TemperatureUnit.RANKINE / LengthUnit.FOOT**2).si() + + """ + return CompositeDimension( + [n.si() for n in self.numerator], [d.si() for d in self.denominator] + ) + def isinstance(self, generic: GenericUnitDescriptor) -> bool: """ Returns True if the CompositeDimension is an instance of the generic, False From 18f91724b52308f4d31437b1c59e219501b0eb1b Mon Sep 17 00:00:00 2001 From: Maximos Nikiforakis Date: Fri, 5 Apr 2024 19:40:23 +0300 Subject: [PATCH 5/9] Implement is_equivalent for generic descriptors --- .../tests/units/test_descriptors.py | 262 +++++++++++++++++- src/property_utils/units/descriptors.py | 189 +++++++++++++ 2 files changed, 447 insertions(+), 4 deletions(-) diff --git a/src/property_utils/tests/units/test_descriptors.py b/src/property_utils/tests/units/test_descriptors.py index 1f21da1..057cacd 100644 --- a/src/property_utils/tests/units/test_descriptors.py +++ b/src/property_utils/tests/units/test_descriptors.py @@ -3,6 +3,7 @@ from operator import mul, truediv from unittest_extensions import args +from typing_extensions import override from property_utils.units.descriptors import ( MeasurementUnit, @@ -182,6 +183,46 @@ def test_with_none(self): self.assertResultRaises(DescriptorExponentError) +@add_to(MeasurementUnitMeta_test_suite) +class TestMeasurementUnitMetaIsEquivalent(TestDescriptor): + def subject(self, descriptor): + return Unit3.is_equivalent(descriptor) + + @args({"descriptor": generic_dimension_1(3)}) + def test_with_aliased_unit(self): + self.assertResultTrue() + + @args({"descriptor": Unit3}) + def test_with_measurement_unit_type(self): + self.assertResultTrue() + + @args({"descriptor": generic_dimension_3()}) + def test_with_generic_dimension(self): + self.assertResultTrue() + + @args({"descriptor": generic_dimension_3(2)}) + def test_with_exponentiated_generic_dimension(self): + self.assertResultFalse() + + @args({"descriptor": GenericCompositeDimension([generic_dimension_3()])}) + def test_with_generic_composite_dimension(self): + self.assertResultTrue() + + @args({"descriptor": GenericCompositeDimension([generic_dimension_1(3)])}) + def test_with_alias_generic_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "descriptor": GenericCompositeDimension( + [generic_dimension_3()], [generic_dimension_1()] + ) + } + ) + def test_with_generic_composite_dimension_same_numerator(self): + self.assertResultFalse() + + @add_to(MeasurementUnit_test_suite) class TestMeasurementUnitFromDescriptor(TestDescriptor): @@ -795,7 +836,7 @@ def test_with_none(self): self.assertResultRaises(DescriptorExponentError) -@add_to(GenericCompositeDimension_test_suite) +@add_to(GenericDimension_test_suite) class TestGenericDimensionEquality(TestDescriptor): def subject(self, generic): return generic_dimension_1() == generic @@ -824,11 +865,104 @@ def test_with_generic_dimension(self): def test_with_same_exponentiated_generic_dimension(self): self.assertResultFalse() - @args({"generic": generic_composite_dimension()}) - def test_with_generic_composite_dimension(self): + @args({"generic": GenericCompositeDimension([generic_dimension_1()])}) + def test_with_numerator_generic_composite_dimension(self): + self.assertResultFalse() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1()], [generic_dimension_2()] + ) + } + ) + def test_with_generic_composite_dimension_same_numerator(self): + self.assertResultFalse() + + +@add_to(GenericDimension_test_suite) +class TestGenericDimensionIsEquivalent(TestGenericDimensionEquality): + """ + Run all tests in TestGenericDimensionEquality but with different subject. + """ + + def subject(self, generic): + return generic_dimension_1().is_equivalent(generic) + + @override + @args({"generic": Unit1}) + def test_with_measurement_unit_type(self): + self.assertResultTrue() + + @override + @args({"generic": GenericCompositeDimension([generic_dimension_1()])}) + def test_with_numerator_generic_composite_dimension(self): + self.assertResultTrue() + + +@add_to(GenericDimension_test_suite) +class TestExponentiatedGenericDimensionIsEquivalent(TestDescriptor): + def subject(self, generic): + return generic_dimension_1(3).is_equivalent(generic) + + @args({"generic": generic_dimension_1(3)}) + def test_with_same_generic(self): + return self.assertResultTrue() + + @args({"generic": Unit3}) + def test_with_alias_measurement_unit_type(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_3()}) + def test_with_alias_generic_dimension(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_3(2)}) + def test_with_exponentiated_alias_dimension(self): self.assertResultFalse() +@add_to(GenericDimension_test_suite) +class TestAliasGenericDimensionIsEquivalent(TestDescriptor): + def subject(self, generic, power=1): + return generic_dimension_3(power).is_equivalent(generic) + + @args({"generic": generic_dimension_1(3)}) + def test_with_aliased_generic_dimension(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_6(3), "power": 2}) + def test_with_other_alias_generic_dimension(self): + self.assertResultTrue() + + +@add_to(GenericDimension_test_suite) +class TestComplexAliasGenericDimensionIsEquivalent(TestDescriptor): + def subject(self, generic, power=1): + return generic_dimension_5(power).is_equivalent(generic) + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1()], [generic_dimension_4(2)] + ) + } + ) + def test_with_aliased_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1(2)], [generic_dimension_4(4)] + ), + "power": 2, + } + ) + def test_with_exponentiated_aliased_composite_dimension(self): + self.assertResultTrue() + + @add_to(Dimension_test_suite) class TestDimensionFromDescriptor(TestDescriptor): produced_type = Dimension @@ -1152,6 +1286,20 @@ def test_with_generic_composite_dimension(self): self.assertResultFalse() +@add_to(Dimension_test_suite) +class TestExponentiatedDimensionEquality(TestDescriptor): + def subject(self, dimension): + return dimension_1(2) == dimension + + @args({"dimension": Unit1.A}) + def test_with_measurement_unit(self): + self.assertResultFalse() + + @args({"dimension": dimension_1()}) + def test_with_dimension(self): + self.assertResultFalse() + + @add_to(GenericCompositeDimension_test_suite) class TestGenericCompositeDimensionToSi(TestDescriptor): produced_type = CompositeDimension @@ -1541,7 +1689,15 @@ def test_with_generic_dimension(self): self.assertResultFalse() @args({"generic": generic_dimension_1(2)}) - def test_with_exponentiated_generic_dimension(self): + def test_with_numerator(self): + self.assertResultFalse() + + @args({"generic": Unit6}) + def test_with_alias_measurement_unit_type(self): + self.assertResultFalse() + + @args({"generic": generic_dimension_6()}) + def test_with_alias_generic_dimension(self): self.assertResultFalse() @args({"generic": GenericCompositeDimension([generic_dimension_1(2)], [])}) @@ -1604,6 +1760,104 @@ def build_descriptor(self): def test_denominator_dimension(self): self.assertResultFalse() +@add_to(GenericCompositeDimension_test_suite) +class TestAliasedGenericCompositeDimensionIsEquivalent(TestDescriptor): + def subject(self, generic): + return self.build_descriptor().is_equivalent(generic) + + def build_descriptor(self): + return GenericCompositeDimension( + [generic_dimension_1()], [generic_dimension_4(2)] + ) + + @args({"generic": Unit5}) + def test_with_alias_measurement_unit_type(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_5()}) + def test_with_alias_generic_dimension(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_5(2)}) + def test_with_other_generic_dimension(self): + self.assertResultFalse() + + +@add_to(GenericCompositeDimension_test_suite) +class TestSingleNumeratorGenericCompositeDimensionIsEquivalent(TestDescriptor): + def subject(self, generic): + return self.build_descriptor().is_equivalent(generic) + + def build_descriptor(self): + return GenericCompositeDimension([generic_dimension_3()]) + + @args({"generic": Unit3}) + def test_with_measurement_unit_type(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_3()}) + def test_with_generic_dimension(self): + self.assertResultTrue() + + + +@add_to(GenericCompositeDimension_test_suite) +class TestComplexAliasGenericCompositeDimensionIsEquivalent(TestDescriptor): + def subject(self, generic): + return self.build_descriptor().is_equivalent(generic) + + def build_descriptor(self): + """ + Unit7 / Unit6 + """ + return GenericCompositeDimension( + [generic_dimension_7()], [generic_dimension_6()] + ) + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_5()], [generic_dimension_6(), generic_dimension_2()] + ) + } + ) # Unit5 / Unit6 / Unit2 + def test_with_other_alias_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1()], + [generic_dimension_4(2), generic_dimension_2(), generic_dimension_6()], + ) + } + ) # Unit1/ Unit4^2 / Unit2 / Unit6 + def test_with_aliased_numerator_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_7()], [generic_dimension_1(2)] + ) + } + ) # Unit7 / Unit1^2 + def test_with_aliased_denominator_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [], + [generic_dimension_4(2), generic_dimension_2(), generic_dimension_1()], + ) + } + ) # 1 / Unit4^2 / Unit2 / Unit1 + def test_with_fully_aliased_composite_dimension(self): + self.assertResultTrue() + + + @add_to(CompositeDimension_test_suite) class TestCompositeDimensionFromDescriptor(TestDescriptor): diff --git a/src/property_utils/units/descriptors.py b/src/property_utils/units/descriptors.py index 1c2aa87..ed17306 100644 --- a/src/property_utils/units/descriptors.py +++ b/src/property_utils/units/descriptors.py @@ -44,6 +44,14 @@ def inverse_generic(self) -> "GenericCompositeDimension": Create a generic composite with inverse units. """ + def is_equivalent(self, other: "GenericUnitDescriptor") -> bool: + """ + Returns True if this generic is equivalent to the given one, False otherwise. + + A generic can be equivalent with another generic if the latter or the former + is an alias. + """ + def __mul__( self, generic: "GenericUnitDescriptor" ) -> "GenericCompositeDimension": ... @@ -134,6 +142,62 @@ def inverse_generic(cls) -> "GenericCompositeDimension": """ return GenericCompositeDimension([], [GenericDimension(cls)]) + # pylint: disable=too-many-return-statements + def is_equivalent(cls, other: GenericUnitDescriptor) -> bool: + """ + Returns True if this generic is equivalent to the given one, False otherwise. + + A generic can be equivalent with another generic if the latter or the former + is an alias. + + Examples: + >>> class LengthUnit(MeasurementUnit): ... + >>> class MassUnit(MeasurementUnit): ... + >>> class TimeUnit(MeasurementUnit): ... + >>> class ForceUnit(AliasMeasurementUnit): + ... @classmethod + ... def aliased_generic_descriptor(cls): + ... return MassUnit * LengthUnit / (TimeUnit**2) + + >>> ForceUnit.is_equivalent(MassUnit * LengthUnit / (TimeUnit**2)) + True + + >>> class EnergyUnit(AliasMeasurementUnit): + ... @classmethod + ... def aliased_generic_descriptor(cls): + ... return ForceUnit * LengthUnit + + >>> EnergyUnit.is_equivalent(MassUnit * (LengthUnit**2) / (TimeUnit**2)) + True + """ + if isinstance(other, MeasurementUnitType): + return cls == other + + if isinstance(other, GenericDimension): + if cls == other.unit_type and other.power == 1: + return True + + if issubclass(other.unit_type, AliasMeasurementUnit): + return ( + other.unit_type.aliased_generic_descriptor() ** other.power + ).is_equivalent(cls) + + if issubclass(cls, AliasMeasurementUnit): + return cls.aliased_generic_descriptor().is_equivalent(other) + + elif isinstance(other, GenericCompositeDimension): + if ( + other.denominator == [] + and len(other.numerator) == 1 + and other.numerator[0].is_equivalent(cls) + ): + return True + + if issubclass(cls, AliasMeasurementUnit): + return cls.aliased_generic_descriptor().is_equivalent(other) + + return False + def __mul__(cls, other: GenericUnitDescriptor) -> "GenericCompositeDimension": """ Defines multiplication between MeasurementUnit types and other generic @@ -493,6 +557,70 @@ def inverse_generic(self) -> "GenericCompositeDimension": """ return GenericCompositeDimension([], [replace(self)]) + # pylint: disable=too-many-return-statements + def is_equivalent(self, other: GenericUnitDescriptor) -> bool: + """ + Returns True if this generic is equivalent to the given one, False otherwise. + + A generic can be equivalent with another generic if the latter or the former + is an alias. + + Examples: + >>> class LengthUnit(MeasurementUnit): ... + >>> class MassUnit(MeasurementUnit): ... + >>> class TimeUnit(MeasurementUnit): ... + >>> class ForceUnit(AliasMeasurementUnit): + ... @classmethod + ... def aliased_generic_descriptor(cls): + ... return MassUnit * LengthUnit / (TimeUnit**2) + + >>> ForceUnit.is_equivalent(MassUnit * LengthUnit / (TimeUnit**2)) + True + + >>> class EnergyUnit(AliasMeasurementUnit): + ... @classmethod + ... def aliased_generic_descriptor(cls): + ... return ForceUnit * LengthUnit + + >>> EnergyUnit.is_equivalent(MassUnit * (LengthUnit**2) / (TimeUnit**2)) + True + """ + if isinstance(other, MeasurementUnitType): + if self.unit_type == other and self.power == 1: + return True + + if issubclass(other, AliasMeasurementUnit): + return other.aliased_generic_descriptor().is_equivalent(self) # type: ignore[attr-defined] + + elif isinstance(other, GenericDimension): + if self.unit_type == other.unit_type and self.power == other.power: + return True + + if issubclass(other.unit_type, AliasMeasurementUnit): + return ( + other.unit_type.aliased_generic_descriptor() ** other.power + ).is_equivalent(self) + + if issubclass(self.unit_type, AliasMeasurementUnit): + return ( + self.unit_type.aliased_generic_descriptor() ** self.power + ).is_equivalent(other) + + elif isinstance(other, GenericCompositeDimension): + if ( + other.denominator == [] + and len(other.numerator) == 1 + and other.numerator[0].is_equivalent(self) + ): + return True + + if issubclass(self.unit_type, AliasMeasurementUnit): + return ( + self.unit_type.aliased_generic_descriptor() ** self.power + ).is_equivalent(other) + + return False + def __mul__(self, generic: GenericUnitDescriptor) -> "GenericCompositeDimension": """ Defines multiplication between GenericDimension(s) and other generic @@ -1026,6 +1154,67 @@ def inverse_generic(self): self._denominator_copy(), self._numerator_copy() ) + def is_equivalent(self, other: GenericUnitDescriptor) -> bool: + """ + Returns True if this generic is equivalent to the given one, False otherwise. + + A generic can be equivalent with another generic if the latter or the former + is an alias. + + Examples: + >>> class LengthUnit(MeasurementUnit): ... + >>> class MassUnit(MeasurementUnit): ... + >>> class TimeUnit(MeasurementUnit): ... + >>> class ForceUnit(AliasMeasurementUnit): + ... @classmethod + ... def aliased_generic_descriptor(cls): + ... return MassUnit * LengthUnit / (TimeUnit**2) + + >>> ForceUnit.is_equivalent(MassUnit * LengthUnit / (TimeUnit**2)) + True + + >>> class EnergyUnit(AliasMeasurementUnit): + ... @classmethod + ... def aliased_generic_descriptor(cls): + ... return ForceUnit * LengthUnit + + >>> EnergyUnit.is_equivalent(MassUnit * (LengthUnit**2) / (TimeUnit**2)) + True + """ + if isinstance(other, MeasurementUnitType): + if ( + self.denominator == [] + and len(self.numerator) == 1 + and self.numerator[0].is_equivalent(other) + ): + return True + + if issubclass(other, AliasMeasurementUnit): + return other.aliased_generic_descriptor().is_equivalent(self) # type: ignore[attr-defined] + + elif isinstance(other, GenericDimension): + if ( + self.denominator == [] + and len(self.numerator) == 1 + and self.numerator[0].is_equivalent(other) + ): + return True + + if issubclass(other.unit_type, AliasMeasurementUnit): + return ( + other.unit_type.aliased_generic_descriptor() ** other.power + ).is_equivalent(self) + + elif isinstance(other, GenericCompositeDimension): + _generic = other.analysed().simplified() + _self = self.analysed().simplified() + + return Counter(_self.numerator) == Counter(_generic.numerator) and ( + Counter(_self.denominator) == Counter(_generic.denominator) + ) + + return False + def _numerator_copy(self) -> List[GenericDimension]: return [replace(n) for n in self.numerator] From 86b7ad4f1e9e26349d347fcbe14e457f22fc2711 Mon Sep 17 00:00:00 2001 From: Maximos Nikiforakis Date: Sat, 6 Apr 2024 09:13:13 +0300 Subject: [PATCH 6/9] Implement isinstance_equivalent method of unit descriptors --- .../tests/units/test_descriptors.py | 264 +++++++++++++++++- src/property_utils/units/descriptors.py | 139 +++++---- src/property_utils/units/units.py | 6 + 3 files changed, 350 insertions(+), 59 deletions(-) diff --git a/src/property_utils/tests/units/test_descriptors.py b/src/property_utils/tests/units/test_descriptors.py index 057cacd..4f6b7bb 100644 --- a/src/property_utils/tests/units/test_descriptors.py +++ b/src/property_utils/tests/units/test_descriptors.py @@ -34,6 +34,8 @@ dimension_3, dimension_4, dimension_5, + dimension_6, + dimension_7, generic_dimension_1, generic_dimension_2, generic_dimension_3, @@ -297,6 +299,47 @@ def test_with_generic_composite_dimension(self): self.assertResultFalse() +@add_to(MeasurementUnit_test_suite) +class TestMeasurementUnitIsInstanceEquivalent(TestDescriptor): + + def subject(self, descriptor): + return Unit3.C.isinstance_equivalent(descriptor) + + @args({"descriptor": generic_dimension_1(3)}) + def test_with_aliased_unit(self): + self.assertResultTrue() + + @args({"descriptor": Unit3}) + def test_with_measurement_unit_type(self): + self.assertResultTrue() + + @args({"descriptor": generic_dimension_3()}) + def test_with_generic_dimension(self): + self.assertResultTrue() + + @args({"descriptor": generic_dimension_3(2)}) + def test_with_exponentiated_generic_dimension(self): + self.assertResultFalse() + + @args({"descriptor": GenericCompositeDimension([generic_dimension_3()])}) + def test_with_generic_composite_dimension(self): + self.assertResultTrue() + + @args({"descriptor": GenericCompositeDimension([generic_dimension_1(3)])}) + def test_with_alias_generic_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "descriptor": GenericCompositeDimension( + [generic_dimension_3()], [generic_dimension_1()] + ) + } + ) + def test_with_generic_composite_dimension_same_numerator(self): + self.assertResultFalse() + + @add_to(MeasurementUnit_test_suite) class TestMeasurementUnitToGeneric(TestDescriptor): def test_to_generic(self): @@ -504,7 +547,7 @@ def test_with_alias_measurement_unit_type(self): } ) def test_with_aliased_measurement_unit_type(self): - self.assertResultTrue() + self.assertResultFalse() @args({"generic": generic_dimension_1()}) def test_with_generic_dimension(self): @@ -1094,7 +1137,7 @@ def test_with_measurement_unit_type(self): } ) def test_with_aliased_composite_dimension(self): - self.assertResultTrue() + self.assertResultFalse() @args( { @@ -1128,7 +1171,7 @@ def test_with_measurement_unit_type(self): } ) def test_with_aliased_composite_dimension(self): - self.assertResultTrue() + self.assertResultFalse() @args( { @@ -1152,11 +1195,11 @@ def test_with_dimension(self): @args({"generic": generic_dimension_6()}) def test_with_alias_dimension(self): - self.assertResultTrue() + self.assertResultFalse() @args({"generic": Unit6}) def test_with_alias_measurement_unit_type(self): - self.assertResultTrue() + self.assertResultFalse() @add_to(Dimension_test_suite) @@ -1170,6 +1213,113 @@ def test_with_dimension(self): @args({"generic": generic_dimension_6(2)}) def test_with_alias_dimension(self): + self.assertResultFalse() + + +@add_to(Dimension_test_suite) +class TestDimensionIsInstanceEquivalent(TestDescriptor): + def subject(self, generic): + return dimension_1().isinstance_equivalent(generic) + + @args({"generic": Unit1.A}) + def test_with_measurement_unit(self): + self.assertResultFalse() + + @args({"generic": dimension_1()}) + def test_with_dimension(self): + self.assertResultFalse() + + @args({"generic": composite_dimension()}) + def test_with_composite_dimension(self): + self.assertResultFalse() + + @args({"generic": Unit1}) + def test_with_measurement_unit_type(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_1()}) + def test_with_generic_dimension(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_1(2)}) + def test_with_same_exponentiated_generic_dimension(self): + self.assertResultFalse() + + @args({"generic": GenericCompositeDimension([generic_dimension_1()])}) + def test_with_numerator_generic_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1()], [generic_dimension_2()] + ) + } + ) + def test_with_generic_composite_dimension_same_numerator(self): + self.assertResultFalse() + + +@add_to(Dimension_test_suite) +class TestExponentiatedDimensionIsInstanceEquivalent(TestDescriptor): + def subject(self, generic): + return dimension_1(3).isinstance_equivalent(generic) + + @args({"generic": generic_dimension_1(3)}) + def test_with_same_generic(self): + return self.assertResultTrue() + + @args({"generic": Unit3}) + def test_with_alias_measurement_unit_type(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_3()}) + def test_with_alias_generic_dimension(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_3(2)}) + def test_with_exponentiated_alias_dimension(self): + self.assertResultFalse() + + +@add_to(Dimension_test_suite) +class TestAliasDimensionIsInstanceEquivalent(TestDescriptor): + def subject(self, generic, power=1): + return dimension_3(power).isinstance_equivalent(generic) + + @args({"generic": generic_dimension_1(3)}) + def test_with_aliased_generic_dimension(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_6(3), "power": 2}) + def test_with_other_alias_generic_dimension(self): + self.assertResultTrue() + + +@add_to(Dimension_test_suite) +class TestComplexAliasDimensionIsInstanceEquivalent(TestDescriptor): + def subject(self, generic, power=1): + return dimension_5(power).isinstance_equivalent(generic) + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1()], [generic_dimension_4(2)] + ) + } + ) + def test_with_aliased_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1(2)], [generic_dimension_4(4)] + ), + "power": 2, + } + ) + def test_with_exponentiated_aliased_composite_dimension(self): self.assertResultTrue() @@ -1760,6 +1910,7 @@ def build_descriptor(self): def test_denominator_dimension(self): self.assertResultFalse() + @add_to(GenericCompositeDimension_test_suite) class TestAliasedGenericCompositeDimensionIsEquivalent(TestDescriptor): def subject(self, generic): @@ -1800,7 +1951,6 @@ def test_with_generic_dimension(self): self.assertResultTrue() - @add_to(GenericCompositeDimension_test_suite) class TestComplexAliasGenericCompositeDimensionIsEquivalent(TestDescriptor): def subject(self, generic): @@ -1857,8 +2007,6 @@ def test_with_fully_aliased_composite_dimension(self): self.assertResultTrue() - - @add_to(CompositeDimension_test_suite) class TestCompositeDimensionFromDescriptor(TestDescriptor): produced_type = CompositeDimension @@ -1997,7 +2145,7 @@ def test_with_generic_composite_dimension(self): @args({"generic": generic_dimension_5()}) def test_with_alias_dimension(self): - self.assertResultTrue() + self.assertResultFalse() @add_to(CompositeDimension_test_suite) @@ -2010,11 +2158,11 @@ def build_descriptor(self): @args({"generic": Unit7}) def test_with_alias_unit(self): - self.assertResultTrue() + self.assertResultFalse() @args({"generic": generic_dimension_7()}) def test_with_alias_dimension(self): - self.assertResultTrue() + self.assertResultFalse() @args( { @@ -2058,7 +2206,7 @@ def test_with_generic_composite_dimension(self): } ) def test_with_aliased_generic_composite_dimension(self): - self.assertResultTrue() + self.assertResultFalse() @add_to(CompositeDimension_test_suite) @@ -2088,6 +2236,98 @@ def test_with_generic_composite_dimension(self): } ) def test_with_aliased_generic_composite_dimension(self): + self.assertResultFalse() + + +@add_to(CompositeDimension_test_suite) +class TestAliasedCompositeDimensionIsInstanceEquivalent(TestDescriptor): + def subject(self, generic): + return self.build_descriptor().isinstance_equivalent(generic) + + def build_descriptor(self): + return CompositeDimension([dimension_1()], [dimension_4(2)]) + + @args({"generic": Unit5}) + def test_with_alias_measurement_unit_type(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_5()}) + def test_with_alias_generic_dimension(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_5(2)}) + def test_with_other_generic_dimension(self): + self.assertResultFalse() + + +@add_to(CompositeDimension_test_suite) +class TestSingleNumeratorCompositeDimensionIsInstanceEquivalent(TestDescriptor): + def subject(self, generic): + return self.build_descriptor().isinstance_equivalent(generic) + + def build_descriptor(self): + return CompositeDimension([dimension_3()]) + + @args({"generic": Unit3}) + def test_with_measurement_unit_type(self): + self.assertResultTrue() + + @args({"generic": generic_dimension_3()}) + def test_with_generic_dimension(self): + self.assertResultTrue() + + +@add_to(CompositeDimension_test_suite) +class TestComplexAliasCompositeDimensionIsInstanceEquivalent(TestDescriptor): + def subject(self, generic): + return self.build_descriptor().isinstance_equivalent(generic) + + def build_descriptor(self): + """ + G / F + """ + return CompositeDimension([dimension_7()], [dimension_6()]) + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_5()], [generic_dimension_6(), generic_dimension_2()] + ) + } + ) # Unit5 / Unit6 / Unit2 + def test_with_other_alias_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_1()], + [generic_dimension_4(2), generic_dimension_2(), generic_dimension_6()], + ) + } + ) # Unit1/ Unit4^2 / Unit2 / Unit6 + def test_with_aliased_numerator_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [generic_dimension_7()], [generic_dimension_1(2)] + ) + } + ) # Unit7 / Unit1^2 + def test_with_aliased_denominator_composite_dimension(self): + self.assertResultTrue() + + @args( + { + "generic": GenericCompositeDimension( + [], + [generic_dimension_4(2), generic_dimension_2(), generic_dimension_1()], + ) + } + ) # 1 / Unit4^2 / Unit2 / Unit1 + def test_with_fully_aliased_composite_dimension(self): self.assertResultTrue() diff --git a/src/property_utils/units/descriptors.py b/src/property_utils/units/descriptors.py index ed17306..05ea925 100644 --- a/src/property_utils/units/descriptors.py +++ b/src/property_utils/units/descriptors.py @@ -83,6 +83,22 @@ def isinstance(self, generic: GenericUnitDescriptor) -> bool: """ Returns True if the UnitDescriptor is an instance of the generic, False otherwise. + + A unit descriptor is an instance of a generic if the generic of the unit + descriptor is equal to the generic. + + Equality between generics is checked with the `==` operator. + """ + + def isinstance_equivalent(self, generic: GenericUnitDescriptor) -> bool: + """ + Returns True if the UnitDescriptor is an instance-equivalent of the generic, + False otherwise. + + A unit descriptor is an instance-equivalent of a generic if the generic of the + unit descriptor is equivalent to the generic. + + Equivalence between generics is checked with the `is_equivalent` method. """ def to_generic(self) -> GenericUnitDescriptor: @@ -342,6 +358,30 @@ def isinstance(self, generic: GenericUnitDescriptor) -> bool: """ return type(self) == generic # pylint: disable=unidiomatic-typecheck + def isinstance_equivalent(self, generic: GenericUnitDescriptor) -> bool: + """ + Returns True if the UnitDescriptor is an instance-equivalent of the generic, + False otherwise. + + A unit descriptor is an instance-equivalent of a generic if the generic of the + unit descriptor is equivalent to the generic. + + Equivalence between generics is checked with the `is_equivalent` method. + + Examples: + >>> class LengthUnit(MeasurementUnit): ... + >>> class AreaUnit(AliasMeasurementUnit): + ... HECTARE = "ha" + ... @classmethod + ... def aliased_generic_descriptor(cls): return LengthUnit**2 + + >>> AreaUnit.HECTARE.isinstance_equivalent(AreaUnit) + True + >>> AreaUnit.HECTARE.isinstance_equivalent(LengthUnit**2) + True + """ + return self.to_generic().is_equivalent(generic) + def to_generic(self) -> GenericUnitDescriptor: """ Create a generic descriptor from this MeasurementUnit. @@ -480,21 +520,6 @@ def from_descriptor(descriptor: UnitDescriptor) -> MeasurementUnit: f"cannot create AliasMeasurementUnit from descriptor {descriptor}" ) - def isinstance(self, generic: GenericUnitDescriptor) -> bool: - """ - Returns True if the AliasMeasurementUnit is an instance of the generic or if - its generic descriptor equals the generic, False otherwise. - - Examples: - >>> class PowerUnit(AliasMeasurementUnit): - ... NEWTON = "N" - ... def aliased_generic_descriptor(cls) -> GenericCompositeDimension: - ... return EnergyUnit - """ - return super().isinstance(generic) or ( - self.aliased_generic_descriptor() == generic - ) - @classmethod def aliased_generic_descriptor(cls) -> GenericUnitDescriptor: """ @@ -787,9 +812,6 @@ def isinstance(self, generic: GenericUnitDescriptor) -> bool: >>> Dimension(TemperatureUnit.CELCIUS).isinstance(TemperatureUnit**2) False """ - if self._isinstance_aliased(generic) or self._isinstance_alias(generic): - return True - if isinstance(generic, MeasurementUnitType): generic = GenericDimension(generic) if not isinstance(generic, GenericDimension): @@ -800,6 +822,28 @@ def isinstance(self, generic: GenericUnitDescriptor) -> bool: return False + def isinstance_equivalent(self, generic: GenericUnitDescriptor) -> bool: + """ + Returns True if the UnitDescriptor is an instance-equivalent of the generic, + False otherwise. + + A unit descriptor is an instance-equivalent of a generic if the generic of the + unit descriptor is equivalent to the generic. + + Equivalence between generics is checked with the `is_equivalent` method. + + Examples: + >>> class LengthUnit(MeasurementUnit): + ... METER = "m" + >>> class VolumeUnit(AliasMeasurementUnit): + ... @classmethod + ... def aliased_generic_descriptor(cls): return LengthUnit**3 + + >>> (LengthUnit.METER**3).isinstance_equivalent(VolumeUnit) + True + """ + return self.to_generic().is_equivalent(generic) + def to_generic(self) -> GenericDimension: """ Create a generic descriptor from this Dimension. @@ -1421,17 +1465,39 @@ def isinstance(self, generic: GenericUnitDescriptor) -> bool: >>> (TemperatureUnit.CELCIUS * LengthUnit.METER).isinstance(TemperatureUnit**2) False """ - if self._isinstance_alias(generic): - return True - if not isinstance(generic, GenericCompositeDimension): return False - return ( - self.to_generic() == generic - or self.to_generic().analysed().simplified() - == generic.analysed().simplified() - ) + return self.to_generic() == generic + + def isinstance_equivalent(self, generic: GenericUnitDescriptor) -> bool: + """ + Returns True if the UnitDescriptor is an instance-equivalent of the generic, + False otherwise. + + A unit descriptor is an instance-equivalent of a generic if the generic of the + unit descriptor is equivalent to the generic. + + Equivalence between generics is checked with the `is_equivalent` method. + + Examples: + >>> class MassUnit(MeasurementUnit): + ... KILO_GRAM = "kg" + >>> class LengthUnit(MeasurementUnit): + ... METER = "m" + >>> class TimeUnit(MeasurementUnit): + ... SECOND = "s" + + >>> class ForceUnit(AliasMeasurementUnit): + ... NEWTON = "N" + ... @classmethod + ... def aliased_generic_descriptor(cls): + ... return MassUnit * LengthUnit / (TimeUnit**2) + + >>> (MassUnit.KILO_GRAM * LengthUnit.METER / (TimeUnit.SECOND**2)).isinstance_equivalent(ForceUnit) + True + """ + return self.to_generic().is_equivalent(generic) def to_generic(self) -> GenericCompositeDimension: """ @@ -1605,27 +1671,6 @@ def inverse(self) -> "CompositeDimension": """ return CompositeDimension(self._denominator_copy(), self._numerator_copy()) - def _isinstance_alias(self, generic: GenericUnitDescriptor) -> bool: - """ - Returns True if the generic is an alias for this CompositeDimension's generic - descriptor, False otherwise. - - Only applicable if generic is an AliasMeasurementUnit. - """ - if isinstance(generic, MeasurementUnitType): - generic = GenericDimension(generic) - - if not isinstance(generic, GenericDimension): - return False - - if not issubclass(generic.unit_type, AliasMeasurementUnit): - return False - - return ( - self.to_generic().analysed().simplified() - == generic.unit_type.aliased_generic_descriptor() ** generic.power - ) - def _numerator_copy(self) -> List[Dimension]: return [replace(n) for n in self.numerator] diff --git a/src/property_utils/units/units.py b/src/property_utils/units/units.py index 470827b..2c46a86 100644 --- a/src/property_utils/units/units.py +++ b/src/property_utils/units/units.py @@ -46,6 +46,9 @@ def si(cls) -> "AbsoluteTemperatureUnit": def isinstance(self, generic: GenericUnitDescriptor) -> bool: return super().isinstance(generic) or generic == AbsoluteTemperatureUnit + def isinstance_equivalent(self, generic: GenericUnitDescriptor) -> bool: + return super().isinstance_equivalent(generic) or self.isinstance(generic) + class AbsoluteTemperatureUnit(MeasurementUnit): KELVIN = "K" @@ -58,6 +61,9 @@ def si(cls) -> "AbsoluteTemperatureUnit": def isinstance(self, generic: GenericUnitDescriptor) -> bool: return super().isinstance(generic) or generic == RelativeTemperatureUnit + def isinstance_equivalent(self, generic: GenericUnitDescriptor) -> bool: + return super().isinstance_equivalent(generic) or self.isinstance(generic) + class LengthUnit(MeasurementUnit): MILLI_METER = "mm" From e37247c56dd8c6892325b7b204d77b399077513e Mon Sep 17 00:00:00 2001 From: Maximos Nikiforakis Date: Mon, 1 Apr 2024 20:24:26 +0300 Subject: [PATCH 7/9] Support converting from alias to aliased units --- .../tests/units/test_converter_types.py | 59 +++++++++ .../tests/units/test_converters.py | 112 +++++++++++++++++- src/property_utils/units/converter_types.py | 65 ++++++++++ 3 files changed, 235 insertions(+), 1 deletion(-) diff --git a/src/property_utils/tests/units/test_converter_types.py b/src/property_utils/tests/units/test_converter_types.py index e3f00a7..692b33c 100644 --- a/src/property_utils/tests/units/test_converter_types.py +++ b/src/property_utils/tests/units/test_converter_types.py @@ -1,3 +1,4 @@ +from typing import Any from unittest import TestSuite, TextTestRunner from unittest_extensions import TestCase, args @@ -24,6 +25,7 @@ Unit2, Unit3, Unit4, + Unit5, Unit1Converter, UnregisteredConverter, Unit1_314Converter, @@ -35,6 +37,7 @@ Unit1Unit2Converter, Unit1Unit4FractionConverter, Unit1_2Unit4_3Converter, + Unit5Converter, ) @@ -158,6 +161,62 @@ def test_valid_conversion_from_a_to_A(self): self.assertResult(20) +@add_to(AbsoluteUnitConverter_test_suite) +class TestAbsoluteUnitConverterAliasMeasurementUnitConvert(TestCase): + def subject(self, value, from_descriptor, to_descriptor) -> Any: + return Unit5Converter.convert(value, from_descriptor, to_descriptor) + + @args( + { + "value": 5, + "from_descriptor": Unit5.e, + "to_descriptor": Unit1.a / (Unit4.d**2), + } + ) + def test_with_si_aliased_units(self): + self.assertResult(5) + + @args( + { + "value": 5, + "from_descriptor": Unit5.E, + "to_descriptor": Unit1.A / (Unit4.D**2), + } + ) + def test_with_aliased_unit(self): + self.assertResultAlmost(5 * 15 * 25 / 10, places=1) + + @args({"value": 10, "from_descriptor": Unit5.E, "to_descriptor": Unit5.e}) + def test_with_normal_units(self): + self.assertResult(150) + + @args({"value": 10, "from_descriptor": Unit5.E, "to_descriptor": Unit5.E}) + def test_with_same_units(self): + self.assertResult(10) + + +@add_to(AbsoluteUnitConverter_test_suite) +class TestAbsoluteUnitConverterAliasMeasurementUnitGetFactor(TestCase): + def subject(self, from_descriptor, to_descriptor) -> Any: + return Unit5Converter.get_factor(from_descriptor, to_descriptor) + + @args({"from_descriptor": Unit5.e, "to_descriptor": Unit1.a / (Unit4.d**2)}) + def test_with_si_aliased_units(self): + self.assertResult(1) + + @args({"from_descriptor": Unit5.E, "to_descriptor": Unit1.A / (Unit4.D**2)}) + def test_with_aliased_unit(self): + self.assertResultAlmost(15 * 25 / 10, places=1) + + @args({"from_descriptor": Unit5.E, "to_descriptor": Unit5.e}) + def test_with_normal_units(self): + self.assertResult(15) + + @args({"from_descriptor": Unit5.E, "to_descriptor": Unit5.E}) + def test_with_same_units(self): + self.assertResult(1) + + @add_to(AbsoluteUnitConverter_test_suite) class TestAbsoluteUnitConverterGetFactor(TestCase): def subject(self, from_descriptor, to_descriptor): diff --git a/src/property_utils/tests/units/test_converters.py b/src/property_utils/tests/units/test_converters.py index c0a75a0..008f8ac 100644 --- a/src/property_utils/tests/units/test_converters.py +++ b/src/property_utils/tests/units/test_converters.py @@ -464,6 +464,28 @@ def test_to_newton(self): def test_to_dyne(self): self.assertResult(2_800_000) + @args( + { + "value": 20, + "to_descriptor": MassUnit.KILO_GRAM + * LengthUnit.METER + / (TimeUnit.SECOND**2), + } + ) + def test_to_aliased_si_units(self): + self.assertResult(20) + + @args( + { + "value": 200, + "to_descriptor": MassUnit.KILO_GRAM + * LengthUnit.KILO_METER + / (TimeUnit.SECOND**2), + } + ) + def test_to_aliased_units(self): + self.assertResult(0.2) + @add_to(AliasPressureUnitConverter_test_suite) class TestAliasPressureUnitConverterConvertToReference(TestCase): @@ -514,7 +536,7 @@ def test_to_psi(self): @args({"value": 3.56, "to_descriptor": PressureUnit.PASCAL}) def test_to_pascal(self): - self.assertResult(356000) + self.assertResult(356_000) @args({"value": 0.55, "to_descriptor": PressureUnit.KILO_PASCAL}) def test_to_kilo_pascal(self): @@ -524,6 +546,34 @@ def test_to_kilo_pascal(self): def test_to_mega_pascal(self): self.assertResult(0.27) + @args( + { + "value": 1.5, + "to_descriptor": MassUnit.KILO_GRAM + / LengthUnit.METER + / (TimeUnit.SECOND**2), + } + ) + def test_to_aliased_si_units(self): + self.assertResult(150_000) + + @args( + { + "value": 1.5, + "to_descriptor": MassUnit.GRAM / LengthUnit.METER / (TimeUnit.SECOND**2), + } + ) + def test_to_aliased_units(self): + self.assertResult(150_000_000) + + @args({"value": 10, "to_descriptor": ForceUnit.NEWTON / LengthUnit.METER**2}) + def test_to_composite_si_units(self): + self.assertResult(1_000_000) + + @args({"value": 10, "to_descriptor": ForceUnit.DYNE / LengthUnit.METER**2}) + def test_to_composite_units(self): + self.assertResult(100_000_000_000) + @add_to(AliasEnergyUnitConverter_test_suite) class TestAliasEnergyUnitConverterConvertToReference(TestCase): @@ -618,6 +668,36 @@ def test_to_watthour(self): def test_to_kilo_watthour(self): self.assertResultAlmost(100_000 / 3600 / 1000) + @args( + { + "value": 20.1, + "to_descriptor": MassUnit.KILO_GRAM + * (LengthUnit.METER**2) + / (TimeUnit.SECOND**2), + } + ) + def test_to_aliased_si_units(self): + self.assertResult(20.1) + + @args( + { + "value": 7, + "to_descriptor": MassUnit.GRAM + * (LengthUnit.METER**2) + / (TimeUnit.SECOND**2), + } + ) + def test_to_aliased_units(self): + self.assertResult(7_000) + + @args({"value": 8, "to_descriptor": ForceUnit.NEWTON * LengthUnit.METER}) + def test_to_composite_si_units(self): + self.assertResult(8) + + @args({"value": 2, "to_descriptor": ForceUnit.NEWTON * LengthUnit.CENTI_METER}) + def test_to_composite_units(self): + self.assertResult(200) + @add_to(AliasPowerUnitConverter_test_suite) class TestAliasPowerUnitConverterConvertToReference(TestCase): @@ -662,6 +742,36 @@ def test_to_mega_watt(self): def test_to_giga_watt(self): self.assertResult(5e-9) + @args( + { + "value": 9, + "to_descriptor": MassUnit.KILO_GRAM + * (LengthUnit.METER**2) + / (TimeUnit.SECOND**3), + } + ) + def test_to_aliased_si_units(self): + self.assertResult(9) + + @args( + { + "value": 9, + "to_descriptor": MassUnit.GRAM + * (LengthUnit.METER**2) + / (TimeUnit.SECOND**3), + } + ) + def test_to_aliased_units(self): + self.assertResult(9_000) + + @args({"value": 6, "to_descriptor": EnergyUnit.JOULE / TimeUnit.SECOND}) + def test_to_composite_si_units(self): + self.assertResult(6) + + @args({"value": 9, "to_descriptor": EnergyUnit.WATTHOUR / TimeUnit.HOUR}) + def test_to_composite_units(self): + self.assertResult(9) + if __name__ == "__main__": runner = TextTestRunner() diff --git a/src/property_utils/units/converter_types.py b/src/property_utils/units/converter_types.py index 9f5ecc0..8cebe58 100644 --- a/src/property_utils/units/converter_types.py +++ b/src/property_utils/units/converter_types.py @@ -19,6 +19,7 @@ from property_utils.units.descriptors import ( MeasurementUnit, + AliasMeasurementUnit, MeasurementUnitType, GenericDimension, GenericCompositeDimension, @@ -216,6 +217,12 @@ def get_factor( f"invalid 'to_descriptor'; expected an instance of {cls.generic_unit_descriptor}. " ) from_unit = MeasurementUnit.from_descriptor(from_descriptor) + + if isinstance(from_unit, AliasMeasurementUnit) and not isinstance( + to_descriptor, AliasMeasurementUnit + ): + return cls._get_aliased_factor(from_unit, to_descriptor) + to_unit = MeasurementUnit.from_descriptor(to_descriptor) try: return cls._to_reference(from_unit) * cls.conversion_map[to_unit] @@ -233,6 +240,35 @@ def _to_reference(cls, from_unit: MeasurementUnit) -> float: f"cannot convert from {from_unit}; unit is not registered in {cls.__name__}'s conversion map. ", ) from None + @classmethod + def _get_aliased_factor( + cls, from_unit: AliasMeasurementUnit, to_descriptor: UnitDescriptor + ) -> float: + """ + Returns the conversion factor from an alias unit to its aliased. + + The conversion happens in four steps: + + 1. Convert from the alias unit to the SI unit. + 2. Convert from the SI unit to the aliased SI units (this step is not + implemented in code, because the conversion factor is 1) + 3. Convert from the SI units to the target units. + + e.g. if you want to convert from bar to kN/m^2: + 1. bar -> Pa + 2. Pa -> N/m^2 (conversion factor 1) + 3. N/m^2 -> kN/m^2 + """ + step_1_factor = cls.get_factor(from_unit, from_unit.si()) + + converter = get_converter(to_descriptor.to_generic()) + + step_3_factor = converter.convert( + 1, to_descriptor.to_generic().to_si(), to_descriptor + ) + + return step_1_factor * step_3_factor + class RelativeUnitConverter( metaclass=ABCMeta @@ -475,6 +511,35 @@ def get_factor( factor = converter.get_factor(from_dimension.unit, to_dimension.unit) return factor**to_dimension.power + @classmethod + def _get_aliased_factor( + cls, from_dimension: Dimension, to_descriptor: AliasMeasurementUnit + ) -> float: + """ + Returns the conversion factor from an alias unit to its aliased. + + The conversion happens in three steps: + + 1. Convert from the alias unit to the SI unit. + 2. Convert from the SI unit to the aliased SI units (this step is not + implemented in code, because the conversion factor is 1) + 3. Convert from the aliased SI units to the target units. + + e.g. if you want to convert from cm^3 to L: + 1. cm^3 -> m^3 + 2. m^3 -> kL (conversion factor 1) + 3. kL -> L + """ + step_1_factor = cls.get_factor(from_dimension, from_dimension.si()) + + converter = get_converter(to_descriptor.to_generic()) + + step_3_factor = converter.convert( + 1, to_descriptor.to_generic().to_si(), to_descriptor + ) + + return step_1_factor * step_3_factor + class CompositeUnitConverter(metaclass=ABCMeta): """ From 3f5cbc675c93877e992c9af6ddc6005e642d5668 Mon Sep 17 00:00:00 2001 From: Maximos Nikiforakis Date: Tue, 2 Apr 2024 10:08:13 +0300 Subject: [PATCH 8/9] Support converting from aliased to alias units --- .../tests/units/test_converter_types.py | 140 ++++++++++++++++++ src/property_utils/units/converter_types.py | 113 ++++++++++++-- 2 files changed, 241 insertions(+), 12 deletions(-) diff --git a/src/property_utils/tests/units/test_converter_types.py b/src/property_utils/tests/units/test_converter_types.py index 692b33c..df19499 100644 --- a/src/property_utils/tests/units/test_converter_types.py +++ b/src/property_utils/tests/units/test_converter_types.py @@ -26,8 +26,12 @@ Unit3, Unit4, Unit5, + Unit6, + Unit7, + Unit8, Unit1Converter, UnregisteredConverter, + Unit1_2Converter, Unit1_314Converter, Unit2_4Converter, Unit3_2Converter, @@ -38,6 +42,8 @@ Unit1Unit4FractionConverter, Unit1_2Unit4_3Converter, Unit5Converter, + Unit1Unit4_2Converter, + Unit6Unit4_2Converter, ) @@ -377,6 +383,28 @@ def test_from_B_to_b(self): self.assertResultRaises(UnsupportedConverterError) +@add_to(ExponentiatedUnitConverter_test_suite) +class TestAliasExponentiatedUnitConverterConvert(TestCase): + def subject(self, value, from_descriptor, to_descriptor): + return Unit1_2Converter.convert(value, from_descriptor, to_descriptor) + + @args({"value": 2, "from_descriptor": Unit1.A**2, "to_descriptor": Unit1.A**2}) + def test_with_same_unit(self): + self.assertResult(2) + + @args({"value": 5, "from_descriptor": Unit1.A**2, "to_descriptor": Unit1.a**2}) + def test_with_same_unit_type(self): + self.assertResult(500) + + @args({"value": 5, "from_descriptor": Unit1.A**2, "to_descriptor": Unit6.F}) + def test_with_alias_units(self): + self.assertResult(250) + + @args({"value": 10, "from_descriptor": Unit1.a**2, "to_descriptor": Unit6.f}) + def test_with_alias_si_units(self): + self.assertResult(10) + + @add_to(ExponentiatedUnitConverter_test_suite) class TestExponentiatedUnitConverterGetFactor(TestCase): def subject(self, from_descriptor, to_descriptor): @@ -685,6 +713,118 @@ def test_valid_conversion_from_A2D3_to_A2d3(self): self.assertResult(10 / 125) +@add_to(CompositeUnitConverter_test_suite) +class TestAliasCompositeUnitConverterConvert(TestCase): + def subject(self, value, from_descriptor, to_descriptor): + return Unit1Unit4_2Converter.convert(value, from_descriptor, to_descriptor) + + @args( + { + "value": 2, + "from_descriptor": Unit1.A / (Unit4.D**2), + "to_descriptor": Unit1.A / (Unit4.D**2), + } + ) + def test_with_same_units(self): + self.assertResult(2) + + @args( + { + "value": 2, + "from_descriptor": Unit1.A / (Unit4.D**2), + "to_descriptor": Unit1.a / (Unit4.d**2), + } + ) + def test_with_si_units(self): + self.assertResult(0.8) + + @args( + { + "value": 3, + "from_descriptor": Unit1.A / (Unit4.D**2), + "to_descriptor": Unit5.E, + } + ) + def test_with_alias_unit(self): + self.assertResult(3 * 10 / 15 / 25) + + @args( + { + "value": 7, + "from_descriptor": Unit1.a / (Unit4.d**2), + "to_descriptor": Unit5.e, + } + ) + def test_with_alias_si_unit(self): + self.assertResult(7) + + +@add_to(CompositeUnitConverter_test_suite) +class TestTwiceAliasCompositeUnitConverterConvert(TestCase): + def subject(self, value, from_descriptor, to_descriptor): + return Unit6Unit4_2Converter.convert(value, from_descriptor, to_descriptor) + + @args( + { + "value": 2, + "from_descriptor": Unit6.F / (Unit4.D**2), + "to_descriptor": Unit6.F / (Unit4.D**2), + } + ) + def test_with_same_units(self): + self.assertResult(2) + + @args( + { + "value": 2, + "from_descriptor": Unit6.F / (Unit4.D**2), + "to_descriptor": Unit6.f / (Unit4.d**2), + } + ) + def test_with_si_units(self): + self.assertResult(2 * 2 / 25) + + @args( + { + "value": 3, + "from_descriptor": Unit6.F / (Unit4.D**2), + "to_descriptor": Unit8.H, + } + ) + def test_with_alias_unit(self): + self.assertResult(0.06) + + @args( + { + "value": 7, + "from_descriptor": Unit6.f / (Unit4.d**2), + "to_descriptor": Unit8.h, + } + ) + def test_with_alias_si_unit(self): + self.assertResult(7) + + @args( + { + "value": 3, + "from_descriptor": Unit6.F / (Unit4.D**2), + "to_descriptor": Unit1.A**2 / (Unit4.D**2), + } + ) + def test_with_aliased_units(self): + self.assertResult(0.06) + + @args( + { + "value": 3, + "from_descriptor": Unit6.f / (Unit4.d**2), + "to_descriptor": Unit1.a**2 / (Unit4.d**2), + } + ) + def test_with_aliased_si_units(self): + self.assertResult(3) + + @add_to(CompositeUnitConverter_test_suite) class TestCompositeUnitConverterGetFactor(TestCase): def subject(self, from_descriptor, to_descriptor): diff --git a/src/property_utils/units/converter_types.py b/src/property_utils/units/converter_types.py index 8cebe58..0e2d7b6 100644 --- a/src/property_utils/units/converter_types.py +++ b/src/property_utils/units/converter_types.py @@ -208,13 +208,13 @@ def get_factor( >>> assert LengthUnitConverter.get_factor(LengthUnit.CENTI_METER, LengthUnit.INCH) == 1/2.54 """ - if not from_descriptor.isinstance(cls.generic_unit_descriptor): + if not from_descriptor.isinstance_equivalent(cls.generic_unit_descriptor): raise UnitConversionError( - f"invalid 'from_descriptor; expected an instance of {cls.generic_unit_descriptor}. " + f"invalid 'from_descriptor; expected an instance-equivalent of {cls.generic_unit_descriptor}. " ) - if not to_descriptor.isinstance(cls.generic_unit_descriptor): + if not to_descriptor.isinstance_equivalent(cls.generic_unit_descriptor): raise UnitConversionError( - f"invalid 'to_descriptor'; expected an instance of {cls.generic_unit_descriptor}. " + f"invalid 'to_descriptor'; expected an instance-equivalent of {cls.generic_unit_descriptor}. " ) from_unit = MeasurementUnit.from_descriptor(from_descriptor) @@ -483,16 +483,22 @@ def get_factor( >>> assert AreaUnitConverter.get_factor(LengthUnit.INCH**2, LengthUnit.CENTI_METER**2) == 6.4516 """ - if not from_descriptor.isinstance(cls.generic_unit_descriptor): + if not from_descriptor.isinstance_equivalent(cls.generic_unit_descriptor): raise UnitConversionError( - f"invalid 'from_descriptor; expected an instance of {cls.generic_unit_descriptor}. " + f"invalid 'from_descriptor; expected an instance-equivalent of {cls.generic_unit_descriptor}. " ) - if not to_descriptor.isinstance(cls.generic_unit_descriptor): + if not to_descriptor.isinstance_equivalent(cls.generic_unit_descriptor): raise UnitConversionError( - f"invalid 'to_descriptor'; expected an instance of {cls.generic_unit_descriptor}. " + f"invalid 'to_descriptor'; expected an instance-equivalent of {cls.generic_unit_descriptor}. " ) from_dimension = Dimension.from_descriptor(from_descriptor) + + if not to_descriptor.isinstance(from_descriptor.to_generic()): + if isinstance(to_descriptor, AliasMeasurementUnit): + return cls._get_aliased_factor(from_dimension, to_descriptor) + to_dimension = Dimension.from_descriptor(to_descriptor) + try: converter = get_converter(cls.generic_unit_descriptor.unit_type) except UndefinedConverterError: @@ -657,15 +663,24 @@ def get_factor( >>> assert VelocityUnitConverter.get_factor(LengthUnit.INCH/TimeUnit.MINUTE, LengthUnit.CENTI_METER/TimeUnit.SECOND) == 2.54/60 """ - if not from_descriptor.isinstance(cls.generic_unit_descriptor): + if not from_descriptor.isinstance_equivalent(cls.generic_unit_descriptor): raise UnitConversionError( - f"invalid 'from_descriptor; expected an instance of {cls.generic_unit_descriptor}. " + f"invalid 'from_descriptor; expected an instance-equivalent of {cls.generic_unit_descriptor}. " ) - if not to_descriptor.isinstance(cls.generic_unit_descriptor): + if not to_descriptor.isinstance_equivalent(cls.generic_unit_descriptor): raise UnitConversionError( - f"invalid 'to_descriptor'; expected an instance of {cls.generic_unit_descriptor}. " + f"invalid 'to_descriptor'; expected an instance-equivalent of {cls.generic_unit_descriptor}. " ) + from_dimension = CompositeDimension.from_descriptor(from_descriptor) + + if not to_descriptor.isinstance(from_descriptor.to_generic()): + + if cls._is_alias(from_dimension, to_descriptor) or ( + cls._is_aliased(from_dimension) + ): + return cls._get_aliased_factor(from_dimension, to_descriptor) + to_dimension = CompositeDimension.from_descriptor(to_descriptor) return cls._get_numerator_factor( from_dimension, to_dimension @@ -731,3 +746,77 @@ def _get_denominator_factor( factor = (converter.get_factor(from_d.unit, to_d.unit)) ** from_d.power denominator_factor *= factor return denominator_factor + + @staticmethod + def _is_alias( + from_dimension: CompositeDimension, descriptor: UnitDescriptor + ) -> bool: + """ + Returns True if the descriptor is an alias of the from_dimension. + + Assumes that from_dimension and descriptor are both an instance of the + converter's generic unit descriptor. + """ + if isinstance(descriptor, AliasMeasurementUnit): + return True + + if isinstance(descriptor, Dimension): + if isinstance(descriptor.unit, AliasMeasurementUnit): + return True + + return False + + if isinstance(descriptor, CompositeDimension): + for n in descriptor.numerator: + if from_dimension.get_numerator(n.to_generic(), None) is None: + return True + + for d in descriptor.denominator: + if from_dimension.get_denominator(d.to_generic(), None) is None: + return True + + return False + + @staticmethod + def _is_aliased(dimension: CompositeDimension) -> bool: + """ + Returns True if the dimension contains an alias, False otherwise. + """ + for n in dimension.numerator: + if isinstance(n.unit, AliasMeasurementUnit): + return True + + for d in dimension.denominator: + if isinstance(d.unit, AliasMeasurementUnit): + return True + + return False + + @classmethod + def _get_aliased_factor( + cls, from_dimension: CompositeDimension, to_descriptor: UnitDescriptor + ) -> float: + """ + Returns the conversion factor from an alias unit to its aliased. + + The conversion happens in three steps: + + 1. Convert from the alias unit to the SI unit. + 2. Convert from the SI unit to the aliased SI units (this step is not + implemented in code, because the conversion factor is 1) + 3. Convert from the SI units to the target units. + + e.g. if you want to convert from cal/K/s to kW/K: + 1. cal/K/s -> J/K/s + 2. J/K/s -> W/K (conversion factor 1) + 3. W/K -> kW/K + """ + step_1_factor = cls.get_factor(from_dimension, from_dimension.si()) + + converter = get_converter(to_descriptor.to_generic()) + + step_3_factor = converter.convert( + 1, to_descriptor.to_generic().to_si(), to_descriptor + ) + + return step_1_factor * step_3_factor From 1bf5dd340e958371a648dbe48dfbcc5ef49eb7bf Mon Sep 17 00:00:00 2001 From: Maximos Nikiforakis Date: Mon, 8 Apr 2024 10:00:11 +0300 Subject: [PATCH 9/9] Adjust properties for alias unit conversions --- src/property_utils/properties/property.py | 14 +- .../tests/properties/test_property.py | 182 ++++++++++++++++++ 2 files changed, 189 insertions(+), 7 deletions(-) diff --git a/src/property_utils/properties/property.py b/src/property_utils/properties/property.py index 3db9a11..595321c 100644 --- a/src/property_utils/properties/property.py +++ b/src/property_utils/properties/property.py @@ -104,7 +104,7 @@ def eq(self, other: "Property", *, rel_tol=1e-9, abs_tol=0) -> bool: """ if not isinstance(other, Property): return False - if not self.unit.isinstance(other.unit.to_generic()): + if not self.unit.isinstance_equivalent(other.unit.to_generic()): return False try: prop = other.to_unit(self.unit) if self.unit != other.unit else other @@ -155,7 +155,7 @@ def to_unit(self, unit: UnitDescriptor) -> Self: >>> T.to_unit(RelativeTemperatureUnit.FAHRENHEIT) """ - if not unit.isinstance(self.unit.to_generic()): + if not unit.isinstance_equivalent(self.unit.to_generic()): raise PropertyUnitConversionError( f"cannot convert {self} to ({unit}) units; 'unit' should be an instance" f" of {self.unit.to_generic()}. " @@ -300,7 +300,7 @@ def __add__(self, other) -> Self: f"cannot add {other} to ({self}); {other} is not a {self.__class__}; " "only same properties can be added to each other. " ) - if not self.unit.isinstance(other.unit.to_generic()): + if not self.unit.isinstance_equivalent(other.unit.to_generic()): raise PropertyBinaryOperationError( f"cannot add ({other}) to ({self}); " f"({other}) must have ({self.unit.to_generic()}) units. " @@ -347,7 +347,7 @@ def __sub__(self, other) -> Self: f"{self.__class__}; only same properties can be subtracted from each " "other. " ) - if not self.unit.isinstance(other.unit.to_generic()): + if not self.unit.isinstance_equivalent(other.unit.to_generic()): raise PropertyBinaryOperationError( f"cannot subtract ({other}) from ({self}); " f"({other}) must have ({self.unit.to_generic()}) units. " @@ -382,7 +382,7 @@ def __rsub__(self, other) -> Self: f"{self.__class__}; only same properties can be subtracted from each " "other. " ) - if not self.unit.isinstance(other.unit.to_generic()): + if not self.unit.isinstance_equivalent(other.unit.to_generic()): raise PropertyBinaryOperationError( f"cannot subtract ({self}) from ({other}); " f"({other}) must have ({self.unit.to_generic()}) units. " @@ -422,7 +422,7 @@ def __eq__(self, other) -> bool: """ if not isinstance(other, Property): return False - if not self.unit.isinstance(other.unit.to_generic()): + if not self.unit.isinstance_equivalent(other.unit.to_generic()): return False try: prop = other.to_unit(self.unit) if self.unit != other.unit else other @@ -581,7 +581,7 @@ def _validate_comparison_input(self, other) -> None: f"cannot compare {other} to ({self}); {other} is not a Property; " "only properties can be compared to properties. " ) - if not self.unit.isinstance(other.unit.to_generic()): + if not self.unit.isinstance_equivalent(other.unit.to_generic()): raise PropertyBinaryOperationError( f"cannot compare ({other}) to ({self}); " f"({other}) must have ({self.unit.to_generic()}) units. " diff --git a/src/property_utils/tests/properties/test_property.py b/src/property_utils/tests/properties/test_property.py index db5f1ca..f9a9461 100644 --- a/src/property_utils/tests/properties/test_property.py +++ b/src/property_utils/tests/properties/test_property.py @@ -12,6 +12,8 @@ Unit2, Unit3, Unit4, + Unit6, + Unit8, generic_dimension_1, generic_composite_dimension, ) @@ -128,6 +130,36 @@ def test_with_uneregistered_units(self): self.assert_invalid_operation() +@add_to(property_test_suite, "eq") +class TestAliasPropertyEq(TestProperty): + def build_property(self): + return Property(10, Unit8.H) + + @args({"other": Property(10, Unit8.H)}) + def test_with_same_unit(self): + self.assertResultTrue() + + @args({"other": Property(40, Unit8.h)}) + def test_with_si_unit(self): + self.assertResultTrue() + + @args({"other": Property(40, Unit1.a**2 / Unit4.d**2)}) + def test_with_aliased_si_units(self): + self.assertResultTrue() + + @args({"other": Property(10, Unit1.A**2 / Unit4.D**2)}) + def test_with_aliased_units(self): + self.assertResultTrue() + + @args({"other": Property(40, Unit6.f / Unit4.d**2)}) + def test_with_other_aliased_si_units(self): + self.assertResultTrue() + + @args({"other": Property(500, Unit6.F / Unit4.D**2)}) + def test_with_other_aliased_units(self): + self.assertResultTrue() + + @add_to(property_test_suite) class TestPropertyToSi(TestProperty): def subject(self, unit): @@ -471,6 +503,28 @@ def test_with_other_units(self): self.assert_invalid_operation() +@add_to(property_test_suite, "__add__") +class TestAliasPropertyAddition(TestProperty): + def build_property(self) -> Property: + return Property(15, Unit8.H) + + @args({"other": Property(40, Unit1.a**2 / Unit4.d**2)}) + def test_with_aliased_si_units(self): + self.assert_result("25.0 H") + + @args({"other": Property(15, Unit1.A**2 / Unit4.D**2)}) + def test_with_aliased_units(self): + self.assert_result("30.0 H") + + @args({"other": Property(40, Unit6.f / Unit4.d**2)}) + def test_with_other_aliased_si_units(self): + self.assert_result("25.0 H") + + @args({"other": Property(500, Unit6.F / Unit4.D**2)}) + def test_with_other_aliased_units(self): + self.assert_result("25.0 H") + + @add_to(property_test_suite, "__sub__") class TestSimplePropertySubtraction(TestProperty): @@ -540,6 +594,28 @@ def test_with_other_units(self): self.assert_invalid_operation() +@add_to(property_test_suite, "__sub__") +class TestAliasPropertySubtraction(TestProperty): + def build_property(self) -> Property: + return Property(25, Unit8.H) + + @args({"other": Property(40, Unit1.a**2 / Unit4.d**2)}) + def test_with_aliased_si_units(self): + self.assert_result("15.0 H") + + @args({"other": Property(15, Unit1.A**2 / Unit4.D**2)}) + def test_with_aliased_units(self): + self.assert_result("10.0 H") + + @args({"other": Property(40, Unit6.f / Unit4.d**2)}) + def test_with_other_aliased_si_units(self): + self.assert_result("15.0 H") + + @args({"other": Property(500, Unit6.F / Unit4.D**2)}) + def test_with_other_aliased_units(self): + self.assert_result("15.0 H") + + @add_to(property_test_suite, "__rsub__") class TestSimplePropertyRightSubtraction(TestProperty): @@ -571,6 +647,24 @@ def test_with_negative(self): self.assert_result("-5.0 A") +@add_to(property_test_suite, "__rsub__") +class TestAliasPropertyRightSubtraction(TestProperty): + def build_property(self) -> Property: + return Property(25, Unit8.H) + + @args({"other": Property(40, Unit1.a**2 / Unit4.d**2)}) + def test_with_aliased_si_units(self): + self.assert_result("-60.0 (a^2) / (d^2)") + + @args({"other": Property(15, Unit1.A**2 / Unit4.D**2)}) + def test_with_aliased_units(self): + self.assert_result("-10.0 (A^2) / (D^2)") + + @args({"other": Property(40, Unit6.f / Unit4.d**2)}) + def test_with_other_aliased_si_units(self): + self.assert_result("-60.0 f / (d^2)") + + @add_to(property_test_suite, "__pow__") class TestPropertyExponentiation(TestProperty): @@ -788,6 +882,28 @@ def test_with_same_prop(self): self.assertResultFalse() +@add_to(property_test_suite, "__gt__") +class TestAliasPropertyGreater(TestProperty): + def build_property(self): + return Property(10, Unit8.H) + + @args({"other": Property(35, Unit1.a**2 / Unit4.d**2)}) + def test_with_aliased_si_units(self): + self.assertResultTrue() + + @args({"other": Property(15, Unit1.A**2 / Unit4.D**2)}) + def test_with_aliased_units(self): + self.assertResultFalse() + + @args({"other": Property(22, Unit6.f / Unit4.d**2)}) + def test_with_other_aliased_si_units(self): + self.assertResultTrue() + + @args({"other": Property(345, Unit6.F / Unit4.D**2)}) + def test_with_other_aliased_units(self): + self.assertResultTrue() + + @add_to(property_test_suite, "__ge__") class TestPropertyGreaterEqual(TestProperty): @@ -854,6 +970,28 @@ def test_with_same_prop(self): self.assertResultTrue() +@add_to(property_test_suite, "__ge__") +class TestAliasPropertyGreaterEqual(TestProperty): + def build_property(self): + return Property(10, Unit8.H) + + @args({"other": Property(40, Unit1.a**2 / Unit4.d**2)}) + def test_with_aliased_si_units(self): + self.assertResultTrue() + + @args({"other": Property(20, Unit1.A**2 / Unit4.D**2)}) + def test_with_aliased_units(self): + self.assertResultFalse() + + @args({"other": Property(40, Unit6.f / Unit4.d**2)}) + def test_with_other_aliased_si_units(self): + self.assertResultTrue() + + @args({"other": Property(500, Unit6.F / Unit4.D**2)}) + def test_with_other_aliased_units(self): + self.assertResultTrue() + + @add_to(property_test_suite, "__lt__") class TestPropertyLower(TestProperty): @@ -920,6 +1058,28 @@ def test_with_same_prop(self): self.assertResultFalse() +@add_to(property_test_suite, "__lt__") +class TestAliasPropertyLower(TestProperty): + def build_property(self): + return Property(10, Unit8.H) + + @args({"other": Property(50, Unit1.a**2 / Unit4.d**2)}) + def test_with_aliased_si_units(self): + self.assertResultTrue() + + @args({"other": Property(11, Unit1.A**2 / Unit4.D**2)}) + def test_with_aliased_units(self): + self.assertResultTrue() + + @args({"other": Property(20, Unit6.f / Unit4.d**2)}) + def test_with_other_aliased_si_units(self): + self.assertResultFalse() + + @args({"other": Property(600, Unit6.F / Unit4.D**2)}) + def test_with_other_aliased_units(self): + self.assertResultTrue() + + @add_to(property_test_suite, "__le__") class TestPropertyLowerEqual(TestProperty): @@ -984,3 +1144,25 @@ def test_with_other_units(self): @args({"other": Property(5, Unit3.C)}) def test_with_same_prop(self): self.assertResultTrue() + + +@add_to(property_test_suite, "__le__") +class TestAliasPropertyLowerEqual(TestProperty): + def build_property(self): + return Property(10, Unit8.H) + + @args({"other": Property(40, Unit1.a**2 / Unit4.d**2)}) + def test_with_aliased_si_units(self): + self.assertResultTrue() + + @args({"other": Property(11, Unit1.A**2 / Unit4.D**2)}) + def test_with_aliased_units(self): + self.assertResultTrue() + + @args({"other": Property(40, Unit6.f / Unit4.d**2)}) + def test_with_other_aliased_si_units(self): + self.assertResultTrue() + + @args({"other": Property(505, Unit6.F / Unit4.D**2)}) + def test_with_other_aliased_units(self): + self.assertResultTrue()