Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Next #8

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 34 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,57 +15,56 @@ The SI module is most conveniently installed using Python's pip installer:
Alternatively the package can be installed from source by calling `python -m
pip install .` (note the dot) from inside the source directory.

The module will be installed in the nutils namespace, alongside other
components of the Nutils suite, from where it should be imortable as `SI`:
After installation the module is imortable as `nutils_SI`:

>>> from nutils import SI
>>> from nutils_SI import parse, Velocity, Length, Time, Force


## Usage

The SI module defines all base units and derived units of the International
System of Units (SI) are predefined, as well as the full set of metric
prefixes. Dimensional values are generated primarily by instantiating the
Quantity type with a string value.
System of Units (SI) plus an Angle dimension, as well as the full set of metric
prefixes. Dimensional values are generated primarily by parsing a string value.

>>> v = SI.parse('7μN*5h/6g')
>>> v = parse('7μN*5h/6g')

The Quantity constructor recognizes the multiplication (\*) and division (/)
operators to separate factors. Every factor can be prefixed with a scale and
suffixed with a power. The remainder must be either a unit, or else a unit with
a metric prefix.
The parser recognizes the multiplication (\*) and division (/) operators to
separate factors. Every factor can be prefixed with a scale and suffixed with a
power. The remainder must be either a unit, or else a unit with a metric
prefix.

In this example, the resulting object is of type "L/T", i.e. length over time,
which is a subtype of Quantity that stores the powers L=1 and T=-1. Many
subtypes are readily defined by their physical names; others can be created
through manipulation.

>>> type(v) == SI.Velocity == SI.Length / SI.Time
>>> type(v) == Velocity == Length / Time
True

While Quantity can instantiate any subtype, we could have created the same
object by instantiating Velocity directly, which has the advantage of verifying
that the specified quantity is indeed of the desired dimension.
While `parse` can instantiate any subtype of Quantity, we could have created
the same object by instantiating Velocity directly, which has the advantage of
verifying that the specified quantity is indeed of the desired dimension.

>>> w = SI.Velocity('8km')
>>> w = Velocity('8km')
Traceback (most recent call last):
...
TypeError: expected [L/T], got [L]

Explicit subtypes can also be used in function annotations:

>>> def f(size: SI.Length, load: SI.Force): pass
>>> def f(size: Length, load: Force): pass

The Quantity type acts as an opaque container. As long as a quantity has a
The Quantity types act as an opaque container. As long as a quantity has a
physical dimension, its value is inaccessible. The value can only be retrieved
by dividing out a reference quantity, so that the result becomes dimensionless
and the Quantity wrapper falls away.

>>> v / SI.parse('m/s')
>>> v / parse('m/s')
21.0

To simplify this fairly common situation, any operation involving a Quantity
and a string is handled by parsing the latter automatically.
To simplify the fairly common situation that the reference quantity is a unit
or another parsed value, any operation involving a Quantity and a string is
handled by parsing the latter automatically.

>>> v / 'm/s'
21.0
Expand All @@ -83,7 +82,7 @@ idiomatic way is to rely on multiplication so as not to depend on the specifics
of the internal reference system.

>>> import numpy
>>> F = numpy.array([1,2,3]) * SI.parse('N')
>>> F = numpy.array([1,2,3]) * parse('N')

For convenience, Quantity objects define the shape, ndim and size attributes.
Beyond this, however, no Numpy specific methods or attributes are defined.
Expand All @@ -101,36 +100,33 @@ In case the predefined set of dimensions and units are insufficient, both can
be extended. For instance, though it is not part of the official SI system, it
might be desirable to add an angular dimension. This is done by creating a new
Dimension instance, using a symbol that avoids the existing symbols T, L, M, I,
Θ, N and J:
Θ, N, J and A:

>>> Angle = SI.Dimension.create('Φ')
>>> from nutils_SI import Dimension, units
>>> Xonon = Dimension.create('X')

At this point, the dimension is not very useful yet as it lacks units. To
rectify this we define the radian by its abbreviation 'rad' in terms of the
provided reference quantity, and assign it to the global table of units:
rectify this we define the unit yam as being internally represented by a unit
value:

>>> SI.units.rad = Angle.reference_quantity
>>> units.yam = Xonon.reference_quantity

Additional units can be defined by relating them to pre-existing ones:
Additional units can be defined by relating them to pre-existing ones. Here we
define the unit zop as the equal of 15 kilo-yam:

>>> import math
>>> SI.units.deg = math.pi / 180 * SI.units.rad
>>> units.zop = 15 * units.kyam

Alternatively, units can be defined using the same string syntax that is used
by the Quantity constructor. Nevertheless, the following statement fails as we
cannot define the same unit twice.

>>> SI.units.deg = '0.017453292519943295rad'
>>> units.zop = '15kyam'
Traceback (most recent call last):
...
ValueError: cannot define 'deg': unit is already defined
ValueError: cannot define 'zop': unit is already defined

Having defined the new units we can directly use them:

>>> angle = SI.parse('30deg')

Any function that accepts angular values will expect to receive them in a
specific unit. The new Angle dimension makes this unit explicit:

>>> math.sin(angle / SI.units.rad)
0.49999999999999994
>>> parse('24mzop/h')
0.1[X/T]
77 changes: 57 additions & 20 deletions nutils/SI.py → nutils_SI.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,16 @@ def from_powers(mcls, arg):
mcls.__cache[name] = cls
return cls

def __hash__(cls):
return hash(tuple(sorted(cls.__powers.items())))

def __getattr__(cls, attr):
if attr.startswith('[') and attr.endswith(']'):
# this, together with __qualname__, is what makes pickle work
return Dimension.from_powers({base: fractions.Fraction(power if isnumer else -power)
for base, power, isnumer in _split_factors(attr[1:-1]) if power})
raise AttributeError(attr)

def __bool__(cls) -> bool:
return bool(cls.__powers)

def __or__(cls, other):
return typing.Union[cls, other]

Expand Down Expand Up @@ -111,19 +111,14 @@ def __stringly_dumps__(cls, v):
def __call__(cls, value):
if cls is Quantity:
raise Exception('Quantity base class cannot be instantiated')
if isinstance(value, cls):
return value
if not isinstance(value, str):
raise ValueError(f'expected a str, got {type(value).__name__}')
q = parse(value)
expect = float if not cls.__powers else cls
if type(q) != expect:
raise TypeError(f'expected {expect.__name__}, got {type(q).__name__}')
if not isinstance(q, cls):
raise TypeError(f'expected {cls.__name__}, got {type(q).__name__}')
return q

def __wrap__(cls, value):
if not cls.__powers:
return value
return super().__call__(value)

@property
Expand All @@ -143,6 +138,8 @@ def parse(s):
except (ValueError, AttributeError):
raise ValueError(f'invalid (sub)expression {expr!r}') from None
q = q * v if isnumer else q / v
if not isinstance(q, Quantity):
q = Dimensionless.__wrap__(q)
q._parsed_from = s
return q

Expand All @@ -152,6 +149,9 @@ class Quantity(metaclass=Dimension):
def __init__(self, value):
self.__value = value

def __getnewargs__(self):
return self.__value,

def __bool__(self):
return bool(self.__value)

Expand All @@ -171,44 +171,70 @@ def __format__(self, format_spec):
def __str__(self):
return str(self.__value) + type(self).__name__

def __repr__(self):
return repr(self.__value) + type(self).__name__

def __hash__(self):
return hash((type(self), self.__value))

@staticmethod
def _dispatch(op, *args, **kwargs):
name = op.__name__
args = [parse(arg) if isinstance(arg, str) else arg for arg in args]
if name in ('add', 'sub', 'subtract', 'hypot'):
if name in ('add', 'sub', 'subtract', 'hypot', 'minimum', 'maximum', 'remainder', 'divmod'):
Dim = type(args[0])
if type(args[1]) != Dim:
raise TypeError(f'incompatible arguments for {name}: ' + ', '.join(type(arg).__name__ for arg in args))
elif name in ('mul', 'multiply', 'matmul'):
elif name == 'reciprocal':
Dim = type(args[0])**-1
elif name in ('mul', 'multiply', 'matmul', 'dot'):
Dim = type(args[0]) * type(args[1])
elif name in ('truediv', 'true_divide', 'divide'):
Dim = type(args[0]) / type(args[1])
elif name in ('neg', 'negative', 'pos', 'positive', 'abs', 'absolute', 'sum', 'mean', 'broadcast_to', 'transpose', 'trace', 'take', 'ptp', 'getitem', 'amax', 'amin'):
elif name in ('neg', 'negative', 'pos', 'positive', 'abs', 'absolute', 'sum', 'cumsum', 'mean', 'broadcast_to', 'transpose', 'trace', 'take', 'compress', 'ptp', 'getitem', 'amax', 'amin', 'diff', 'reshape', 'ravel', 'repeat', 'swapaxes'):
Dim = type(args[0])
elif name == 'sqrt':
Dim = type(args[0])**fractions.Fraction(1,2)
elif name == 'square':
Dim = type(args[0])**2
elif name == 'setitem':
Dim = type(args[0])
if type(args[2]) != Dim:
raise TypeError(f'cannot assign {type(args[2]).__name__} to {Dim.__name__}')
elif name in ('pow', 'power'):
Dim = type(args[0])**args[1]
elif name in ('lt', 'le', 'eq', 'ne', 'gt', 'ge', 'equal', 'not_equal', 'less', 'less_equal', 'greater', 'greater_equal', 'isfinite', 'isnan'):
args = args[0], float(args[1])
elif name in ('lt', 'le', 'eq', 'ne', 'gt', 'ge', 'equal', 'not_equal', 'less', 'less_equal', 'greater', 'greater_equal', 'isfinite', 'isinf', 'isnan'):
if any(type(q) != type(args[0]) for q in args[1:]):
raise TypeError(f'incompatible arguments for {name}: ' + ', '.join(type(arg).__name__ for arg in args))
Dim = Dimension.from_powers({})
Dim = Dimensionless
elif name in ('stack', 'concatenate'):
stack_args, = args
Dim = type(stack_args[0])
if any(type(q) != Dim for q in stack_args[1:]):
raise TypeError(f'incompatible arguments for {name}: ' + ', '.join(type(arg).__name__ for arg in stack_args))
args = [q.__value for q in stack_args],
elif name in ('shape', 'ndim', 'size'):
Dim = Dimension.from_powers({})
elif name in ('shape', 'ndim', 'size', 'sign'):
Dim = Dimensionless
elif name in ('sin', 'cos', 'tan'):
if not isinstance(args[0], Angle):
raise TypeError(f'trigonometric functions require angle {Angle.__name__}, got {type(args[0]).__name__}')
Dim = Dimensionless
elif name == 'arctan2':
if type(args[0]) != type(args[1]):
raise TypeError(f'arguments of arctan2 must have equal dimension, got {type(args[0]).__name__} and {type(args[1]).__name__}')
Dim = Angle
else:
return NotImplemented
assert isinstance(Dim, Dimension)
return Dim.__wrap__(op(*(arg.__value if isinstance(arg, Quantity) else arg for arg in args), **kwargs))
try:
retval = op(*(arg.__value if isinstance(arg, Quantity) else arg for arg in args), **kwargs)
except TypeError:
return NotImplemented
if Dim == Dimensionless:
return retval
if name == 'divmod':
return retval[0], Dim.__wrap__(retval[1])
return Dim.__wrap__(retval)

def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
if method != '__call__':
Expand Down Expand Up @@ -249,10 +275,15 @@ def _binary_r(name):
__sub__, __rsub__ = _binary_r('sub')
__mul__, __rmul__ = _binary_r('mul')
__matmul__, __rmatmul__ = _binary_r('matmul')
__truediv__, __rtruediv__ = _binary_r('truediv')
__truediv, __rtruediv__ = _binary_r('truediv')
__mod__, __rmod__ = _binary_r('mod')
__pow__, __rpow__ = _binary_r('pow')

def __truediv__(self, other):
if type(other) is str:
return self.__value / self.__class__(other).__value
return self.__truediv(other)

def _attr(name):
return property(lambda self: getattr(self.__value, name))

Expand Down Expand Up @@ -300,13 +331,16 @@ def _split_factors(s):

## SI DIMENSIONS

Dimensionless = Dimension.from_powers({})

Time = Dimension.create('T')
Length = Dimension.create('L')
Mass = Dimension.create('M')
ElectricCurrent = Dimension.create('I')
Temperature = Dimension.create('θ')
AmountOfSubstance = Dimension.create('N')
LuminousFlux = LuminousIntensity = Dimension.create('J')
Angle = Dimension.create('A')

Area = Length**2
Volume = Length**3
Expand Down Expand Up @@ -350,6 +384,7 @@ def _split_factors(s):
units.K = Temperature.reference_quantity
units.mol = AmountOfSubstance.reference_quantity
units.cd = LuminousIntensity.reference_quantity
units.rad = Angle.reference_quantity

units.N = 'kg*m/s2' # newton
units.Pa = 'N/m2' # pascal
Expand Down Expand Up @@ -380,3 +415,5 @@ def _split_factors(s):
units.t = '1000kg' # ton
units.Da = '1.66053904020yg' # dalton
units.eV = '.1602176634aJ' # electronvolt
units.deg = '0.017453292519943295rad' # degree
units['in'] = 25.4 * units.mm # inch
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ requires = ["flit_core >=3.2,<4"]
build-backend = "flit_core.buildapi"

[project]
name = "nutils.SI"
name = "nutils-SI"
authors = [{name = "Evalf", email = "[email protected]"}]
readme = "README.md"
license = {file = "LICENSE"}
Expand All @@ -12,3 +12,6 @@ dynamic = ["version", "description"]

[project.urls]
Home = "https://github.com/evalf/nutils-SI/"

[tool.flit.module]
name = "nutils_SI"
29 changes: 25 additions & 4 deletions tests/test_SI.py → tests.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from nutils import SI
import nutils_SI as SI

import numpy
import pickle
Expand Down Expand Up @@ -46,15 +46,15 @@ class Quantity(unittest.TestCase):

def test_fromstring(self):
F = SI.parse('5kN')
self.assertEqual(type(F), SI.Force)
self.assertIsInstance(F, SI.Force)
self.assertEqual(F / 'N', 5000)
v = SI.parse('-864km/24h')
self.assertEqual(type(v), SI.Velocity)
self.assertIsInstance(v, SI.Velocity)
self.assertEqual(v / 'm/s', -10)

def test_fromvalue(self):
F = SI.Force('10N')
self.assertEqual(type(F), SI.Force)
self.assertIsInstance(F, SI.Force)
self.assertEqual(F / SI.Force('2N'), 5)

def test_array(self):
Expand Down Expand Up @@ -246,3 +246,24 @@ def test_pickle(self):
def test_string_representation(self):
F = SI.Force('2N')
self.assertEqual(str(F), '2.0[M*L/T2]')
self.assertEqual(repr(F), '2.0[M*L/T2]')

def test_type_error(self):
for F in SI.Force('2N'), numpy.array([1,2,3]) * SI.Force('N'):
with self.assertRaisesRegex(TypeError, r"unsupported operand type\(s\) for /: '\[M\*L/T2\]' and 'object'"):
F / object()

def test_angle(self):
φ = SI.Angle('30deg')
self.assertTrue(numpy.isclose(numpy.sin(φ), .5))
self.assertTrue(numpy.isclose(numpy.cos(φ), numpy.sqrt(3)/2))
self.assertTrue(numpy.isclose(numpy.tan(φ), 1/numpy.sqrt(3)))
with self.assertRaisesRegex(TypeError, r'trigonometric functions require angle \[A\], got \[L\]'):
numpy.sin(SI.parse('2m'))
a = SI.Length('1m')
b = SI.Length('-1m')
φ = numpy.arctan2(a, b)
self.assertIsInstance(φ, SI.Angle)
self.assertTrue(numpy.isclose(φ / 'deg', 135))
with self.assertRaisesRegex(TypeError, r'arguments of arctan2 must have equal dimension, got \[L\] and \[M\]'):
numpy.arctan2(SI.parse('2m'), SI.parse('1kg'))
Empty file removed tests/__init__.py
Empty file.