diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 5543f1d4e..918126a63 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -316,12 +316,207 @@ def startswith(self, prefix: str) -> bool: or (self.ascii_symbol is not None and self.ascii_symbol.lower().startswith(prefix)) \ or (self.symbol is not None and self.symbol.lower().startswith(prefix)) -# -# Parsing plan: -# Require unknown amounts of units to be explicitly positive or negative? -# -# +class ArbitraryUnit(NamedUnit): + """A unit for an unknown quantity + + While this library attempts to handle all known SI units, it is + likely that users will want to express quantities of arbitrary + units (for example, calculating donuts per person for a meeting). + The arbitrary unit allows for these unforseeable quantities.""" + + def __init__(self, + numerator: str | list[str] | dict[str, int], + denominator: None | list[str] | dict[str, int]= None): + match numerator: + case str(): + self._numerator = {numerator: 1} + case list(): + self._numerator = {} + for key in numerator: + if key in self._numerator: + self._numerator[key] += 1 + else: + self._numerator[key] = 1 + case dict(): + self._numerator = numerator + case _: + raise TypeError + match denominator: + case None: + self._denominator = {} + case str(): + self._denominator = {denominator: 1} + case list(): + self._denominator = {} + for key in denominator: + if key in self._denominator: + self._denominator[key] += 1 + else: + self._denominator[key] = 1 + case dict(): + self._denominator = denominator + case _: + raise TypeError + self._unit = NamedUnit(1, Dimensions(), "") # Unitless + + super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) + + def _name(self): + num = [] + for key, value in self._numerator.items(): + if value == 1: + num.append(key) + else: + num.append(f"{key}^{value}") + den = [] + for key, value in self._denominator.items(): + if value == 1: + den.append(key) + else: + den.append(f"{key}^{value}") + num.sort() + den.sort() + match (num, den): + case ([], []): + return "" + case (_, []): + return " ".join(num) + case ([], _): + return "1 / " + " ".join(den) + case _: + return " ".join(num) + " / " + " ".join(den) + + def __eq__(self, other): + match other: + case ArbitraryUnit(): + return self._numerator == other._numerator and self._denominator == other._denominator and self._unit == other._unit + case Unit(): + return not self._numerator and not self._denominator and self._unit == other + + + def __mul__(self: Self, other: "Unit"): + match other: + case ArbitraryUnit(): + num = dict(self._numerator) + for key in other._numerator: + if key in num: + num[key] += other._numerator[key] + else: + num[key] = other._numerator[key] + den = dict(self._denominator) + for key in other._denominator: + if key in den: + den[key] += other._denominator[key] + else: + den[key] = other._denominator[key] + result = ArbitraryUnit(num, den) + result._unit *= other._unit + return result._reduce() + case NamedUnit() | Unit() | int() | float(): + result = ArbitraryUnit(self._numerator, self._denominator) + result._unit *= other + return result + case _: + return NotImplemented + + def __rmul__(self: Self, other): + return self * other + + def __truediv__(self: Self, other: "Unit"): + match other: + case ArbitraryUnit(): + num = dict(self._numerator) + for key in other._denominator: + if key in num: + num[key] += other._denominator[key] + else: + num[key] = other._denominator[key] + den = dict(self._denominator) + for key in other._numerator: + if key in den: + den[key] += other._numerator[key] + else: + den[key] = other._numerator[key] + result = ArbitraryUnit(num, den) + result._unit /= other._unit + return result._reduce() + case NamedUnit() | Unit() | int() | float(): + result = ArbitraryUnit(self._numerator, self._denominator) + result._unit /= other + return result + case _: + return NotImplemented + + def __rtruediv__(self: Self, other: "Unit"): + match other: + case ArbitraryUnit(): + num = dict(other._numerator) + for key in self._denominator: + if key in num: + num[key] += self._denominator[key] + else: + num[key] = self._denominator[key] + den = dict(other._denominator) + for key in self._numerator: + if key in den: + den[key] += self._numerator[key] + else: + den[key] = self._numerator[key] + result = ArbitraryUnit(num, den) + result._unit = other._unit / self._unit + return result._reduce() + case NamedUnit() | Unit() | int() | float(): + result = ArbitraryUnit(self._denominator, self._numerator) + result._unit = other / result._unit + return result + case _: + return NotImplemented + + def __pow__(self, power: int): + match power: + case int() | float(): + num = {key: value * power for key, value in self._numerator.items()} + den = {key: value * power for key, value in self._denominator.items()} + result = ArbitraryUnit(num, den) + result._unit = self._unit ** power + return result + case _: + return NotImplemented + + + def equivalent(self: Self, other: "Unit"): + match other: + case ArbitraryUnit(): + return self._unit.equivalent(other._unit) and sorted(self._numerator) == sorted(other._numerator) and sorted(self._denominator) == sorted(other._denominator) + case _: + return False + + def _reduce(self): + """Remove redundant units""" + for k in self._denominator: + if k in self._numerator: + common = min(self._numerator[k], self._denominator[k]) + self._numerator[k] -= common + self._denominator[k] -= common + dead_nums = [k for k in self._numerator if self._numerator[k] == 0] + for k in dead_nums: + del self._numerator[k] + dead_dens = [k for k in self._denominator if self._denominator[k] == 0] + for k in dead_dens: + del self._denominator[k] + return self + + def __str__(self): + result = self._name() + if type(self._unit) is NamedUnit and self._unit.name.strip(): + result += f" {self._unit.name.strip()}" + if type(self._unit) is Unit and str(self._unit).strip(): + result += f" {str(self._unit).strip()}" + return result + + def __repr__(self): + return str(self) @dataclass diff --git a/sasdata/quantities/accessors.py b/sasdata/quantities/accessors.py index d23268544..159d33d49 100644 --- a/sasdata/quantities/accessors.py +++ b/sasdata/quantities/accessors.py @@ -9479,6 +9479,14 @@ def radians(self) -> T: else: return quantity.in_units_of(units.radians) + @property + def rotations(self) -> T: + quantity = self.quantity + if quantity is None: + return None + else: + return quantity.in_units_of(units.rotations) + class SolidangleAccessor[T](QuantityAccessor[T]): diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index fe840ab85..6b6ef194e 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -401,6 +401,222 @@ def startswith(self, prefix: str) -> bool: or (self.ascii_symbol is not None and self.ascii_symbol.lower().startswith(prefix)) \ or (self.symbol is not None and self.symbol.lower().startswith(prefix)) + +class ArbitraryUnit(NamedUnit): + """A unit for an unknown quantity + + While this library attempts to handle all known SI units, it is + likely that users will want to express quantities of arbitrary + units (for example, calculating donuts per person for a meeting). + The arbitrary unit allows for these unforseeable quantities.""" + + def __init__(self, + numerator: str | list[str] | dict[str, int], + denominator: None | list[str] | dict[str, int]= None): + match numerator: + case str(): + self._numerator = {numerator: 1} + case list(): + self._numerator = {} + for key in numerator: + if key in self._numerator: + self._numerator[key] += 1 + else: + self._numerator[key] = 1 + case dict(): + self._numerator = numerator + case _: + raise TypeError + match denominator: + case None: + self._denominator = {} + case str(): + self._denominator = {denominator: 1} + case list(): + self._denominator = {} + for key in denominator: + if key in self._denominator: + self._denominator[key] += 1 + else: + self._denominator[key] = 1 + case dict(): + self._denominator = denominator + case _: + raise TypeError + self._unit = NamedUnit(1, Dimensions(), "") # Unitless + + super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) + + def _name(self): + num = [] + for key, value in self._numerator.items(): + if value == 1: + num.append(key) + else: + num.append(f"{key}^{value}") + den = [] + for key, value in self._denominator.items(): + if value == 1: + den.append(key) + else: + den.append(f"{key}^{value}") + num.sort() + den.sort() + match (num, den): + case ([], []): + return "" + case (_, []): + return " ".join(num) + case ([], _): + return "1 / " + " ".join(den) + case _: + return " ".join(num) + " / " + " ".join(den) + + def __eq__(self, other): + match other: + case ArbitraryUnit(): + return self._numerator == other._numerator and self._denominator == other._denominator and self._unit == other._unit + case Unit(): + return not self._numerator and not self._denominator and self._unit == other + + + def __mul__(self: Self, other: "Unit"): + match other: + case ArbitraryUnit(): + num = dict(self._numerator) + for key in other._numerator: + if key in num: + num[key] += other._numerator[key] + else: + num[key] = other._numerator[key] + den = dict(self._denominator) + for key in other._denominator: + if key in den: + den[key] += other._denominator[key] + else: + den[key] = other._denominator[key] + result = ArbitraryUnit(num, den) + result._unit *= other._unit + return result._reduce() + case NamedUnit() | Unit() | int() | float(): + result = ArbitraryUnit(self._numerator, self._denominator) + result._unit *= other + return result + case _: + return NotImplemented + + def __rmul__(self: Self, other): + return self * other + + def __truediv__(self: Self, other: "Unit"): + match other: + case ArbitraryUnit(): + num = dict(self._numerator) + for key in other._denominator: + if key in num: + num[key] += other._denominator[key] + else: + num[key] = other._denominator[key] + den = dict(self._denominator) + for key in other._numerator: + if key in den: + den[key] += other._numerator[key] + else: + den[key] = other._numerator[key] + result = ArbitraryUnit(num, den) + result._unit /= other._unit + return result._reduce() + case NamedUnit() | Unit() | int() | float(): + result = ArbitraryUnit(self._numerator, self._denominator) + result._unit /= other + return result + case _: + return NotImplemented + + def __rtruediv__(self: Self, other: "Unit"): + match other: + case ArbitraryUnit(): + num = dict(other._numerator) + for key in self._denominator: + if key in num: + num[key] += self._denominator[key] + else: + num[key] = self._denominator[key] + den = dict(other._denominator) + for key in self._numerator: + if key in den: + den[key] += self._numerator[key] + else: + den[key] = self._numerator[key] + result = ArbitraryUnit(num, den) + result._unit = other._unit / self._unit + return result._reduce() + case NamedUnit() | Unit() | int() | float(): + result = ArbitraryUnit(self._denominator, self._numerator) + result._unit = other / result._unit + return result + case _: + return NotImplemented + + def __pow__(self, power: int): + match power: + case int() | float(): + num = {key: value * power for key, value in self._numerator.items()} + den = {key: value * power for key, value in self._denominator.items()} + result = ArbitraryUnit(num, den) + result._unit = self._unit ** power + return result + case _: + return NotImplemented + + + def equivalent(self: Self, other: "Unit"): + match other: + case ArbitraryUnit(): + return self._unit.equivalent(other._unit) and sorted(self._numerator) == sorted(other._numerator) and sorted(self._denominator) == sorted(other._denominator) + case _: + return False + + def si_equivalent(self): + """ Get the SI unit corresponding to this unit""" + """FIXME: TODO""" + return Unit(1, self.dimensions) + + def _format_unit(self, format_process: list["UnitFormatProcessor"]): + """FIXME: TODO""" + for processor in format_process: + pass + + def _reduce(self): + """Remove redundant units""" + for k in self._denominator: + if k in self._numerator: + common = min(self._numerator[k], self._denominator[k]) + self._numerator[k] -= common + self._denominator[k] -= common + dead_nums = [k for k in self._numerator if self._numerator[k] == 0] + for k in dead_nums: + del self._numerator[k] + dead_dens = [k for k in self._denominator if self._denominator[k] == 0] + for k in dead_dens: + del self._denominator[k] + return self + + def __str__(self): + result = self._name() + if type(self._unit) is NamedUnit and self._unit.name.strip(): + result += f" {self._unit.name.strip()}" + if type(self._unit) is Unit and str(self._unit).strip(): + result += f" {str(self._unit).strip()}" + return result + + def __repr__(self): + return str(self) + + @staticmethod + def parse(unit_string: str) -> "Unit": + """FIXME: TODO""" + pass # # Parsing plan: # Require unknown amounts of units to be explicitly positive or negative? diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index 3bc775313..95bf4c3ca 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -1,72 +1,117 @@ import math -import sasdata.quantities.units as units -from sasdata.quantities.units import Unit - - -class EqualUnits: - def __init__(self, test_name: str, *units): - self.test_name = "Equality: " + test_name - self.units: list[Unit] = list(units) - - def run_test(self): - for i, unit_1 in enumerate(self.units): - for unit_2 in self.units[i + 1 :]: - assert unit_1.equivalent(unit_2), "Units should be equivalent" - assert unit_1 == unit_2, "Units should be equal" - - -class EquivalentButUnequalUnits: - def __init__(self, test_name: str, *units): - self.test_name = "Equivalence: " + test_name - self.units: list[Unit] = list(units) - - def run_test(self): - for i, unit_1 in enumerate(self.units): - for unit_2 in self.units[i + 1 :]: - assert unit_1.equivalent(unit_2), "Units should be equivalent" - assert unit_1 != unit_2, "Units should not be equal" - - -class DissimilarUnits: - def __init__(self, test_name: str, *units): - self.test_name = "Dissimilar: " + test_name - self.units: list[Unit] = list(units) - - def run_test(self): - for i, unit_1 in enumerate(self.units): - for unit_2 in self.units[i + 1 :]: - assert not unit_1.equivalent(unit_2), "Units should not be equivalent" - +import pytest -tests = [ - - EqualUnits("Pressure", - units.pascals, - units.newtons / units.meters ** 2, - units.micronewtons * units.millimeters ** -2), - - EqualUnits("Resistance", - units.ohms, - units.volts / units.amperes, - 1e-3/units.millisiemens), - - EquivalentButUnequalUnits("Angular frequency", - units.rotations / units.minutes, - units.degrees * units.hertz), - - EqualUnits("Angular frequency", - (units.rotations/units.minutes ), - (units.radians*units.hertz) * 2 * math.pi/60.0), - - DissimilarUnits("Frequency and Angular frequency", - (units.rotations/units.minutes), - (units.hertz)), - - -] - - -for test in tests: - print(test.test_name) - test.run_test() +import sasdata.quantities.units as units +from sasdata.quantities.units import ArbitraryUnit + +EQUAL_TERMS = { + "Pressure": [units.pascals, units.newtons / units.meters**2, units.micronewtons * units.millimeters**-2], + "Resistance": [units.ohms, units.volts / units.amperes, 1e-3 / units.millisiemens], + "Angular frequency": [(units.rotations / units.minutes), (units.radians * units.hertz) * 2 * math.pi / 60.0], + "Arbitrary Units": [ArbitraryUnit("Pizzas"), ArbitraryUnit(["Pizzas"])], + "Arbitrary Fractional Units": [ + ArbitraryUnit("Slices", denominator=["Pizzas"]), + ArbitraryUnit(["Slices"], denominator=["Pizzas"]), + ], + "Arbitrary Multiplication": [ + ArbitraryUnit("Pizzas") * ArbitraryUnit("People"), + ArbitraryUnit(["Pizzas", "People"]), + ], + "Arbitrary Multiplication with Units": [ + ArbitraryUnit("Pizzas") * units.meters, + units.meters * ArbitraryUnit(["Pizzas"]), + ], + "Arbitrary Power": [ + ArbitraryUnit(["Slices"], denominator=["Pizza"]) * ArbitraryUnit(["Slices"], denominator=["Pizza"]), + ArbitraryUnit(["Slices"], denominator=["Pizza"]) ** 2, + ], + "Arbitrary Fractional Power": [ + ArbitraryUnit(["Pizza", "Pizza", "Pizza"]), + ArbitraryUnit(["Pizza", "Pizza"]) ** 1.5, + ], + "Arbitrary Division": [ + ArbitraryUnit("Slices") / ArbitraryUnit("Pizza"), + ArbitraryUnit(["Slices"], denominator=["Pizza"]), + (1 / ArbitraryUnit("Pizza")) * ArbitraryUnit("Slices"), + 1 / (ArbitraryUnit("Pizza") / ArbitraryUnit("Slices")), + ], + "Arbitrary Complicated Math": [ + (ArbitraryUnit("Slices") / ArbitraryUnit("Person")) + / (ArbitraryUnit("Slices") / ArbitraryUnit("Pizzas")) + * ArbitraryUnit("Person"), + ArbitraryUnit("Pizzas"), + ], +} + + +@pytest.fixture(params=EQUAL_TERMS) +def equal_term(request): + return EQUAL_TERMS[request.param] + + +def test_unit_equality(equal_term): + for i, unit_1 in enumerate(equal_term): + for unit_2 in equal_term[i + 1 :]: + assert unit_1.equivalent(unit_2), "Units should be equivalent" + assert unit_1 == unit_2, "Units should be equal" + + +EQUIVALENT_TERMS = { + "Angular frequency": [units.rotations / units.minutes, units.degrees * units.hertz], +} + + +@pytest.fixture(params=EQUIVALENT_TERMS) +def equivalent_term(request): + return EQUIVALENT_TERMS[request.param] + + +def test_unit_equivalent(equivalent_term): + units = equivalent_term + for i, unit_1 in enumerate(units): + for unit_2 in units[i + 1 :]: + assert unit_1.equivalent(unit_2), "Units should be equivalent" + assert unit_1 != unit_2, "Units not should be equal" + + +DISSIMILAR_TERMS = { + "Frequency and Angular frequency": [(units.rotations / units.minutes), (units.hertz)], + "Different Arbitrary Units": [ArbitraryUnit("Pizzas"), ArbitraryUnit(["Donuts"])], + "Arbitrary Multiplication with Units": [ + ArbitraryUnit("Pizzas") * units.meters, + units.seconds * ArbitraryUnit(["Pizzas"]), + ], +} + + +@pytest.fixture(params=DISSIMILAR_TERMS) +def dissimilar_term(request): + return DISSIMILAR_TERMS[request.param] + + +def test_unit_dissimilar(dissimilar_term): + units = dissimilar_term + for i, unit_1 in enumerate(units): + for unit_2 in units[i + 1 :]: + assert not unit_1.equivalent(unit_2), "Units should not be equivalent" + + +def test_unit_names(): + pizza = ArbitraryUnit(["Pizza"]) + slice = ArbitraryUnit(["Slice"]) + pineapple = ArbitraryUnit(["Pineapple"]) + pie = ArbitraryUnit(["Pie"]) + empty = ArbitraryUnit([]) + + assert str(empty) == "" + + assert str(pizza) == "Pizza" + assert str(pizza * pineapple) == "Pineapple Pizza" + assert str(pizza * pizza) == "Pizza^2" + + assert str(1 / pizza) == "1 / Pizza" + assert str(slice / pizza) == "Slice / Pizza" + assert str((slice / pizza) ** 2) == "Slice^2 / Pizza^2" + + assert str(pie**0.5) == "Pie^0.5" # A valid unit, because pie are square