Skip to content
205 changes: 200 additions & 5 deletions sasdata/quantities/_units_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,12 +316,207 @@ def startswith(self, prefix: str) -> bool:
or (self.ascii_symbol is not None and self.ascii_symbol.lower().startswith(prefix)) \
or (self.symbol is not None and self.symbol.lower().startswith(prefix))

#
# Parsing plan:
# Require unknown amounts of units to be explicitly positive or negative?
#
#

class ArbitraryUnit(NamedUnit):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arbitrary unit (a.u.) is used for SAS data not on absolute scale. I think UnknownUnit or similar would better match the current naming scheme and not cause confusion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right about the confusion. I've switched to UnknownUnit

"""A unit for an unknown quantity

While this library attempts to handle all known SI units, it is
likely that users will want to express quantities of arbitrary
units (for example, calculating donuts per person for a meeting).
The arbitrary unit allows for these unforseeable quantities."""

def __init__(self,
numerator: str | list[str] | dict[str, int],
denominator: None | list[str] | dict[str, int]= None):
Comment on lines +329 to +330
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should these strings and/or lists look like? Documentation on this would be helpful. Currently, the unit converter allows a*a, a**2, and a^2. What happens if a^2/b is passed as the numerator? Does it matter?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likely it won't matter, since UnknownUnit should only be called after a failed parse that has already split on those characters. However, just in case, I've added validation that throws a runtime error if invalid characters are in any part of the unit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is contained in the _valid_name static method

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
Comment on lines +332 to +344
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This match/case block is almost exactly the same as the one for denominator. Could this be made into a separate function that returns the resulting dictionary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactoring out the repetition also made the string validation simpler. The separate function is now the _parse_arg class method.

match denominator:
case None:
self._denominator = {}
case str():
self._denominator = {denominator: 1}
case list():
self._denominator = {}
for key in denominator:
if key in self._denominator:
self._denominator[key] += 1
else:
self._denominator[key] = 1
case dict():
self._denominator = denominator
case _:
raise TypeError
self._unit = NamedUnit(1, Dimensions(), "") # Unitless

super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name())

def _name(self):
num = []
for key, value in self._numerator.items():
if value == 1:
num.append(key)
else:
num.append(f"{key}^{value}")
den = []
for key, value in self._denominator.items():
if value == 1:
den.append(key)
else:
den.append(f"{key}^{value}")
num.sort()
den.sort()
match (num, den):
case ([], []):
return ""
case (_, []):
return " ".join(num)
case ([], _):
return "1 / " + " ".join(den)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will give something like 1 / A B C which will create ambiguity for B and C. Maybe return "1 / (" + " ".join(den) + ")"? I would suggest something similar in the default case as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added parentheses, but only in the case where there are multiple terms in the denominator. Thus, it will appear as "1 / (A B C)", but the single term will still appear as "1 / A"

case _:
return " ".join(num) + " / " + " ".join(den)

def __eq__(self, other):
match other:
case ArbitraryUnit():
return self._numerator == other._numerator and self._denominator == other._denominator and self._unit == other._unit
case Unit():
return not self._numerator and not self._denominator and self._unit == other


def __mul__(self: Self, other: "Unit"):
match other:
case ArbitraryUnit():
num = dict(self._numerator)
for key in other._numerator:
if key in num:
num[key] += other._numerator[key]
else:
num[key] = other._numerator[key]
den = dict(self._denominator)
for key in other._denominator:
if key in den:
den[key] += other._denominator[key]
else:
den[key] = other._denominator[key]
result = ArbitraryUnit(num, den)
result._unit *= other._unit
return result._reduce()
case NamedUnit() | Unit() | int() | float():
result = ArbitraryUnit(self._numerator, self._denominator)
result._unit *= other
return result
case _:
return NotImplemented

def __rmul__(self: Self, other):
return self * other

def __truediv__(self: Self, other: "Unit"):
match other:
case ArbitraryUnit():
num = dict(self._numerator)
for key in other._denominator:
if key in num:
num[key] += other._denominator[key]
else:
num[key] = other._denominator[key]
den = dict(self._denominator)
for key in other._numerator:
if key in den:
den[key] += other._numerator[key]
else:
den[key] = other._numerator[key]
result = ArbitraryUnit(num, den)
result._unit /= other._unit
return result._reduce()
case NamedUnit() | Unit() | int() | float():
result = ArbitraryUnit(self._numerator, self._denominator)
result._unit /= other
return result
case _:
return NotImplemented

def __rtruediv__(self: Self, other: "Unit"):
match other:
case ArbitraryUnit():
num = dict(other._numerator)
for key in self._denominator:
if key in num:
num[key] += self._denominator[key]
else:
num[key] = self._denominator[key]
den = dict(other._denominator)
for key in self._numerator:
if key in den:
den[key] += self._numerator[key]
else:
den[key] = self._numerator[key]
result = ArbitraryUnit(num, den)
result._unit = other._unit / self._unit
return result._reduce()
case NamedUnit() | Unit() | int() | float():
result = ArbitraryUnit(self._denominator, self._numerator)
result._unit = other / result._unit
return result
case _:
return NotImplemented

def __pow__(self, power: int):
match power:
case int() | float():
num = {key: value * power for key, value in self._numerator.items()}
den = {key: value * power for key, value in self._denominator.items()}
result = ArbitraryUnit(num, den)
result._unit = self._unit ** power
return result
case _:
return NotImplemented


def equivalent(self: Self, other: "Unit"):
match other:
case ArbitraryUnit():
return self._unit.equivalent(other._unit) and sorted(self._numerator) == sorted(other._numerator) and sorted(self._denominator) == sorted(other._denominator)
case _:
return False

def _reduce(self):
"""Remove redundant units"""
for k in self._denominator:
if k in self._numerator:
common = min(self._numerator[k], self._denominator[k])
self._numerator[k] -= common
self._denominator[k] -= common
dead_nums = [k for k in self._numerator if self._numerator[k] == 0]
for k in dead_nums:
del self._numerator[k]
dead_dens = [k for k in self._denominator if self._denominator[k] == 0]
for k in dead_dens:
del self._denominator[k]
return self

def __str__(self):
result = self._name()
if type(self._unit) is NamedUnit and self._unit.name.strip():
result += f" {self._unit.name.strip()}"
if type(self._unit) is Unit and str(self._unit).strip():
result += f" {str(self._unit).strip()}"
return result

def __repr__(self):
return str(self)


@dataclass
Expand Down
8 changes: 8 additions & 0 deletions sasdata/quantities/accessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand Down
Loading