Skip to content

Commit 37e2d7a

Browse files
authored
Merge pull request #190 from SasView/172_unrecognized_units
172 unrecognized units (Again)
2 parents bde0c8b + 7f9de96 commit 37e2d7a

4 files changed

Lines changed: 549 additions & 205 deletions

File tree

sasdata/quantities/_units_base.py

Lines changed: 208 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
from collections.abc import Sequence
2-
from dataclasses import dataclass
1+
import re
32
from fractions import Fraction
43
from typing import Self
54

65
import numpy as np
7-
from unicode_superscript import int_as_unicode_superscript
6+
from unicode_superscript import int_as_unicode_superscript # type: ignore[import-untyped]
87

98

109
class DimensionError(Exception):
@@ -111,15 +110,15 @@ def __pow__(self, power: int | float):
111110
(self.moles_hint * numerator) // denominator,
112111
(self.angle_hint * numerator) // denominator)
113112

114-
def __eq__(self: Self, other: Self):
113+
def __eq__(self: Self, other: object) -> bool:
115114
if isinstance(other, Dimensions):
116-
return (self.length == other.length and
117-
self.time == other.time and
118-
self.mass == other.mass and
119-
self.current == other.current and
120-
self.temperature == other.temperature and
121-
self.moles_hint == other.moles_hint and
122-
self.angle_hint == other.angle_hint)
115+
return (self.length == other.length
116+
and self.time == other.time
117+
and self.mass == other.mass
118+
and self.current == other.current
119+
and self.temperature == other.temperature
120+
and self.moles_hint == other.moles_hint
121+
and self.angle_hint == other.angle_hint)
123122

124123
return NotImplemented
125124

@@ -210,9 +209,6 @@ def __init__(self,
210209
self.scale = si_scaling_factor
211210
self.dimensions = dimensions
212211

213-
def _components(self, tokens: Sequence["UnitToken"]):
214-
pass
215-
216212
def __mul__(self: Self, other: "Unit"):
217213
if isinstance(other, Unit):
218214
return Unit(self.scale * other.scale, self.dimensions * other.dimensions)
@@ -246,17 +242,15 @@ def __pow__(self, power: int | float):
246242
def equivalent(self: Self, other: "Unit"):
247243
return self.dimensions == other.dimensions
248244

249-
def __eq__(self: Self, other: "Unit"):
250-
return self.equivalent(other) and np.abs(np.log(self.scale/other.scale)) < 1e-5
245+
def __eq__(self: Self, other: object) -> bool:
246+
if isinstance(other, Unit):
247+
return self.equivalent(other) and np.abs(np.log(self.scale/other.scale)) < 1e-5
248+
return False
251249

252250
def si_equivalent(self):
253251
""" Get the SI unit corresponding to this unit"""
254252
return Unit(1, self.dimensions)
255253

256-
def _format_unit(self, format_process: list["UnitFormatProcessor"]):
257-
for processor in format_process:
258-
pass
259-
260254
def __repr__(self):
261255
if self.scale == 1:
262256
# We're in SI
@@ -265,9 +259,6 @@ def __repr__(self):
265259
else:
266260
return f"Unit[{self.scale}, {self.dimensions}]"
267261

268-
@staticmethod
269-
def parse(unit_string: str) -> "Unit":
270-
pass
271262

272263
class NamedUnit(Unit):
273264
""" Units, but they have a name, and a symbol
@@ -308,57 +299,204 @@ def __eq__(self, other):
308299
case _:
309300
return False
310301

311-
312302
def startswith(self, prefix: str) -> bool:
313303
"""Check if any representation of the unit begins with the prefix string"""
314304
prefix = prefix.lower()
315305
return (self.name is not None and self.name.lower().startswith(prefix)) \
316-
or (self.ascii_symbol is not None and self.ascii_symbol.lower().startswith(prefix)) \
317-
or (self.symbol is not None and self.symbol.lower().startswith(prefix))
318-
319-
#
320-
# Parsing plan:
321-
# Require unknown amounts of units to be explicitly positive or negative?
322-
#
323-
#
324-
325-
326-
327-
@dataclass
328-
class ProcessedUnitToken:
329-
""" Mid processing representation of formatted units """
330-
base_string: str
331-
exponent_string: str
332-
latex_exponent_string: str
333-
exponent: int
334-
335-
class UnitFormatProcessor:
336-
""" Represents a step in the unit processing pipeline"""
337-
def apply(self, scale, dimensions) -> tuple[ProcessedUnitToken, float, Dimensions]:
338-
""" This will be called to deal with each processing stage"""
339-
340-
class RequiredUnitFormatProcessor(UnitFormatProcessor):
341-
""" This unit is required to exist in the formatting """
342-
def __init__(self, unit: Unit, power: int = 1):
343-
self.unit = unit
344-
self.power = power
345-
def apply(self, scale, dimensions) -> tuple[float, Dimensions, ProcessedUnitToken]:
346-
new_scale = scale / (self.unit.scale * self.power)
347-
new_dimensions = self.unit.dimensions / (dimensions**self.power)
348-
token = ProcessedUnitToken(self.unit, self.power)
349-
350-
return new_scale, new_dimensions, token
351-
class GreedyAbsDimensionUnitFormatProcessor(UnitFormatProcessor):
352-
""" This processor minimises the dimensionality of the unit by multiplying by as many
353-
units of the specified type as needed """
354-
def __init__(self, unit: Unit):
355-
self.unit = unit
356-
357-
def apply(self, scale, dimensions) -> tuple[ProcessedUnitToken, float, Dimensions]:
358-
pass
359-
360-
class GreedyAbsDimensionUnitFormatProcessor(UnitFormatProcessor):
361-
pass
306+
or (self.ascii_symbol is not None and self.ascii_symbol.lower().startswith(prefix)) \
307+
or (self.symbol is not None and self.symbol.lower().startswith(prefix))
308+
309+
310+
class UnknownUnit(NamedUnit):
311+
"""A unit for an unknown quantity
312+
313+
While this library attempts to handle all known SI units, it is
314+
likely that users will want to express quantities of arbitrary
315+
units (for example, calculating donuts per person for a meeting).
316+
The arbitrary unit allows for these unforseeable quantities."""
317+
318+
def __init__(self,
319+
numerator: str | list[str] | dict[str, int | float],
320+
denominator: None | list[str] | dict[str, int | float] = None):
321+
if numerator is None:
322+
return TypeError
323+
self._numerator = UnknownUnit._parse_arg(numerator)
324+
self._denominator = UnknownUnit._parse_arg(denominator)
325+
self._unit = NamedUnit(1, Dimensions(), "") # Unitless
326+
327+
super().__init__(si_scaling_factor=1, dimensions=self._unit.dimensions, symbol=self._name())
328+
329+
@staticmethod
330+
def _parse_arg(arg: str | list[str] | dict[str, int | float] | None) -> dict[str, int | float]:
331+
"""Parse the different possibilities for constructor arguments
332+
333+
Both the numerator and the denominator could be a string, a
334+
list of strings, or a dict. Parse any of these values into a
335+
dictionary of names and powers.
336+
337+
"""
338+
match arg:
339+
case None:
340+
return {}
341+
case str():
342+
return {UnknownUnit._valid_name(arg): 1}
343+
case list():
344+
result: dict[str, int | float] = {}
345+
for key in arg:
346+
if key in result:
347+
result[key] += 1
348+
else:
349+
UnknownUnit._valid_name(key)
350+
result[key] = 1
351+
return result
352+
case dict():
353+
for key in arg:
354+
UnknownUnit._valid_name(key)
355+
return arg
356+
case _:
357+
raise TypeError
358+
359+
@staticmethod
360+
def _valid_name(name: str) -> str:
361+
"""Confirms that the name of a unit is appropriate
362+
363+
This mostly confirms that the unit does not contain math
364+
operators that would act on other units, like / or ^
365+
"""
366+
367+
if re.search(r"[*/^\s]", name):
368+
raise RuntimeError(f'Unit name "{name}" contains invalid characters (*, /, ^, or whitespace)')
369+
370+
return name
371+
372+
def _name(self):
373+
num = []
374+
for key, value in self._numerator.items():
375+
if value == 1:
376+
num.append(key)
377+
else:
378+
num.append(f"{key}^{value}")
379+
den = []
380+
for key, value in self._denominator.items():
381+
den.append(f"{key}^{-value}")
382+
num.sort()
383+
den.sort()
384+
return " ".join(num + den)
385+
386+
def __eq__(self, other):
387+
match other:
388+
case UnknownUnit():
389+
return self._numerator == other._numerator and self._denominator == other._denominator and self._unit == other._unit
390+
case Unit():
391+
return not self._numerator and not self._denominator and self._unit == other
392+
case _:
393+
return False
394+
395+
def __mul__(self: Self, other: "Unit"):
396+
match other:
397+
case UnknownUnit():
398+
num = dict(self._numerator)
399+
for key in other._numerator:
400+
if key in num:
401+
num[key] += other._numerator[key]
402+
else:
403+
num[key] = other._numerator[key]
404+
den = dict(self._denominator)
405+
for key in other._denominator:
406+
if key in den:
407+
den[key] += other._denominator[key]
408+
else:
409+
den[key] = other._denominator[key]
410+
result = UnknownUnit(num, den)
411+
result._unit *= other._unit
412+
return result._reduce()
413+
case NamedUnit() | Unit() | int() | float():
414+
result = UnknownUnit(self._numerator, self._denominator)
415+
result._unit *= other
416+
return result
417+
case _:
418+
return NotImplemented
419+
420+
def __rmul__(self: Self, other):
421+
return self * other
422+
423+
def __truediv__(self: Self, other: "Unit") -> "UnknownUnit":
424+
match other:
425+
case UnknownUnit():
426+
num = dict(self._numerator)
427+
for key in other._denominator:
428+
if key in num:
429+
num[key] += other._denominator[key]
430+
else:
431+
num[key] = other._denominator[key]
432+
den = dict(self._denominator)
433+
for key in other._numerator:
434+
if key in den:
435+
den[key] += other._numerator[key]
436+
else:
437+
den[key] = other._numerator[key]
438+
result = UnknownUnit(num, den)
439+
result._unit /= other._unit
440+
return result._reduce()
441+
case NamedUnit() | Unit() | int() | float():
442+
result = UnknownUnit(self._numerator, self._denominator)
443+
result._unit /= other
444+
return result
445+
case _:
446+
return NotImplemented
447+
448+
def __rtruediv__(self: Self, other: "Unit") -> "UnknownUnit":
449+
return (self/other) ** -1
450+
451+
def __pow__(self, power: int | float) -> "UnknownUnit":
452+
match power:
453+
case int() | float():
454+
num = {key: value * power for key, value in self._numerator.items()}
455+
den = {key: value * power for key, value in self._denominator.items()}
456+
if power < 0:
457+
num, den = den, num
458+
num = {k: -v for k,v in num.items()}
459+
den = {k: -v for k,v in den.items()}
460+
461+
result = UnknownUnit(num, den)
462+
result._unit = self._unit ** power
463+
return result
464+
case _:
465+
return NotImplemented
466+
467+
def equivalent(self: Self, other: "Unit"):
468+
match other:
469+
case UnknownUnit():
470+
return self._unit.equivalent(other._unit) and sorted(self._numerator) == sorted(other._numerator) and sorted(self._denominator) == sorted(other._denominator)
471+
case _:
472+
return False
473+
474+
def _reduce(self):
475+
"""Remove redundant units"""
476+
for k in self._denominator:
477+
if k in self._numerator:
478+
common = min(self._numerator[k], self._denominator[k])
479+
self._numerator[k] -= common
480+
self._denominator[k] -= common
481+
dead_nums = [k for k in self._numerator if self._numerator[k] == 0]
482+
for k in dead_nums:
483+
del self._numerator[k]
484+
dead_dens = [k for k in self._denominator if self._denominator[k] == 0]
485+
for k in dead_dens:
486+
del self._denominator[k]
487+
return self
488+
489+
def __str__(self):
490+
result = self._name()
491+
if type(self._unit) is NamedUnit and self._unit.name.strip():
492+
result += f" {self._unit.name.strip()}"
493+
if type(self._unit) is Unit and str(self._unit).strip():
494+
result += f" {str(self._unit).strip()}"
495+
return result
496+
497+
def __repr__(self):
498+
return str(self)
499+
362500

363501
class UnitGroup:
364502
""" A group of units that all have the same dimensionality """

sasdata/quantities/accessors.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9479,6 +9479,14 @@ def radians(self) -> T:
94799479
else:
94809480
return quantity.in_units_of(units.radians)
94819481

9482+
@property
9483+
def rotations(self) -> T:
9484+
quantity = self.quantity
9485+
if quantity is None:
9486+
return None
9487+
else:
9488+
return quantity.in_units_of(units.rotations)
9489+
94829490

94839491

94849492
class SolidangleAccessor[T](QuantityAccessor[T]):

0 commit comments

Comments
 (0)