From d5ab81edf853ad295c160263fcac3ca1ed947769 Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Thu, 19 Feb 2026 12:52:15 +0000 Subject: [PATCH 01/21] Implement ArbitraryUnit class --- sasdata/quantities/_units_base.py | 109 ++++++++++++++++++++++++++++++ sasdata/quantities/accessors.py | 8 +++ sasdata/quantities/units.py | 109 ++++++++++++++++++++++++++++++ test/quantities/utest_units.py | 19 +++++- 4 files changed, 244 insertions(+), 1 deletion(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 5543f1d4e..881973872 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -316,6 +316,115 @@ 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], denominator: None | list[str] = None): + match numerator: + case str(name): + self._numerator = [name] + case _: + self._numerator = numerator + match denominator: + case None: + self._denominator = [] + case _: + self._denominator = denominator + self._unit = Unit(1, Dimensions()) # Unitless + print("Made") + + super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) + print("Make more") + + def _name(self): + match (self._numerator, self._denominator): + case ([], []): + return "" + case (_, []): + return " ".join(self._numerator) + case ([], _): + return "1 / " + " ".join(self._denominator) + case _: + return " ".join(self._numerator) + " / " + " ".join(self._denominator) + + 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"): + # if isinstance(other, Unit): + # return Unit(self.scale * other.scale, self.dimensions * other.dimensions) + # elif isinstance(other, (int, float)): + # return Unit(other * self.scale, self.dimensions) + return NotImplemented + + def __truediv__(self: Self, other: "Unit"): + # if isinstance(other, Unit): + # return Unit(self.scale / other.scale, self.dimensions / other.dimensions) + # elif isinstance(other, (int, float)): + # return Unit(self.scale / other, self.dimensions) + # else: + return NotImplemented + + def __rtruediv__(self: Self, other: "Unit"): + # if isinstance(other, Unit): + # return Unit(other.scale / self.scale, other.dimensions / self.dimensions) + # elif isinstance(other, (int, float)): + # return Unit(other / self.scale, self.dimensions ** -1) + # else: + return NotImplemented + + def __pow__(self, power: int | float): + # if not isinstance(power, int | float): + # return NotImplemented + + return Unit(self.scale**power, self.dimensions**power) + + + 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 __eq__(self: Self, other: "Unit"): + """FIXME: TODO""" + return self.equivalent(other) and np.abs(np.log(self.scale/other.scale)) < 1e-5 + + 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 __repr__(self): + """FIXME: TODO""" + if self.scale == 1: + # We're in SI + return self.dimensions.si_repr() + + else: + return f"Unit[{self.scale}, {self.dimensions}]" + + @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/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..94479bddd 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -401,6 +401,115 @@ 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], denominator: None | list[str] = None): + match numerator: + case str(name): + self._numerator = [name] + case _: + self._numerator = numerator + match denominator: + case None: + self._denominator = [] + case _: + self._denominator = denominator + self._unit = Unit(1, Dimensions()) # Unitless + print("Made") + + super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) + print("Make more") + + def _name(self): + match (self._numerator, self._denominator): + case ([], []): + return "" + case (_, []): + return " ".join(self._numerator) + case ([], _): + return "1 / " + " ".join(self._denominator) + case _: + return " ".join(self._numerator) + " / " + " ".join(self._denominator) + + 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"): + # if isinstance(other, Unit): + # return Unit(self.scale * other.scale, self.dimensions * other.dimensions) + # elif isinstance(other, (int, float)): + # return Unit(other * self.scale, self.dimensions) + return NotImplemented + + def __truediv__(self: Self, other: "Unit"): + # if isinstance(other, Unit): + # return Unit(self.scale / other.scale, self.dimensions / other.dimensions) + # elif isinstance(other, (int, float)): + # return Unit(self.scale / other, self.dimensions) + # else: + return NotImplemented + + def __rtruediv__(self: Self, other: "Unit"): + # if isinstance(other, Unit): + # return Unit(other.scale / self.scale, other.dimensions / self.dimensions) + # elif isinstance(other, (int, float)): + # return Unit(other / self.scale, self.dimensions ** -1) + # else: + return NotImplemented + + def __pow__(self, power: int | float): + # if not isinstance(power, int | float): + # return NotImplemented + + return Unit(self.scale**power, self.dimensions**power) + + + 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 __eq__(self: Self, other: "Unit"): + """FIXME: TODO""" + return self.equivalent(other) and np.abs(np.log(self.scale/other.scale)) < 1e-5 + + 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 __repr__(self): + """FIXME: TODO""" + if self.scale == 1: + # We're in SI + return self.dimensions.si_repr() + + else: + return f"Unit[{self.scale}, {self.dimensions}]" + + @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..621117a32 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -1,7 +1,7 @@ import math import sasdata.quantities.units as units -from sasdata.quantities.units import Unit +from sasdata.quantities.units import Unit, ArbitraryUnit class EqualUnits: @@ -12,6 +12,7 @@ def __init__(self, test_name: str, *units): def run_test(self): for i, unit_1 in enumerate(self.units): for unit_2 in self.units[i + 1 :]: + print(unit_1, unit_2) assert unit_1.equivalent(unit_2), "Units should be equivalent" assert unit_1 == unit_2, "Units should be equal" @@ -38,6 +39,10 @@ def run_test(self): for unit_2 in self.units[i + 1 :]: assert not unit_1.equivalent(unit_2), "Units should not be equivalent" +pizza_a = ArbitraryUnit("Pizzas") +pizza_b = ArbitraryUnit(["Pizzas"]) +print(f"A: {pizza_a}\tB: {pizza_b}") + tests = [ @@ -63,6 +68,18 @@ def run_test(self): (units.rotations/units.minutes), (units.hertz)), + EqualUnits("Arbitrary Units", + ArbitraryUnit("Pizzas"), + ArbitraryUnit(["Pizzas"])), + + EqualUnits("Arbitrary Units", + ArbitraryUnit("Slices", denominator=["Pizzas"]), + ArbitraryUnit(["Slices"], denominator=["Pizzas"])), + + DissimilarUnits("Different Arbitrary Units", + ArbitraryUnit("Pizzas"), + ArbitraryUnit(["Doughnuts"])), + ] From 880a6b82aabfe023da238d263270d5c38e56d96a Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Thu, 19 Feb 2026 14:26:39 +0000 Subject: [PATCH 02/21] Refactor utest_units.py The tests now properly appear in the pytest list of tests. Additionally, the tests are given readable parameterised names, but display the actual values on failure. --- sasdata/quantities/_units_base.py | 14 ++-- sasdata/quantities/units.py | 10 ++- test/quantities/utest_units.py | 114 ++++++++++++------------------ 3 files changed, 54 insertions(+), 84 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 881973872..1a7418127 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -398,10 +398,6 @@ def equivalent(self: Self, other: "Unit"): case _: return False - def __eq__(self: Self, other: "Unit"): - """FIXME: TODO""" - return self.equivalent(other) and np.abs(np.log(self.scale/other.scale)) < 1e-5 - def si_equivalent(self): """ Get the SI unit corresponding to this unit""" """FIXME: TODO""" @@ -414,12 +410,10 @@ def _format_unit(self, format_process: list["UnitFormatProcessor"]): def __repr__(self): """FIXME: TODO""" - if self.scale == 1: - # We're in SI - return self.dimensions.si_repr() - - else: - return f"Unit[{self.scale}, {self.dimensions}]" + result = self._name() + if self._unit.__repr__(): + result += f" {self._unit.__repr__()}" + return result @staticmethod def parse(unit_string: str) -> "Unit": diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index 94479bddd..7fbf9750d 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -499,12 +499,10 @@ def _format_unit(self, format_process: list["UnitFormatProcessor"]): def __repr__(self): """FIXME: TODO""" - if self.scale == 1: - # We're in SI - return self.dimensions.si_repr() - - else: - return f"Unit[{self.scale}, {self.dimensions}]" + result = self._name() + if self._unit.__repr__(): + result += f" {self._unit.__repr__()}" + return result @staticmethod def parse(unit_string: str) -> "Unit": diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index 621117a32..33d474721 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -1,89 +1,67 @@ import math +import pytest import sasdata.quantities.units as units -from sasdata.quantities.units import Unit, ArbitraryUnit +from sasdata.quantities.units import ArbitraryUnit -class EqualUnits: - def __init__(self, test_name: str, *units): - self.test_name = "Equality: " + test_name - self.units: list[Unit] = list(units) +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"]), + ], +} - def run_test(self): - for i, unit_1 in enumerate(self.units): - for unit_2 in self.units[i + 1 :]: - print(unit_1, unit_2) - assert unit_1.equivalent(unit_2), "Units should be equivalent" - assert unit_1 == unit_2, "Units should be equal" +@pytest.fixture(params=EQUAL_TERMS) +def equal_term(request): + return EQUAL_TERMS[request.param] -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" +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" -class DissimilarUnits: - def __init__(self, test_name: str, *units): - self.test_name = "Dissimilar: " + test_name - self.units: list[Unit] = list(units) +EQUIVALENT_TERMS = { + "Angular frequency": [units.rotations / units.minutes, units.degrees * units.hertz], +} - 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" -pizza_a = ArbitraryUnit("Pizzas") -pizza_b = ArbitraryUnit(["Pizzas"]) -print(f"A: {pizza_a}\tB: {pizza_b}") +@pytest.fixture(params=EQUIVALENT_TERMS) +def equivalent_term(request): + return EQUIVALENT_TERMS[request.param] -tests = [ +def test_unit_equivalent(equivalent_term): + units = equivalent_term + for i, unit_1 in enumerate(units): + for unit_2 in units[i + 1 :]: + print(unit_1, unit_2) + assert unit_1.equivalent(unit_2), "Units should be equivalent" + assert unit_1 != unit_2, "Units not should be equal" - 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), +DISSIMILAR_TERMS = { + "Frequency and Angular frequency": [(units.rotations / units.minutes), (units.hertz)], + "Different Arbitrary Units": [ArbitraryUnit("Pizzas"), ArbitraryUnit(["Donuts"])], +} - 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), +@pytest.fixture(params=DISSIMILAR_TERMS) +def dissimilar_term(request): + return DISSIMILAR_TERMS[request.param] - DissimilarUnits("Frequency and Angular frequency", - (units.rotations/units.minutes), - (units.hertz)), - EqualUnits("Arbitrary Units", - ArbitraryUnit("Pizzas"), - ArbitraryUnit(["Pizzas"])), - - EqualUnits("Arbitrary Units", - ArbitraryUnit("Slices", denominator=["Pizzas"]), - ArbitraryUnit(["Slices"], denominator=["Pizzas"])), - - DissimilarUnits("Different Arbitrary Units", - ArbitraryUnit("Pizzas"), - ArbitraryUnit(["Doughnuts"])), - - -] - - -for test in tests: - print(test.test_name) - test.run_test() +def test_unit_dissimilar(dissimilar_term): + units = dissimilar_term + for i, unit_1 in enumerate(units): + for unit_2 in units[i + 1 :]: + print(unit_1, unit_2) + assert not unit_1.equivalent(unit_2), "Units should not be equivalent" From b4cb726bf1b9f31388fa62cc2b34d6661a960fea Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Thu, 19 Feb 2026 15:31:04 +0000 Subject: [PATCH 03/21] Add multiplication support for arbitrary units --- sasdata/quantities/_units_base.py | 22 +++++++++++++++------- sasdata/quantities/units.py | 20 ++++++++++++++------ test/quantities/utest_units.py | 12 ++++++++++++ 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 1a7418127..a9b56e88f 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -337,10 +337,8 @@ def __init__(self, numerator: str | list[str], denominator: None | list[str] = N case _: self._denominator = denominator self._unit = Unit(1, Dimensions()) # Unitless - print("Made") super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) - print("Make more") def _name(self): match (self._numerator, self._denominator): @@ -362,11 +360,21 @@ def __eq__(self, other): def __mul__(self: Self, other: "Unit"): - # if isinstance(other, Unit): - # return Unit(self.scale * other.scale, self.dimensions * other.dimensions) - # elif isinstance(other, (int, float)): - # return Unit(other * self.scale, self.dimensions) - return NotImplemented + print(f"Calling mul on {self} and {other}") + match other: + case ArbitraryUnit(): + result = ArbitraryUnit(self._numerator + other._numerator, self._denominator + other._denominator) + result._unit *= other._unit + return result + 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"): # if isinstance(other, Unit): diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index 7fbf9750d..465735441 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -422,10 +422,8 @@ def __init__(self, numerator: str | list[str], denominator: None | list[str] = N case _: self._denominator = denominator self._unit = Unit(1, Dimensions()) # Unitless - print("Made") super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) - print("Make more") def _name(self): match (self._numerator, self._denominator): @@ -447,6 +445,20 @@ def __eq__(self, other): def __mul__(self: Self, other: "Unit"): + print(f"Calling mul on {self} and {other}") + match other: + case ArbitraryUnit(): + result = ArbitraryUnit(self._numerator + other._numerator, self._denominator + other._denominator) + result._unit *= other._unit + return result + case NamedUnit() | Unit() | int() | float(): + result = ArbitraryUnit(self._numerator, self._denominator) + result._unit *= other + return result + case _: + return NotImplemented + + # if isinstance(other, Unit): # return Unit(self.scale * other.scale, self.dimensions * other.dimensions) # elif isinstance(other, (int, float)): @@ -483,10 +495,6 @@ def equivalent(self: Self, other: "Unit"): case _: return False - def __eq__(self: Self, other: "Unit"): - """FIXME: TODO""" - return self.equivalent(other) and np.abs(np.log(self.scale/other.scale)) < 1e-5 - def si_equivalent(self): """ Get the SI unit corresponding to this unit""" """FIXME: TODO""" diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index 33d474721..050e5ff2d 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -14,6 +14,14 @@ 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"]), + ], } @@ -51,6 +59,10 @@ def test_unit_equivalent(equivalent_term): 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"]), + ], } From 2eebf5cdd269eb837fec45096dc62e64aa5f598e Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Thu, 19 Feb 2026 15:48:51 +0000 Subject: [PATCH 04/21] Add power support for arbitrary units --- sasdata/quantities/_units_base.py | 13 ++++++++----- sasdata/quantities/units.py | 23 +++++++++++------------ test/quantities/utest_units.py | 6 ++++++ 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index a9b56e88f..13c56364e 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -392,11 +392,14 @@ def __rtruediv__(self: Self, other: "Unit"): # else: return NotImplemented - def __pow__(self, power: int | float): - # if not isinstance(power, int | float): - # return NotImplemented - - return Unit(self.scale**power, self.dimensions**power) + def __pow__(self, power: int): + match power: + case int(): + result = ArbitraryUnit(sorted(self._numerator * power), sorted(self._denominator * power)) + result._unit = self._unit ** power + return result + case _: + return NotImplemented def equivalent(self: Self, other: "Unit"): diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index 465735441..936bd70e7 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -457,13 +457,9 @@ def __mul__(self: Self, other: "Unit"): return result case _: return NotImplemented - - - # if isinstance(other, Unit): - # return Unit(self.scale * other.scale, self.dimensions * other.dimensions) - # elif isinstance(other, (int, float)): - # return Unit(other * self.scale, self.dimensions) - return NotImplemented + + def __rmul__(self: Self, other): + return self * other def __truediv__(self: Self, other: "Unit"): # if isinstance(other, Unit): @@ -481,11 +477,14 @@ def __rtruediv__(self: Self, other: "Unit"): # else: return NotImplemented - def __pow__(self, power: int | float): - # if not isinstance(power, int | float): - # return NotImplemented - - return Unit(self.scale**power, self.dimensions**power) + def __pow__(self, power: int): + match power: + case int(): + result = ArbitraryUnit(sorted(self._numerator * power), sorted(self._denominator * power)) + result._unit = self._unit ** power + return result + case _: + return NotImplemented def equivalent(self: Self, other: "Unit"): diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index 050e5ff2d..ec3b4ad0d 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -22,6 +22,10 @@ ArbitraryUnit("Pizzas") * units.meters, units.meters * ArbitraryUnit(["Pizzas"]), ], + "Arbitrary Power": [ + ArbitraryUnit(["Slices"], denominator=["Pizza"]) * ArbitraryUnit(["Slices"], denominator=["Pizza"]), + ArbitraryUnit(["Slices"], denominator=["Pizza"]) ** 2, + ], } @@ -33,6 +37,8 @@ def equal_term(request): def test_unit_equality(equal_term): for i, unit_1 in enumerate(equal_term): for unit_2 in equal_term[i + 1 :]: + print(f"A: {unit_1}") + print(f"B: {unit_2}") assert unit_1.equivalent(unit_2), "Units should be equivalent" assert unit_1 == unit_2, "Units should be equal" From 926a305d2c51b7f9e5e7890ee8e50e229484d2d4 Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Thu, 19 Feb 2026 15:57:51 +0000 Subject: [PATCH 05/21] Refactor arbitrary unit representations --- sasdata/quantities/_units_base.py | 59 ++++++++++++++++++++++++------- sasdata/quantities/units.py | 59 ++++++++++++++++++++++++------- 2 files changed, 94 insertions(+), 24 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 13c56364e..651894d33 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -325,28 +325,50 @@ class ArbitraryUnit(NamedUnit): 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], denominator: None | list[str] = None): + def __init__(self, + numerator: str | list[str] | dict[str, int], + denominator: None | list[str] | dict[str, int]= None): match numerator: - case str(name): - self._numerator = [name] - case _: + 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 _: + 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 = Unit(1, Dimensions()) # Unitless super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) def _name(self): match (self._numerator, self._denominator): - case ([], []): + case ({}, {}): return "" - case (_, []): + case (_, {}): return " ".join(self._numerator) - case ([], _): + case ({}, _): return "1 / " + " ".join(self._denominator) case _: return " ".join(self._numerator) + " / " + " ".join(self._denominator) @@ -360,10 +382,21 @@ def __eq__(self, other): def __mul__(self: Self, other: "Unit"): - print(f"Calling mul on {self} and {other}") match other: case ArbitraryUnit(): - result = ArbitraryUnit(self._numerator + other._numerator, self._denominator + other._denominator) + 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 case NamedUnit() | Unit() | int() | float(): @@ -395,7 +428,9 @@ def __rtruediv__(self: Self, other: "Unit"): def __pow__(self, power: int): match power: case int(): - result = ArbitraryUnit(sorted(self._numerator * power), sorted(self._denominator * power)) + 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 _: diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index 936bd70e7..cd1517544 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -410,28 +410,50 @@ class ArbitraryUnit(NamedUnit): 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], denominator: None | list[str] = None): + def __init__(self, + numerator: str | list[str] | dict[str, int], + denominator: None | list[str] | dict[str, int]= None): match numerator: - case str(name): - self._numerator = [name] - case _: + 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 _: + 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 = Unit(1, Dimensions()) # Unitless super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) def _name(self): match (self._numerator, self._denominator): - case ([], []): + case ({}, {}): return "" - case (_, []): + case (_, {}): return " ".join(self._numerator) - case ([], _): + case ({}, _): return "1 / " + " ".join(self._denominator) case _: return " ".join(self._numerator) + " / " + " ".join(self._denominator) @@ -445,10 +467,21 @@ def __eq__(self, other): def __mul__(self: Self, other: "Unit"): - print(f"Calling mul on {self} and {other}") match other: case ArbitraryUnit(): - result = ArbitraryUnit(self._numerator + other._numerator, self._denominator + other._denominator) + 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 case NamedUnit() | Unit() | int() | float(): @@ -480,7 +513,9 @@ def __rtruediv__(self: Self, other: "Unit"): def __pow__(self, power: int): match power: case int(): - result = ArbitraryUnit(sorted(self._numerator * power), sorted(self._denominator * power)) + 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 _: From c12ac246b76f64d6fc81b093877cfb2f53f85c92 Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Thu, 19 Feb 2026 16:09:31 +0000 Subject: [PATCH 06/21] Enable arbitrary division --- sasdata/quantities/_units_base.py | 31 ++++++++++++++++++++++++------- sasdata/quantities/units.py | 31 ++++++++++++++++++++++++------- test/quantities/utest_units.py | 14 ++++++++++++-- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 651894d33..8163475d9 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -410,12 +410,29 @@ def __rmul__(self: Self, other): return self * other def __truediv__(self: Self, other: "Unit"): - # if isinstance(other, Unit): - # return Unit(self.scale / other.scale, self.dimensions / other.dimensions) - # elif isinstance(other, (int, float)): - # return Unit(self.scale / other, self.dimensions) - # else: - return NotImplemented + 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 + 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"): # if isinstance(other, Unit): @@ -427,7 +444,7 @@ def __rtruediv__(self: Self, other: "Unit"): def __pow__(self, power: int): match power: - case int(): + 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) diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index cd1517544..4c2d8250e 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -495,12 +495,29 @@ def __rmul__(self: Self, other): return self * other def __truediv__(self: Self, other: "Unit"): - # if isinstance(other, Unit): - # return Unit(self.scale / other.scale, self.dimensions / other.dimensions) - # elif isinstance(other, (int, float)): - # return Unit(self.scale / other, self.dimensions) - # else: - return NotImplemented + 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 + 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"): # if isinstance(other, Unit): @@ -512,7 +529,7 @@ def __rtruediv__(self: Self, other: "Unit"): def __pow__(self, power: int): match power: - case int(): + 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) diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index ec3b4ad0d..df4147911 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -26,6 +26,14 @@ 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"]), + ], } @@ -37,8 +45,10 @@ def equal_term(request): def test_unit_equality(equal_term): for i, unit_1 in enumerate(equal_term): for unit_2 in equal_term[i + 1 :]: - print(f"A: {unit_1}") - print(f"B: {unit_2}") + if type(unit_1) is ArbitraryUnit: + print(f"A: {unit_1._numerator} / {unit_1._denominator}") + if type(unit_2) is ArbitraryUnit: + print(f"B: {unit_2._numerator} / {unit_2._denominator}") assert unit_1.equivalent(unit_2), "Units should be equivalent" assert unit_1 == unit_2, "Units should be equal" From 5707a2e566a6d2599690caeecb47e9ad6c22b789 Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Thu, 19 Feb 2026 16:23:57 +0000 Subject: [PATCH 07/21] Rework display of arbitrary units --- sasdata/quantities/_units_base.py | 39 +++++++++++++++++++++---------- sasdata/quantities/units.py | 39 +++++++++++++++++++++---------- test/quantities/utest_units.py | 19 +++++++++++++++ 3 files changed, 73 insertions(+), 24 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 8163475d9..0c506bdc3 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -358,20 +358,34 @@ def __init__(self, self._denominator = denominator case _: raise TypeError - self._unit = Unit(1, Dimensions()) # Unitless + self._unit = NamedUnit(1, Dimensions(), "") # Unitless super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) def _name(self): - match (self._numerator, self._denominator): - case ({}, {}): + 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(self._numerator) - case ({}, _): - return "1 / " + " ".join(self._denominator) + case (_, []): + return " ".join(num) + case ([], _): + return "1 / " + " ".join(den) case _: - return " ".join(self._numerator) + " / " + " ".join(self._denominator) + return " ".join(num) + " / " + " ".join(den) def __eq__(self, other): match other: @@ -471,11 +485,12 @@ def _format_unit(self, format_process: list["UnitFormatProcessor"]): for processor in format_process: pass - def __repr__(self): - """FIXME: TODO""" + def __str__(self): result = self._name() - if self._unit.__repr__(): - result += f" {self._unit.__repr__()}" + 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 @staticmethod diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index 4c2d8250e..d5ae6e6ff 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -443,20 +443,34 @@ def __init__(self, self._denominator = denominator case _: raise TypeError - self._unit = Unit(1, Dimensions()) # Unitless + self._unit = NamedUnit(1, Dimensions(), "") # Unitless super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) def _name(self): - match (self._numerator, self._denominator): - case ({}, {}): + 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(self._numerator) - case ({}, _): - return "1 / " + " ".join(self._denominator) + case (_, []): + return " ".join(num) + case ([], _): + return "1 / " + " ".join(den) case _: - return " ".join(self._numerator) + " / " + " ".join(self._denominator) + return " ".join(num) + " / " + " ".join(den) def __eq__(self, other): match other: @@ -556,11 +570,12 @@ def _format_unit(self, format_process: list["UnitFormatProcessor"]): for processor in format_process: pass - def __repr__(self): - """FIXME: TODO""" + def __str__(self): result = self._name() - if self._unit.__repr__(): - result += f" {self._unit.__repr__()}" + 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 @staticmethod diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index df4147911..5ad29a95d 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -93,3 +93,22 @@ def test_unit_dissimilar(dissimilar_term): for unit_2 in units[i + 1 :]: print(unit_1, unit_2) 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((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 From b9df2ecdeec9f60e2acbae25e77a130fd53a5a1a Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Thu, 19 Feb 2026 16:52:37 +0000 Subject: [PATCH 08/21] Properly reduce terms and add rdiv for arbitrary units --- sasdata/quantities/_units_base.py | 51 ++++++++++++++++++++++++++----- sasdata/quantities/units.py | 51 ++++++++++++++++++++++++++----- test/quantities/utest_units.py | 9 ++++++ 3 files changed, 95 insertions(+), 16 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 0c506bdc3..cbb223d90 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -412,7 +412,7 @@ def __mul__(self: Self, other: "Unit"): den[key] = other._denominator[key] result = ArbitraryUnit(num, den) result._unit *= other._unit - return result + return result._reduce() case NamedUnit() | Unit() | int() | float(): result = ArbitraryUnit(self._numerator, self._denominator) result._unit *= other @@ -440,7 +440,7 @@ def __truediv__(self: Self, other: "Unit"): den[key] = other._numerator[key] result = ArbitraryUnit(num, den) result._unit /= other._unit - return result + return result._reduce() case NamedUnit() | Unit() | int() | float(): result = ArbitraryUnit(self._numerator, self._denominator) result._unit /= other @@ -449,12 +449,29 @@ def __truediv__(self: Self, other: "Unit"): return NotImplemented def __rtruediv__(self: Self, other: "Unit"): - # if isinstance(other, Unit): - # return Unit(other.scale / self.scale, other.dimensions / self.dimensions) - # elif isinstance(other, (int, float)): - # return Unit(other / self.scale, self.dimensions ** -1) - # else: - return NotImplemented + 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: @@ -485,6 +502,21 @@ def _format_unit(self, format_process: list["UnitFormatProcessor"]): 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(): @@ -493,6 +525,9 @@ def __str__(self): result += f" {str(self._unit).strip()}" return result + def __repr__(self): + return str(self) + @staticmethod def parse(unit_string: str) -> "Unit": """FIXME: TODO""" diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index d5ae6e6ff..0741b864a 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -497,7 +497,7 @@ def __mul__(self: Self, other: "Unit"): den[key] = other._denominator[key] result = ArbitraryUnit(num, den) result._unit *= other._unit - return result + return result._reduce() case NamedUnit() | Unit() | int() | float(): result = ArbitraryUnit(self._numerator, self._denominator) result._unit *= other @@ -525,7 +525,7 @@ def __truediv__(self: Self, other: "Unit"): den[key] = other._numerator[key] result = ArbitraryUnit(num, den) result._unit /= other._unit - return result + return result._reduce() case NamedUnit() | Unit() | int() | float(): result = ArbitraryUnit(self._numerator, self._denominator) result._unit /= other @@ -534,12 +534,29 @@ def __truediv__(self: Self, other: "Unit"): return NotImplemented def __rtruediv__(self: Self, other: "Unit"): - # if isinstance(other, Unit): - # return Unit(other.scale / self.scale, other.dimensions / self.dimensions) - # elif isinstance(other, (int, float)): - # return Unit(other / self.scale, self.dimensions ** -1) - # else: - return NotImplemented + 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: @@ -570,6 +587,21 @@ def _format_unit(self, format_process: list["UnitFormatProcessor"]): 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(): @@ -578,6 +610,9 @@ def __str__(self): result += f" {str(self._unit).strip()}" return result + def __repr__(self): + return str(self) + @staticmethod def parse(unit_string: str) -> "Unit": """FIXME: TODO""" diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index 5ad29a95d..bbcce09da 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -33,6 +33,14 @@ "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"), ], } @@ -108,6 +116,7 @@ def test_unit_names(): 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" From 416253eda410fb1d5f4f4eb010115440bb8c864c Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Fri, 20 Feb 2026 11:49:04 +0000 Subject: [PATCH 09/21] Remove unneeded function stubs --- sasdata/quantities/_units_base.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index cbb223d90..095cad754 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -492,16 +492,6 @@ def equivalent(self: Self, other: "Unit"): 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: @@ -528,17 +518,6 @@ def __str__(self): 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? -# -# - - @dataclass class ProcessedUnitToken: From 86aa707e19a8dd7d9d3aeac2f5dbb4692c352ee0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:57:10 +0000 Subject: [PATCH 10/21] [pre-commit.ci lite] apply automatic fixes for ruff linting errors --- sasdata/quantities/_units_base.py | 4 ++-- sasdata/quantities/units.py | 4 ++-- test/quantities/utest_units.py | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 095cad754..918126a63 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -501,10 +501,10 @@ def _reduce(self): 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] + 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] + del self._denominator[k] return self def __str__(self): diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index 0741b864a..6b6ef194e 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -596,10 +596,10 @@ def _reduce(self): 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] + 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] + del self._denominator[k] return self def __str__(self): diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index bbcce09da..f9ba2c435 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -1,10 +1,10 @@ import math + import pytest 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], @@ -113,11 +113,11 @@ def test_unit_names(): assert str(empty) == "" assert str(pizza) == "Pizza" - assert str((pizza * pineapple)) == "Pineapple Pizza" - assert str((pizza * pizza)) == "Pizza^2" + 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(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 + assert str(pie**0.5) == "Pie^0.5" # A valid unit, because pie are square From b4d3df356b1bd83b349b2593b3834940f5526e50 Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Fri, 20 Feb 2026 11:49:04 +0000 Subject: [PATCH 11/21] Fix windows unicode printing issue in test --- test/quantities/utest_units.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index f9ba2c435..95bf4c3ca 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -53,10 +53,6 @@ def equal_term(request): def test_unit_equality(equal_term): for i, unit_1 in enumerate(equal_term): for unit_2 in equal_term[i + 1 :]: - if type(unit_1) is ArbitraryUnit: - print(f"A: {unit_1._numerator} / {unit_1._denominator}") - if type(unit_2) is ArbitraryUnit: - print(f"B: {unit_2._numerator} / {unit_2._denominator}") assert unit_1.equivalent(unit_2), "Units should be equivalent" assert unit_1 == unit_2, "Units should be equal" @@ -75,7 +71,6 @@ def test_unit_equivalent(equivalent_term): units = equivalent_term for i, unit_1 in enumerate(units): for unit_2 in units[i + 1 :]: - print(unit_1, unit_2) assert unit_1.equivalent(unit_2), "Units should be equivalent" assert unit_1 != unit_2, "Units not should be equal" @@ -99,7 +94,6 @@ def test_unit_dissimilar(dissimilar_term): units = dissimilar_term for i, unit_1 in enumerate(units): for unit_2 in units[i + 1 :]: - print(unit_1, unit_2) assert not unit_1.equivalent(unit_2), "Units should not be equivalent" From dae83465529d0a8e32076d723cef24c116881dce Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Tue, 3 Mar 2026 11:27:44 +0000 Subject: [PATCH 12/21] Switch name to UnknownUnit This is clearer than ArbitraryUnit, which might be confused for uncalibrated scattering data. --- sasdata/quantities/_units_base.py | 26 +++++------ sasdata/quantities/units.py | 47 ++++++-------------- test/quantities/utest_units.py | 72 +++++++++++++++---------------- 3 files changed, 62 insertions(+), 83 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 918126a63..cc274c100 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -317,7 +317,7 @@ def startswith(self, prefix: str) -> bool: or (self.symbol is not None and self.symbol.lower().startswith(prefix)) -class ArbitraryUnit(NamedUnit): +class UnknownUnit(NamedUnit): """A unit for an unknown quantity While this library attempts to handle all known SI units, it is @@ -389,7 +389,7 @@ def _name(self): def __eq__(self, other): match other: - case ArbitraryUnit(): + case UnknownUnit(): 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 @@ -397,7 +397,7 @@ def __eq__(self, other): def __mul__(self: Self, other: "Unit"): match other: - case ArbitraryUnit(): + case UnknownUnit(): num = dict(self._numerator) for key in other._numerator: if key in num: @@ -410,11 +410,11 @@ def __mul__(self: Self, other: "Unit"): den[key] += other._denominator[key] else: den[key] = other._denominator[key] - result = ArbitraryUnit(num, den) + result = UnknownUnit(num, den) result._unit *= other._unit return result._reduce() case NamedUnit() | Unit() | int() | float(): - result = ArbitraryUnit(self._numerator, self._denominator) + result = UnknownUnit(self._numerator, self._denominator) result._unit *= other return result case _: @@ -425,7 +425,7 @@ def __rmul__(self: Self, other): def __truediv__(self: Self, other: "Unit"): match other: - case ArbitraryUnit(): + case UnknownUnit(): num = dict(self._numerator) for key in other._denominator: if key in num: @@ -438,11 +438,11 @@ def __truediv__(self: Self, other: "Unit"): den[key] += other._numerator[key] else: den[key] = other._numerator[key] - result = ArbitraryUnit(num, den) + result = UnknownUnit(num, den) result._unit /= other._unit return result._reduce() case NamedUnit() | Unit() | int() | float(): - result = ArbitraryUnit(self._numerator, self._denominator) + result = UnknownUnit(self._numerator, self._denominator) result._unit /= other return result case _: @@ -450,7 +450,7 @@ def __truediv__(self: Self, other: "Unit"): def __rtruediv__(self: Self, other: "Unit"): match other: - case ArbitraryUnit(): + case UnknownUnit(): num = dict(other._numerator) for key in self._denominator: if key in num: @@ -463,11 +463,11 @@ def __rtruediv__(self: Self, other: "Unit"): den[key] += self._numerator[key] else: den[key] = self._numerator[key] - result = ArbitraryUnit(num, den) + result = UnknownUnit(num, den) result._unit = other._unit / self._unit return result._reduce() case NamedUnit() | Unit() | int() | float(): - result = ArbitraryUnit(self._denominator, self._numerator) + result = UnknownUnit(self._denominator, self._numerator) result._unit = other / result._unit return result case _: @@ -478,7 +478,7 @@ def __pow__(self, power: int): 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 = UnknownUnit(num, den) result._unit = self._unit ** power return result case _: @@ -487,7 +487,7 @@ def __pow__(self, power: int): def equivalent(self: Self, other: "Unit"): match other: - case ArbitraryUnit(): + case UnknownUnit(): return self._unit.equivalent(other._unit) and sorted(self._numerator) == sorted(other._numerator) and sorted(self._denominator) == sorted(other._denominator) case _: return False diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index 6b6ef194e..d8c0ea217 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -402,7 +402,7 @@ def startswith(self, prefix: str) -> bool: or (self.symbol is not None and self.symbol.lower().startswith(prefix)) -class ArbitraryUnit(NamedUnit): +class UnknownUnit(NamedUnit): """A unit for an unknown quantity While this library attempts to handle all known SI units, it is @@ -474,7 +474,7 @@ def _name(self): def __eq__(self, other): match other: - case ArbitraryUnit(): + case UnknownUnit(): 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 @@ -482,7 +482,7 @@ def __eq__(self, other): def __mul__(self: Self, other: "Unit"): match other: - case ArbitraryUnit(): + case UnknownUnit(): num = dict(self._numerator) for key in other._numerator: if key in num: @@ -495,11 +495,11 @@ def __mul__(self: Self, other: "Unit"): den[key] += other._denominator[key] else: den[key] = other._denominator[key] - result = ArbitraryUnit(num, den) + result = UnknownUnit(num, den) result._unit *= other._unit return result._reduce() case NamedUnit() | Unit() | int() | float(): - result = ArbitraryUnit(self._numerator, self._denominator) + result = UnknownUnit(self._numerator, self._denominator) result._unit *= other return result case _: @@ -510,7 +510,7 @@ def __rmul__(self: Self, other): def __truediv__(self: Self, other: "Unit"): match other: - case ArbitraryUnit(): + case UnknownUnit(): num = dict(self._numerator) for key in other._denominator: if key in num: @@ -523,11 +523,11 @@ def __truediv__(self: Self, other: "Unit"): den[key] += other._numerator[key] else: den[key] = other._numerator[key] - result = ArbitraryUnit(num, den) + result = UnknownUnit(num, den) result._unit /= other._unit return result._reduce() case NamedUnit() | Unit() | int() | float(): - result = ArbitraryUnit(self._numerator, self._denominator) + result = UnknownUnit(self._numerator, self._denominator) result._unit /= other return result case _: @@ -535,7 +535,7 @@ def __truediv__(self: Self, other: "Unit"): def __rtruediv__(self: Self, other: "Unit"): match other: - case ArbitraryUnit(): + case UnknownUnit(): num = dict(other._numerator) for key in self._denominator: if key in num: @@ -548,11 +548,11 @@ def __rtruediv__(self: Self, other: "Unit"): den[key] += self._numerator[key] else: den[key] = self._numerator[key] - result = ArbitraryUnit(num, den) + result = UnknownUnit(num, den) result._unit = other._unit / self._unit return result._reduce() case NamedUnit() | Unit() | int() | float(): - result = ArbitraryUnit(self._denominator, self._numerator) + result = UnknownUnit(self._denominator, self._numerator) result._unit = other / result._unit return result case _: @@ -563,7 +563,7 @@ def __pow__(self, power: int): 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 = UnknownUnit(num, den) result._unit = self._unit ** power return result case _: @@ -572,21 +572,11 @@ def __pow__(self, power: int): def equivalent(self: Self, other: "Unit"): match other: - case ArbitraryUnit(): + case UnknownUnit(): 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: @@ -613,17 +603,6 @@ def __str__(self): 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? -# -# - - @dataclass class ProcessedUnitToken: diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index 95bf4c3ca..3cd9eb093 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -3,44 +3,44 @@ import pytest import sasdata.quantities.units as units -from sasdata.quantities.units import ArbitraryUnit +from sasdata.quantities.units import UnknownUnit 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"]), + "Unknown Units": [UnknownUnit("Pizzas"), UnknownUnit(["Pizzas"])], + "Unknown Fractional Units": [ + UnknownUnit("Slices", denominator=["Pizzas"]), + UnknownUnit(["Slices"], denominator=["Pizzas"]), ], - "Arbitrary Multiplication": [ - ArbitraryUnit("Pizzas") * ArbitraryUnit("People"), - ArbitraryUnit(["Pizzas", "People"]), + "Unknown Multiplication": [ + UnknownUnit("Pizzas") * UnknownUnit("People"), + UnknownUnit(["Pizzas", "People"]), ], - "Arbitrary Multiplication with Units": [ - ArbitraryUnit("Pizzas") * units.meters, - units.meters * ArbitraryUnit(["Pizzas"]), + "Unknown Multiplication with Units": [ + UnknownUnit("Pizzas") * units.meters, + units.meters * UnknownUnit(["Pizzas"]), ], - "Arbitrary Power": [ - ArbitraryUnit(["Slices"], denominator=["Pizza"]) * ArbitraryUnit(["Slices"], denominator=["Pizza"]), - ArbitraryUnit(["Slices"], denominator=["Pizza"]) ** 2, + "Unknown Power": [ + UnknownUnit(["Slices"], denominator=["Pizza"]) * UnknownUnit(["Slices"], denominator=["Pizza"]), + UnknownUnit(["Slices"], denominator=["Pizza"]) ** 2, ], - "Arbitrary Fractional Power": [ - ArbitraryUnit(["Pizza", "Pizza", "Pizza"]), - ArbitraryUnit(["Pizza", "Pizza"]) ** 1.5, + "Unknown Fractional Power": [ + UnknownUnit(["Pizza", "Pizza", "Pizza"]), + UnknownUnit(["Pizza", "Pizza"]) ** 1.5, ], - "Arbitrary Division": [ - ArbitraryUnit("Slices") / ArbitraryUnit("Pizza"), - ArbitraryUnit(["Slices"], denominator=["Pizza"]), - (1 / ArbitraryUnit("Pizza")) * ArbitraryUnit("Slices"), - 1 / (ArbitraryUnit("Pizza") / ArbitraryUnit("Slices")), + "Unknown Division": [ + UnknownUnit("Slices") / UnknownUnit("Pizza"), + UnknownUnit(["Slices"], denominator=["Pizza"]), + (1 / UnknownUnit("Pizza")) * UnknownUnit("Slices"), + 1 / (UnknownUnit("Pizza") / UnknownUnit("Slices")), ], - "Arbitrary Complicated Math": [ - (ArbitraryUnit("Slices") / ArbitraryUnit("Person")) - / (ArbitraryUnit("Slices") / ArbitraryUnit("Pizzas")) - * ArbitraryUnit("Person"), - ArbitraryUnit("Pizzas"), + "Unknown Complicated Math": [ + (UnknownUnit("Slices") / UnknownUnit("Person")) + / (UnknownUnit("Slices") / UnknownUnit("Pizzas")) + * UnknownUnit("Person"), + UnknownUnit("Pizzas"), ], } @@ -77,10 +77,10 @@ def test_unit_equivalent(equivalent_term): 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"]), + "Different Unknown Units": [UnknownUnit("Pizzas"), UnknownUnit(["Donuts"])], + "Unknown Multiplication with Units": [ + UnknownUnit("Pizzas") * units.meters, + units.seconds * UnknownUnit(["Pizzas"]), ], } @@ -98,11 +98,11 @@ def test_unit_dissimilar(dissimilar_term): def test_unit_names(): - pizza = ArbitraryUnit(["Pizza"]) - slice = ArbitraryUnit(["Slice"]) - pineapple = ArbitraryUnit(["Pineapple"]) - pie = ArbitraryUnit(["Pie"]) - empty = ArbitraryUnit([]) + pizza = UnknownUnit(["Pizza"]) + slice = UnknownUnit(["Slice"]) + pineapple = UnknownUnit(["Pineapple"]) + pie = UnknownUnit(["Pie"]) + empty = UnknownUnit([]) assert str(empty) == "" From 03178cfae8bcf34713f60d161a78150a55b3fdd9 Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Tue, 3 Mar 2026 11:44:09 +0000 Subject: [PATCH 13/21] Minor format cleanup of _units_base.py --- sasdata/quantities/_units_base.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index cc274c100..e7cf202e8 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -308,13 +308,12 @@ def __eq__(self, other): case _: return False - def startswith(self, prefix: str) -> bool: """Check if any representation of the unit begins with the prefix string""" prefix = prefix.lower() return (self.name is not None and self.name.lower().startswith(prefix)) \ - 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)) + 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 UnknownUnit(NamedUnit): @@ -394,7 +393,6 @@ def __eq__(self, other): case Unit(): return not self._numerator and not self._denominator and self._unit == other - def __mul__(self: Self, other: "Unit"): match other: case UnknownUnit(): From 02234e23ba870417bda1e737f85a538a8bc3569e Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Tue, 3 Mar 2026 11:57:26 +0000 Subject: [PATCH 14/21] Check for invalid characters in UnknownUnit Current the only invalid characters are Space, /, and ^. I've also refactored the argument parsing to remove duplication between the numerator and denominator. --- sasdata/quantities/_units_base.py | 71 +++++++++++++++++----------- sasdata/quantities/units.py | 77 ++++++++++++++++++------------- test/quantities/utest_units.py | 19 ++++++-- 3 files changed, 106 insertions(+), 61 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index e7cf202e8..7f0939f12 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from fractions import Fraction from typing import Self +import re import numpy as np from unicode_superscript import int_as_unicode_superscript @@ -326,40 +327,57 @@ class UnknownUnit(NamedUnit): 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: + denominator: None | list[str] | dict[str, int] = None): + if numerator is None: + return TypeError + self._numerator = UnknownUnit._parse_arg(numerator) + self._denominator = UnknownUnit._parse_arg(denominator) + self._unit = NamedUnit(1, Dimensions(), "") # Unitless + + super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) + + @staticmethod + def _parse_arg(arg: str | list[str] | dict[str, int]): + """Parse the different possibilities for constructor arguments + + Both the numerator and the denominator could be a string, a + list of strings, or a dict. Parse any of these values into a + dictionary of names and powers. + + """ + match arg: case None: - self._denominator = {} + return {} case str(): - self._denominator = {denominator: 1} + return {UnknownUnit._valid_name(arg): 1} case list(): - self._denominator = {} - for key in denominator: - if key in self._denominator: - self._denominator[key] += 1 + result = {} + for key in arg: + if key in result: + result[key] += 1 else: - self._denominator[key] = 1 + UnknownUnit._valid_name(key) + result[key] = 1 + return result case dict(): - self._denominator = denominator + for key in arg: + UnknownUnit._valid_name(key) + return arg case _: raise TypeError - self._unit = NamedUnit(1, Dimensions(), "") # Unitless - super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) + @staticmethod + def _valid_name(name: str) -> str: + """Confirms that the name of a unit is appropriate + + This mostly confirms that the unit does not contain math + operators that would act on other units, like / or ^ + """ + + if re.search(r"[*/^\s]", name): + raise RuntimeError(f'Unit name "{name}" contains invalid characters (*, /, ^, or whitespace)') + + return name def _name(self): num = [] @@ -482,7 +500,6 @@ def __pow__(self, power: int): case _: return NotImplemented - def equivalent(self: Self, other: "Unit"): match other: case UnknownUnit(): diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index d8c0ea217..7e0612045 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -86,6 +86,7 @@ from dataclasses import dataclass from fractions import Fraction from typing import Self +import re import numpy as np @@ -393,13 +394,12 @@ def __eq__(self, other): case _: return False - def startswith(self, prefix: str) -> bool: """Check if any representation of the unit begins with the prefix string""" prefix = prefix.lower() return (self.name is not None and self.name.lower().startswith(prefix)) \ - 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)) + 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 UnknownUnit(NamedUnit): @@ -412,40 +412,57 @@ class UnknownUnit(NamedUnit): 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: + denominator: None | list[str] | dict[str, int] = None): + if numerator is None: + return TypeError + self._numerator = UnknownUnit._parse_arg(numerator) + self._denominator = UnknownUnit._parse_arg(denominator) + self._unit = NamedUnit(1, Dimensions(), "") # Unitless + + super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) + + @staticmethod + def _parse_arg(arg: str | list[str] | dict[str, int]): + """Parse the different possibilities for constructor arguments + + Both the numerator and the denominator could be a string, a + list of strings, or a dict. Parse any of these values into a + dictionary of names and powers. + + """ + match arg: case None: - self._denominator = {} + return {} case str(): - self._denominator = {denominator: 1} + return {UnknownUnit._valid_name(arg): 1} case list(): - self._denominator = {} - for key in denominator: - if key in self._denominator: - self._denominator[key] += 1 + result = {} + for key in arg: + if key in result: + result[key] += 1 else: - self._denominator[key] = 1 + UnknownUnit._valid_name(key) + result[key] = 1 + return result case dict(): - self._denominator = denominator + for key in arg: + UnknownUnit._valid_name(key) + return arg case _: raise TypeError - self._unit = NamedUnit(1, Dimensions(), "") # Unitless - super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) + @staticmethod + def _valid_name(name: str) -> str: + """Confirms that the name of a unit is appropriate + + This mostly confirms that the unit does not contain math + operators that would act on other units, like / or ^ + """ + + if re.search(r"[*/^\s]", name): + raise RuntimeError(f'Unit name "{name}" contains invalid characters (*, /, ^, or whitespace)') + + return name def _name(self): num = [] @@ -479,7 +496,6 @@ def __eq__(self, other): case Unit(): return not self._numerator and not self._denominator and self._unit == other - def __mul__(self: Self, other: "Unit"): match other: case UnknownUnit(): @@ -569,7 +585,6 @@ def __pow__(self, power: int): case _: return NotImplemented - def equivalent(self: Self, other: "Unit"): match other: case UnknownUnit(): diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index 3cd9eb093..afdb3e238 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -99,11 +99,24 @@ def test_unit_dissimilar(dissimilar_term): def test_unit_names(): pizza = UnknownUnit(["Pizza"]) - slice = UnknownUnit(["Slice"]) - pineapple = UnknownUnit(["Pineapple"]) - pie = UnknownUnit(["Pie"]) + slice = UnknownUnit("Slice") + pineapple = UnknownUnit("Pineapple") + pie = UnknownUnit("Pie") empty = UnknownUnit([]) + with pytest.raises(RuntimeError): + UnknownUnit("a/b") + with pytest.raises(RuntimeError): + UnknownUnit(["a^b"]) + with pytest.raises(RuntimeError): + UnknownUnit({"a b": 1}) + with pytest.raises(RuntimeError): + UnknownUnit("a", {"a*b": 1}) + with pytest.raises(RuntimeError): + UnknownUnit("a", ["a^b"]) + with pytest.raises(RuntimeError): + UnknownUnit("a", "a/b") + assert str(empty) == "" assert str(pizza) == "Pizza" From caa9c340cb151da973c603261dbf79fdae4a3248 Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Tue, 3 Mar 2026 12:44:01 +0000 Subject: [PATCH 15/21] Add parentheses around denominator of UnknownUnits --- sasdata/quantities/_units_base.py | 8 ++++++-- sasdata/quantities/units.py | 8 ++++++-- test/quantities/utest_units.py | 4 +++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 7f0939f12..676cc42ab 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -399,10 +399,14 @@ def _name(self): return "" case (_, []): return " ".join(num) + case ([], [d]): + return f"1 / {d}" case ([], _): - return "1 / " + " ".join(den) + return "1 / (" + " ".join(den) + ")" + case (_, [d]): + return f"{" ".join(num)} / {d}" case _: - return " ".join(num) + " / " + " ".join(den) + return f"{" ".join(num)} / ({" ".join(den)})" def __eq__(self, other): match other: diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index 7e0612045..cb4c5f07b 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -484,10 +484,14 @@ def _name(self): return "" case (_, []): return " ".join(num) + case ([], [d]): + return f"1 / {d}" case ([], _): - return "1 / " + " ".join(den) + return "1 / (" + " ".join(den) + ")" + case (_, [d]): + return f"{" ".join(num)} / {d}" case _: - return " ".join(num) + " / " + " ".join(den) + return f"{" ".join(num)} / ({" ".join(den)})" def __eq__(self, other): match other: diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index afdb3e238..f173c6757 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -93,7 +93,7 @@ def dissimilar_term(request): def test_unit_dissimilar(dissimilar_term): units = dissimilar_term for i, unit_1 in enumerate(units): - for unit_2 in units[i + 1 :]: + for unit_2 in units[i + 1:]: assert not unit_1.equivalent(unit_2), "Units should not be equivalent" @@ -124,7 +124,9 @@ def test_unit_names(): assert str(pizza * pizza) == "Pizza^2" assert str(1 / pizza) == "1 / Pizza" + assert str(1 / pizza / pineapple) == "1 / (Pineapple Pizza)" assert str(slice / pizza) == "Slice / Pizza" + assert str(slice / pizza / pineapple) == "Slice / (Pineapple 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 From 6f53423e9f2b18a73abacd4df99144a69897ad2a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:49:22 +0000 Subject: [PATCH 16/21] [pre-commit.ci lite] apply automatic fixes for ruff linting errors --- sasdata/quantities/_units_base.py | 2 +- sasdata/quantities/units.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 676cc42ab..9267b8848 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -1,8 +1,8 @@ +import re from collections.abc import Sequence from dataclasses import dataclass from fractions import Fraction from typing import Self -import re import numpy as np from unicode_superscript import int_as_unicode_superscript diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index cb4c5f07b..02987e603 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -82,11 +82,11 @@ # Included from _units_base.py # +import re from collections.abc import Sequence from dataclasses import dataclass from fractions import Fraction from typing import Self -import re import numpy as np From f25acbe585656e1002b1d532b7f26a3580668bbb Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Tue, 3 Mar 2026 13:58:50 +0000 Subject: [PATCH 17/21] Simplify division and serialisation of UnknownUnits --- sasdata/quantities/_units_base.py | 48 ++++++------------------------- sasdata/quantities/units.py | 48 ++++++------------------------- test/quantities/utest_units.py | 10 +++---- 3 files changed, 21 insertions(+), 85 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 9267b8848..852803f41 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -388,25 +388,10 @@ def _name(self): 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}") + den.append(f"{key}^{-value}") num.sort() den.sort() - match (num, den): - case ([], []): - return "" - case (_, []): - return " ".join(num) - case ([], [d]): - return f"1 / {d}" - case ([], _): - return "1 / (" + " ".join(den) + ")" - case (_, [d]): - return f"{" ".join(num)} / {d}" - case _: - return f"{" ".join(num)} / ({" ".join(den)})" + return " ".join(num + den) def __eq__(self, other): match other: @@ -469,35 +454,18 @@ def __truediv__(self: Self, other: "Unit"): return NotImplemented def __rtruediv__(self: Self, other: "Unit"): - match other: - case UnknownUnit(): - 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 = UnknownUnit(num, den) - result._unit = other._unit / self._unit - return result._reduce() - case NamedUnit() | Unit() | int() | float(): - result = UnknownUnit(self._denominator, self._numerator) - result._unit = other / result._unit - return result - case _: - return NotImplemented + return (self/other) ** -1 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()} + if power < 0: + num, den = den, num + num = {k: -v for k,v in num.items()} + den = {k: -v for k,v in den.items()} + result = UnknownUnit(num, den) result._unit = self._unit ** power return result diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index 02987e603..9957082d3 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -473,25 +473,10 @@ def _name(self): 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}") + den.append(f"{key}^{-value}") num.sort() den.sort() - match (num, den): - case ([], []): - return "" - case (_, []): - return " ".join(num) - case ([], [d]): - return f"1 / {d}" - case ([], _): - return "1 / (" + " ".join(den) + ")" - case (_, [d]): - return f"{" ".join(num)} / {d}" - case _: - return f"{" ".join(num)} / ({" ".join(den)})" + return " ".join(num + den) def __eq__(self, other): match other: @@ -554,35 +539,18 @@ def __truediv__(self: Self, other: "Unit"): return NotImplemented def __rtruediv__(self: Self, other: "Unit"): - match other: - case UnknownUnit(): - 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 = UnknownUnit(num, den) - result._unit = other._unit / self._unit - return result._reduce() - case NamedUnit() | Unit() | int() | float(): - result = UnknownUnit(self._denominator, self._numerator) - result._unit = other / result._unit - return result - case _: - return NotImplemented + return (self/other) ** -1 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()} + if power < 0: + num, den = den, num + num = {k: -v for k,v in num.items()} + den = {k: -v for k,v in den.items()} + result = UnknownUnit(num, den) result._unit = self._unit ** power return result diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index f173c6757..4f4496004 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -123,10 +123,10 @@ def test_unit_names(): assert str(pizza * pineapple) == "Pineapple Pizza" assert str(pizza * pizza) == "Pizza^2" - assert str(1 / pizza) == "1 / Pizza" - assert str(1 / pizza / pineapple) == "1 / (Pineapple Pizza)" - assert str(slice / pizza) == "Slice / Pizza" - assert str(slice / pizza / pineapple) == "Slice / (Pineapple Pizza)" - assert str((slice / pizza) ** 2) == "Slice^2 / Pizza^2" + assert str(1 / pizza) == "Pizza^-1" + assert str(1 / pizza / pineapple) == "Pineapple^-1 Pizza^-1" + assert str(slice / pizza) == "Slice Pizza^-1" + assert str(slice / pizza / pineapple) == "Slice Pineapple^-1 Pizza^-1" + assert str((slice / pizza) ** 2) == "Slice^2 Pizza^-2" assert str(pie**0.5) == "Pie^0.5" # A valid unit, because pie are square From 5d241454de4458c612fb1d51cface182edd42226 Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Fri, 6 Mar 2026 12:21:42 +0000 Subject: [PATCH 18/21] Mypy fixup of sasdata/quantities/units.py --- sasdata/quantities/_units_base.py | 84 +++++++------------------------ sasdata/quantities/units.py | 84 +++++++------------------------ 2 files changed, 38 insertions(+), 130 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 852803f41..001f4308d 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -1,11 +1,9 @@ import re -from collections.abc import Sequence -from dataclasses import dataclass from fractions import Fraction from typing import Self import numpy as np -from unicode_superscript import int_as_unicode_superscript +from unicode_superscript import int_as_unicode_superscript # type: ignore[import-untyped] class DimensionError(Exception): @@ -112,15 +110,15 @@ def __pow__(self, power: int | float): (self.moles_hint * numerator) // denominator, (self.angle_hint * numerator) // denominator) - def __eq__(self: Self, other: Self): + def __eq__(self: Self, other: object) -> bool: if isinstance(other, Dimensions): - return (self.length == other.length and - self.time == other.time and - self.mass == other.mass and - self.current == other.current and - self.temperature == other.temperature and - self.moles_hint == other.moles_hint and - self.angle_hint == other.angle_hint) + return (self.length == other.length + and self.time == other.time + and self.mass == other.mass + and self.current == other.current + and self.temperature == other.temperature + and self.moles_hint == other.moles_hint + and self.angle_hint == other.angle_hint) return NotImplemented @@ -211,9 +209,6 @@ def __init__(self, self.scale = si_scaling_factor self.dimensions = dimensions - def _components(self, tokens: Sequence["UnitToken"]): - pass - def __mul__(self: Self, other: "Unit"): if isinstance(other, Unit): return Unit(self.scale * other.scale, self.dimensions * other.dimensions) @@ -247,17 +242,15 @@ def __pow__(self, power: int | float): def equivalent(self: Self, other: "Unit"): return self.dimensions == other.dimensions - def __eq__(self: Self, other: "Unit"): - return self.equivalent(other) and np.abs(np.log(self.scale/other.scale)) < 1e-5 + def __eq__(self: Self, other: object) -> bool: + if isinstance(other, Unit): + return self.equivalent(other) and np.abs(np.log(self.scale/other.scale)) < 1e-5 + return False def si_equivalent(self): """ Get the SI unit corresponding to this unit""" return Unit(1, self.dimensions) - def _format_unit(self, format_process: list["UnitFormatProcessor"]): - for processor in format_process: - pass - def __repr__(self): if self.scale == 1: # We're in SI @@ -266,9 +259,6 @@ def __repr__(self): else: return f"Unit[{self.scale}, {self.dimensions}]" - @staticmethod - def parse(unit_string: str) -> "Unit": - pass class NamedUnit(Unit): """ Units, but they have a name, and a symbol @@ -326,8 +316,8 @@ class UnknownUnit(NamedUnit): 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): + numerator: str | list[str] | dict[str, int | float], + denominator: None | list[str] | dict[str, int | float] = None): if numerator is None: return TypeError self._numerator = UnknownUnit._parse_arg(numerator) @@ -337,7 +327,7 @@ def __init__(self, super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) @staticmethod - def _parse_arg(arg: str | list[str] | dict[str, int]): + def _parse_arg(arg: str | list[str] | dict[str, int | float] | None) -> dict[str, int | float]: """Parse the different possibilities for constructor arguments Both the numerator and the denominator could be a string, a @@ -351,7 +341,7 @@ def _parse_arg(arg: str | list[str] | dict[str, int]): case str(): return {UnknownUnit._valid_name(arg): 1} case list(): - result = {} + result: dict[str, int | float] = {} for key in arg: if key in result: result[key] += 1 @@ -453,10 +443,10 @@ def __truediv__(self: Self, other: "Unit"): case _: return NotImplemented - def __rtruediv__(self: Self, other: "Unit"): + def __rtruediv__(self: Self, other: "Unit") -> "Unit": return (self/other) ** -1 - def __pow__(self, power: int): + def __pow__(self, power: int | float) -> "Unit": match power: case int() | float(): num = {key: value * power for key, value in self._numerator.items()} @@ -506,42 +496,6 @@ def __repr__(self): return str(self) -@dataclass -class ProcessedUnitToken: - """ Mid processing representation of formatted units """ - base_string: str - exponent_string: str - latex_exponent_string: str - exponent: int - -class UnitFormatProcessor: - """ Represents a step in the unit processing pipeline""" - def apply(self, scale, dimensions) -> tuple[ProcessedUnitToken, float, Dimensions]: - """ This will be called to deal with each processing stage""" - -class RequiredUnitFormatProcessor(UnitFormatProcessor): - """ This unit is required to exist in the formatting """ - def __init__(self, unit: Unit, power: int = 1): - self.unit = unit - self.power = power - def apply(self, scale, dimensions) -> tuple[float, Dimensions, ProcessedUnitToken]: - new_scale = scale / (self.unit.scale * self.power) - new_dimensions = self.unit.dimensions / (dimensions**self.power) - token = ProcessedUnitToken(self.unit, self.power) - - return new_scale, new_dimensions, token -class GreedyAbsDimensionUnitFormatProcessor(UnitFormatProcessor): - """ This processor minimises the dimensionality of the unit by multiplying by as many - units of the specified type as needed """ - def __init__(self, unit: Unit): - self.unit = unit - - def apply(self, scale, dimensions) -> tuple[ProcessedUnitToken, float, Dimensions]: - pass - -class GreedyAbsDimensionUnitFormatProcessor(UnitFormatProcessor): - pass - class UnitGroup: """ A group of units that all have the same dimensionality """ def __init__(self, name: str, units: list[NamedUnit]): diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index 9957082d3..6a09cb98a 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -83,14 +83,12 @@ # import re -from collections.abc import Sequence -from dataclasses import dataclass from fractions import Fraction from typing import Self import numpy as np -from sasdata.quantities.unicode_superscript import int_as_unicode_superscript +from sasdata.quantities.unicode_superscript import int_as_unicode_superscript # type: ignore[import-untyped] class DimensionError(Exception): @@ -197,15 +195,15 @@ def __pow__(self, power: int | float): (self.moles_hint * numerator) // denominator, (self.angle_hint * numerator) // denominator) - def __eq__(self: Self, other: Self): + def __eq__(self: Self, other: object) -> bool: if isinstance(other, Dimensions): - return (self.length == other.length and - self.time == other.time and - self.mass == other.mass and - self.current == other.current and - self.temperature == other.temperature and - self.moles_hint == other.moles_hint and - self.angle_hint == other.angle_hint) + return (self.length == other.length + and self.time == other.time + and self.mass == other.mass + and self.current == other.current + and self.temperature == other.temperature + and self.moles_hint == other.moles_hint + and self.angle_hint == other.angle_hint) return NotImplemented @@ -296,9 +294,6 @@ def __init__(self, self.scale = si_scaling_factor self.dimensions = dimensions - def _components(self, tokens: Sequence["UnitToken"]): - pass - def __mul__(self: Self, other: "Unit"): if isinstance(other, Unit): return Unit(self.scale * other.scale, self.dimensions * other.dimensions) @@ -332,17 +327,15 @@ def __pow__(self, power: int | float): def equivalent(self: Self, other: "Unit"): return self.dimensions == other.dimensions - def __eq__(self: Self, other: "Unit"): - return self.equivalent(other) and np.abs(np.log(self.scale/other.scale)) < 1e-5 + def __eq__(self: Self, other: object) -> bool: + if isinstance(other, Unit): + return self.equivalent(other) and np.abs(np.log(self.scale/other.scale)) < 1e-5 + return False def si_equivalent(self): """ Get the SI unit corresponding to this unit""" return Unit(1, self.dimensions) - def _format_unit(self, format_process: list["UnitFormatProcessor"]): - for processor in format_process: - pass - def __repr__(self): if self.scale == 1: # We're in SI @@ -351,9 +344,6 @@ def __repr__(self): else: return f"Unit[{self.scale}, {self.dimensions}]" - @staticmethod - def parse(unit_string: str) -> "Unit": - pass class NamedUnit(Unit): """ Units, but they have a name, and a symbol @@ -411,8 +401,8 @@ class UnknownUnit(NamedUnit): 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): + numerator: str | list[str] | dict[str, int | float], + denominator: None | list[str] | dict[str, int | float] = None): if numerator is None: return TypeError self._numerator = UnknownUnit._parse_arg(numerator) @@ -422,7 +412,7 @@ def __init__(self, super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name()) @staticmethod - def _parse_arg(arg: str | list[str] | dict[str, int]): + def _parse_arg(arg: str | list[str] | dict[str, int | float] | None) -> dict[str, int | float]: """Parse the different possibilities for constructor arguments Both the numerator and the denominator could be a string, a @@ -436,7 +426,7 @@ def _parse_arg(arg: str | list[str] | dict[str, int]): case str(): return {UnknownUnit._valid_name(arg): 1} case list(): - result = {} + result: dict[str, int | float] = {} for key in arg: if key in result: result[key] += 1 @@ -538,10 +528,10 @@ def __truediv__(self: Self, other: "Unit"): case _: return NotImplemented - def __rtruediv__(self: Self, other: "Unit"): + def __rtruediv__(self: Self, other: "Unit") -> "Unit": return (self/other) ** -1 - def __pow__(self, power: int): + def __pow__(self, power: int | float) -> "Unit": match power: case int() | float(): num = {key: value * power for key, value in self._numerator.items()} @@ -591,42 +581,6 @@ def __repr__(self): return str(self) -@dataclass -class ProcessedUnitToken: - """ Mid processing representation of formatted units """ - base_string: str - exponent_string: str - latex_exponent_string: str - exponent: int - -class UnitFormatProcessor: - """ Represents a step in the unit processing pipeline""" - def apply(self, scale, dimensions) -> tuple[ProcessedUnitToken, float, Dimensions]: - """ This will be called to deal with each processing stage""" - -class RequiredUnitFormatProcessor(UnitFormatProcessor): - """ This unit is required to exist in the formatting """ - def __init__(self, unit: Unit, power: int = 1): - self.unit = unit - self.power = power - def apply(self, scale, dimensions) -> tuple[float, Dimensions, ProcessedUnitToken]: - new_scale = scale / (self.unit.scale * self.power) - new_dimensions = self.unit.dimensions / (dimensions**self.power) - token = ProcessedUnitToken(self.unit, self.power) - - return new_scale, new_dimensions, token -class GreedyAbsDimensionUnitFormatProcessor(UnitFormatProcessor): - """ This processor minimises the dimensionality of the unit by multiplying by as many - units of the specified type as needed """ - def __init__(self, unit: Unit): - self.unit = unit - - def apply(self, scale, dimensions) -> tuple[ProcessedUnitToken, float, Dimensions]: - pass - -class GreedyAbsDimensionUnitFormatProcessor(UnitFormatProcessor): - pass - class UnitGroup: """ A group of units that all have the same dimensionality """ def __init__(self, name: str, units: list[NamedUnit]): From 59bd2b16e784f539243e0e18cbbe0e6f09e476f3 Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Mon, 9 Mar 2026 10:08:16 +0000 Subject: [PATCH 19/21] Rename test_unit_names to test_unit_operations --- test/quantities/utest_units.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/quantities/utest_units.py b/test/quantities/utest_units.py index 4f4496004..c0d11b81a 100644 --- a/test/quantities/utest_units.py +++ b/test/quantities/utest_units.py @@ -97,7 +97,7 @@ def test_unit_dissimilar(dissimilar_term): assert not unit_1.equivalent(unit_2), "Units should not be equivalent" -def test_unit_names(): +def test_unit_operations(): pizza = UnknownUnit(["Pizza"]) slice = UnknownUnit("Slice") pineapple = UnknownUnit("Pineapple") From fa7b2c39d4f4de4c39f6464b3d5de5c7fe5060c2 Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Tue, 17 Mar 2026 11:09:38 +0000 Subject: [PATCH 20/21] Add default case for __eq__ in UnknownUnit --- sasdata/quantities/_units_base.py | 2 ++ sasdata/quantities/units.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 001f4308d..2ec47c69e 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -389,6 +389,8 @@ def __eq__(self, other): 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 + case _: + return False def __mul__(self: Self, other: "Unit"): match other: diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index 6a09cb98a..9835555b8 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -474,6 +474,8 @@ def __eq__(self, other): 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 + case _: + return False def __mul__(self: Self, other: "Unit"): match other: From 7f9de9656ca7c9429e022fc7f536d11e698e228a Mon Sep 17 00:00:00 2001 From: Adam Washington Date: Tue, 17 Mar 2026 11:11:11 +0000 Subject: [PATCH 21/21] Fix return types for division of UnknownUnit --- sasdata/quantities/_units_base.py | 6 +++--- sasdata/quantities/units.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sasdata/quantities/_units_base.py b/sasdata/quantities/_units_base.py index 2ec47c69e..a18657782 100644 --- a/sasdata/quantities/_units_base.py +++ b/sasdata/quantities/_units_base.py @@ -420,7 +420,7 @@ def __mul__(self: Self, other: "Unit"): def __rmul__(self: Self, other): return self * other - def __truediv__(self: Self, other: "Unit"): + def __truediv__(self: Self, other: "Unit") -> "UnknownUnit": match other: case UnknownUnit(): num = dict(self._numerator) @@ -445,10 +445,10 @@ def __truediv__(self: Self, other: "Unit"): case _: return NotImplemented - def __rtruediv__(self: Self, other: "Unit") -> "Unit": + def __rtruediv__(self: Self, other: "Unit") -> "UnknownUnit": return (self/other) ** -1 - def __pow__(self, power: int | float) -> "Unit": + def __pow__(self, power: int | float) -> "UnknownUnit": match power: case int() | float(): num = {key: value * power for key, value in self._numerator.items()} diff --git a/sasdata/quantities/units.py b/sasdata/quantities/units.py index 9835555b8..c4ca8f12f 100644 --- a/sasdata/quantities/units.py +++ b/sasdata/quantities/units.py @@ -505,7 +505,7 @@ def __mul__(self: Self, other: "Unit"): def __rmul__(self: Self, other): return self * other - def __truediv__(self: Self, other: "Unit"): + def __truediv__(self: Self, other: "Unit") -> "UnknownUnit": match other: case UnknownUnit(): num = dict(self._numerator) @@ -530,10 +530,10 @@ def __truediv__(self: Self, other: "Unit"): case _: return NotImplemented - def __rtruediv__(self: Self, other: "Unit") -> "Unit": + def __rtruediv__(self: Self, other: "Unit") -> "UnknownUnit": return (self/other) ** -1 - def __pow__(self, power: int | float) -> "Unit": + def __pow__(self, power: int | float) -> "UnknownUnit": match power: case int() | float(): num = {key: value * power for key, value in self._numerator.items()}