nutils-units 0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evalf
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: nutils-units
3
+ Version: 0.1
4
+ Summary: Nutils Units: object wrappers for dimensional computations
5
+ Author-email: Evalf <info@evalf.com>
6
+ License-Expression: MIT AND (Apache-2.0 OR BSD-2-Clause)
7
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
8
+ Classifier: Topic :: Scientific/Engineering :: Physics
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # Nutils Units
15
+
16
+ The nutils.units module provides a way to track dimensions of numerical objects
17
+ and their change as the objects pass through functions. Its purposes are
18
+ twofold:
19
+
20
+ 1. Numerical type checking, safeguarding against such mistakes as adding two
21
+ objects of different dimensions together.
22
+ 2. Unit consistency, ensuring that different metric systems can coexist without
23
+ crashing into Mars.
24
+
25
+ Units offers three API levels: core, metric and typing, with each building on
26
+ top of the former.
27
+
28
+ ## Core
29
+
30
+ The core API offers the `Monomial` class, which wraps an object to assign it
31
+ physical dimensions. A dimension is identified by a string, such as "L" for
32
+ length. By wrapping the unit float we define the reference length for our
33
+ calculations to be one meter.
34
+
35
+ ```python
36
+ >>> from nutils.units.core import Monomial
37
+ >>> meter = Monomial(1., "L")
38
+
39
+ ```
40
+
41
+ Once the initial unit is seeded, we can build on it to define derived units:
42
+
43
+ ```python
44
+ >>> inch = meter * 0.0254
45
+
46
+ ```
47
+
48
+ This demonstrates that `Monomial` objects support numerical manipulation such
49
+ as multiplication. These manipulations extend to (supported) external packages
50
+ such as numpy:
51
+
52
+ ```python
53
+ >>> import numpy as np
54
+ >>> v1 = np.array([1, -2]) * meter
55
+ >>> v2 = np.array([3, 1]) * inch
56
+ >>> array = np.stack([v1, v2])
57
+ >>> np.linalg.det(array)
58
+ np.float64(0.17779999999999999)[L2]
59
+
60
+ ```
61
+
62
+ The [L2] in the string representation indicates that the result has dimension
63
+ length squared, i.e. area. The value before it is the representation of the
64
+ wrapped object, but its value is shielded by the wrapper. The wrapper falls
65
+ away when an operation yields a dimensionless result, for instance by dividing
66
+ out a unit:
67
+
68
+ ```python
69
+ >>> array / meter
70
+ array([[ 1. , -2. ],
71
+ [ 0.0762, 0.0254]])
72
+
73
+ ```
74
+
75
+ Crucially, dimensionless results do not depend on the definition of our
76
+ reference lengths, as any scaling cancels out by definition. We could have
77
+ defined our meter to be `Monomial(np.pi, "L")` instead and still obtained the
78
+ same result up to rounding errors. The numerical value of the reference length
79
+ is merely a conduit for the internal manipulations.
80
+
81
+ ## Metric
82
+
83
+ The metric API adds support for units via the `parse` function. This returns a
84
+ `UMonomial` object that has access to a predefined set of units, using the SI
85
+ base units as internal reference measures.
86
+
87
+ ```python
88
+ >>> from nutils.units.metric import parse
89
+ >>> length = parse('2cm')
90
+ >>> width = parse('3.5in')
91
+ >>> force = parse('5N')
92
+
93
+ ```
94
+
95
+ We can then manipulate the `UMonomial` objects as before.
96
+
97
+ ```
98
+ >>> area = length * width
99
+ >>> pressure = force / area
100
+ >>> pressure / 'kPa'
101
+ 2.8121484814398205
102
+
103
+ ```
104
+
105
+ Note that in dividing out the unit we omitted `parse`, which is a convenience
106
+ shorthand for this precise situation. For added convenience, the `UMonomial`
107
+ class also supports direct string formatting of the wrapped value.
108
+
109
+ ```python
110
+ >>> f'pressure: {pressure:.1kPa}'
111
+ 'pressure: 2.8kPa'
112
+
113
+ ```
114
+
115
+ The units registry is an append-only state machine that is part of the metric
116
+ module. Additional units can be added if necessary via the `units.register`
117
+ function.
118
+
119
+ ```python
120
+ >>> from nutils.units.metric import units
121
+ >>> units.register("lbf", parse("4.448222N"), prefix="")
122
+ >>> units.register("psi", parse("lbf/in2"), prefix="")
123
+ >>> f'pressure: {pressure:.1psi}'
124
+ 'pressure: 0.4psi'
125
+
126
+ ```
127
+
128
+ ## Typing
129
+
130
+ The typing API adds specific types for scalar quantities.
131
+
132
+ ```python
133
+ >>> from nutils.units.typing import Length, Time, Velocity
134
+ >>> Velocity('.4km/h')
135
+ Quantity[L/T](.4km/h)
136
+
137
+ ```
138
+
139
+ The `Quantity` types function similarly to `parse`, with two differences: 1.
140
+ the object reduces back to its original string argument, with potential uses
141
+ for object introspection, and 2. it protects against using wrong units.
142
+
143
+ ```python
144
+ >>> Velocity('.4km/g')
145
+ Traceback (most recent call last):
146
+ ...
147
+ nutils.units.error.DimensionError: cannot parse .4km/g as L/T
148
+
149
+ ```
150
+
151
+ Derived quantities can be formed by operating directly on the types:
152
+
153
+ ```python
154
+ >>> Velocity == Length / Time
155
+ True
156
+
157
+ ```
158
+
159
+ The quantity types can be used as function annotations for general readability
160
+ a for the potential aid external introspection tools.
161
+
162
+ ```python
163
+ >>> def distance(velocity: Velocity, time: Time):
164
+ ... return velocity * time
165
+
166
+ ```
167
+
168
+ Note that the return value of this function is not a `Length` but a general
169
+ `UMonomial`, as the result of a numerical operation does not have an inherent
170
+ unit.
@@ -0,0 +1,157 @@
1
+ # Nutils Units
2
+
3
+ The nutils.units module provides a way to track dimensions of numerical objects
4
+ and their change as the objects pass through functions. Its purposes are
5
+ twofold:
6
+
7
+ 1. Numerical type checking, safeguarding against such mistakes as adding two
8
+ objects of different dimensions together.
9
+ 2. Unit consistency, ensuring that different metric systems can coexist without
10
+ crashing into Mars.
11
+
12
+ Units offers three API levels: core, metric and typing, with each building on
13
+ top of the former.
14
+
15
+ ## Core
16
+
17
+ The core API offers the `Monomial` class, which wraps an object to assign it
18
+ physical dimensions. A dimension is identified by a string, such as "L" for
19
+ length. By wrapping the unit float we define the reference length for our
20
+ calculations to be one meter.
21
+
22
+ ```python
23
+ >>> from nutils.units.core import Monomial
24
+ >>> meter = Monomial(1., "L")
25
+
26
+ ```
27
+
28
+ Once the initial unit is seeded, we can build on it to define derived units:
29
+
30
+ ```python
31
+ >>> inch = meter * 0.0254
32
+
33
+ ```
34
+
35
+ This demonstrates that `Monomial` objects support numerical manipulation such
36
+ as multiplication. These manipulations extend to (supported) external packages
37
+ such as numpy:
38
+
39
+ ```python
40
+ >>> import numpy as np
41
+ >>> v1 = np.array([1, -2]) * meter
42
+ >>> v2 = np.array([3, 1]) * inch
43
+ >>> array = np.stack([v1, v2])
44
+ >>> np.linalg.det(array)
45
+ np.float64(0.17779999999999999)[L2]
46
+
47
+ ```
48
+
49
+ The [L2] in the string representation indicates that the result has dimension
50
+ length squared, i.e. area. The value before it is the representation of the
51
+ wrapped object, but its value is shielded by the wrapper. The wrapper falls
52
+ away when an operation yields a dimensionless result, for instance by dividing
53
+ out a unit:
54
+
55
+ ```python
56
+ >>> array / meter
57
+ array([[ 1. , -2. ],
58
+ [ 0.0762, 0.0254]])
59
+
60
+ ```
61
+
62
+ Crucially, dimensionless results do not depend on the definition of our
63
+ reference lengths, as any scaling cancels out by definition. We could have
64
+ defined our meter to be `Monomial(np.pi, "L")` instead and still obtained the
65
+ same result up to rounding errors. The numerical value of the reference length
66
+ is merely a conduit for the internal manipulations.
67
+
68
+ ## Metric
69
+
70
+ The metric API adds support for units via the `parse` function. This returns a
71
+ `UMonomial` object that has access to a predefined set of units, using the SI
72
+ base units as internal reference measures.
73
+
74
+ ```python
75
+ >>> from nutils.units.metric import parse
76
+ >>> length = parse('2cm')
77
+ >>> width = parse('3.5in')
78
+ >>> force = parse('5N')
79
+
80
+ ```
81
+
82
+ We can then manipulate the `UMonomial` objects as before.
83
+
84
+ ```
85
+ >>> area = length * width
86
+ >>> pressure = force / area
87
+ >>> pressure / 'kPa'
88
+ 2.8121484814398205
89
+
90
+ ```
91
+
92
+ Note that in dividing out the unit we omitted `parse`, which is a convenience
93
+ shorthand for this precise situation. For added convenience, the `UMonomial`
94
+ class also supports direct string formatting of the wrapped value.
95
+
96
+ ```python
97
+ >>> f'pressure: {pressure:.1kPa}'
98
+ 'pressure: 2.8kPa'
99
+
100
+ ```
101
+
102
+ The units registry is an append-only state machine that is part of the metric
103
+ module. Additional units can be added if necessary via the `units.register`
104
+ function.
105
+
106
+ ```python
107
+ >>> from nutils.units.metric import units
108
+ >>> units.register("lbf", parse("4.448222N"), prefix="")
109
+ >>> units.register("psi", parse("lbf/in2"), prefix="")
110
+ >>> f'pressure: {pressure:.1psi}'
111
+ 'pressure: 0.4psi'
112
+
113
+ ```
114
+
115
+ ## Typing
116
+
117
+ The typing API adds specific types for scalar quantities.
118
+
119
+ ```python
120
+ >>> from nutils.units.typing import Length, Time, Velocity
121
+ >>> Velocity('.4km/h')
122
+ Quantity[L/T](.4km/h)
123
+
124
+ ```
125
+
126
+ The `Quantity` types function similarly to `parse`, with two differences: 1.
127
+ the object reduces back to its original string argument, with potential uses
128
+ for object introspection, and 2. it protects against using wrong units.
129
+
130
+ ```python
131
+ >>> Velocity('.4km/g')
132
+ Traceback (most recent call last):
133
+ ...
134
+ nutils.units.error.DimensionError: cannot parse .4km/g as L/T
135
+
136
+ ```
137
+
138
+ Derived quantities can be formed by operating directly on the types:
139
+
140
+ ```python
141
+ >>> Velocity == Length / Time
142
+ True
143
+
144
+ ```
145
+
146
+ The quantity types can be used as function annotations for general readability
147
+ a for the potential aid external introspection tools.
148
+
149
+ ```python
150
+ >>> def distance(velocity: Velocity, time: Time):
151
+ ... return velocity * time
152
+
153
+ ```
154
+
155
+ Note that the return value of this function is not a `Length` but a general
156
+ `UMonomial`, as the result of a numerical operation does not have an inherent
157
+ unit.
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "nutils-units"
3
+ readme = "README.md"
4
+ authors = [
5
+ { name = "Evalf", email = "info@evalf.com" },
6
+ ]
7
+ requires-python = '>=3.10'
8
+ description = "Nutils Units: object wrappers for dimensional computations"
9
+ license = "MIT AND (Apache-2.0 OR BSD-2-Clause)"
10
+ dynamic = ["version"]
11
+ classifiers = [
12
+ "Topic :: Scientific/Engineering :: Mathematics",
13
+ "Topic :: Scientific/Engineering :: Physics",
14
+ ]
15
+
16
+ [tool.setuptools.dynamic]
17
+ version = {attr = "nutils.units.__version__"}
18
+ readme = {file = ["README"]}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ "Nutils Units: object wrappers for dimensional computations"
2
+
3
+ __version__ = "0.1"
@@ -0,0 +1,70 @@
1
+ from inspect import getmodule
2
+ from functools import partial, partialmethod
3
+
4
+
5
+ class DispatchTable(dict):
6
+ """Table of handler functions.
7
+
8
+ The dispatch table is a dictionary that maps the fully qualified name of a
9
+ callable to a handler function that should be called with the callable as
10
+ the first positional argument, followed by its arguments. Handlers are
11
+ registered via the .register method:
12
+
13
+ >>> dispatch_table = DispatchTable()
14
+ >>> @dispatch_table.register("math.sqrt")
15
+ ... def handle_root(op, x):
16
+ ... return str(op(float(x)))
17
+
18
+ In this (rather contrived) example we create a handler that extends the
19
+ math.sqrt function to operate on string arguments. To obtain the relevant
20
+ handler we use the .bind method:
21
+
22
+ >>> import math
23
+ >>> f = dispatch_table.bind(math.sqrt)
24
+ >>> f("4")
25
+ '2.0'
26
+
27
+ Note that by registering the fully qualified name of the callable, rather
28
+ than the callable itself, we are able to prepare a table for any number of
29
+ external functions without ever importing any third party modules.
30
+ """
31
+
32
+ def register(self, qualname):
33
+ return partial(_setitem_retvalue, self, qualname)
34
+
35
+ def bind(self, func):
36
+ return partial(self[get_qualname(func)], func)
37
+
38
+ def bind_method(self, func):
39
+ return partialmethod(self.bind(func))
40
+
41
+ def bind_method_and_reverse(self, func):
42
+ bound = self.bind(func)
43
+ return partialmethod(bound), partialmethod(_swap, bound)
44
+
45
+ def call_or_notimp(self, func, args, kwargs):
46
+ try:
47
+ bound = self.bind(func)
48
+ except KeyError:
49
+ return NotImplemented
50
+ else:
51
+ return bound(*args, **kwargs)
52
+
53
+
54
+ def get_qualname(func):
55
+ """Get the qualified domain name of a callable.
56
+
57
+ This function returns the string to be used in DispatchTable.register to
58
+ identify a callable.
59
+ """
60
+
61
+ return getmodule(func).__name__ + "." + func.__qualname__
62
+
63
+
64
+ def _setitem_retvalue(d, k, v):
65
+ d[k] = v
66
+ return v
67
+
68
+
69
+ def _swap(self, func, arg):
70
+ return func(arg, self)
@@ -0,0 +1,157 @@
1
+ from fractions import Fraction
2
+
3
+
4
+ class Powers:
5
+ """Immutable counter with fractional values.
6
+
7
+ This class is similar to `collections.Counter`, with two key differences:
8
+
9
+ - Values are instances of `fractions.Fraction`;
10
+ - The collection is immutable.
11
+
12
+ A `Powers` instance can be created from a string, which sets its counter
13
+ equal to 1:
14
+ >>> Powers("A")
15
+ Powers(A)
16
+
17
+ We can then use multiplication and division to scale all counts:
18
+ >>> Powers("A") * 3
19
+ Powers(A3)
20
+
21
+ And we can use addition and subtraction to combine powers:
22
+ >>> Powers("A") * 3 + Powers("B") / 2
23
+ Powers(A3*B1/2)
24
+
25
+ Note that the different items are separated by a `*`. Note what happens
26
+ when we reach a negative count:
27
+ >>> Powers("A") * 3 - Powers("B") * 2
28
+ Powers(A3/B2)
29
+
30
+ Rather than making the power negatve, we switch the `*` for a `/`. This
31
+ notation reflects the interpretation of counts as monomial powers.
32
+
33
+ When we created a power from a string before, we actually made use of the
34
+ fact that we can parse back the entire string representation now
35
+ established:
36
+ >>> Powers("A3/B2")
37
+ Powers(A3/B2)
38
+
39
+ Alternatively we can create it from a dictionary:
40
+ >>> Powers({"A": Fraction(3), "B": Fraction(-2)})
41
+ Powers(A3/B2)
42
+
43
+ Or even from an existing powers object:
44
+ >>> Powers(Powers("A"))
45
+ Powers(A)
46
+ """
47
+
48
+ def __init__(self, d):
49
+ if isinstance(d, Powers):
50
+ self.__d = d.__d
51
+ elif isinstance(d, str):
52
+ self.__d = _split_factors(d)
53
+ elif isinstance(d, dict) and all(
54
+ isinstance(k, str) and isinstance(v, Fraction) for k, v in d.items()
55
+ ):
56
+ self.__d = {k: v for k, v in d.items() if v}
57
+ else:
58
+ raise ValueError(f"cannot create Powers from {d!r}")
59
+
60
+ def __hash__(self):
61
+ return hash(tuple(sorted(self.__d.items())))
62
+
63
+ def __eq__(self, other):
64
+ return self is other or isinstance(other, Powers) and self.__d == other.__d
65
+
66
+ def __add__(self, other):
67
+ if not isinstance(other, Powers):
68
+ return NotImplemented
69
+ d = self.__d.copy()
70
+ for name, n in other.__d.items():
71
+ d[name] = d.get(name, 0) + n
72
+ return Powers(d)
73
+
74
+ def __sub__(self, other):
75
+ if not isinstance(other, Powers):
76
+ return NotImplemented
77
+ d = self.__d.copy()
78
+ for name, n in other.__d.items():
79
+ d[name] = d.get(name, 0) - n
80
+ return Powers(d)
81
+
82
+ def __mul__(self, other):
83
+ if isinstance(other, float):
84
+ tmp = Fraction(other).limit_denominator()
85
+ if other == float(tmp):
86
+ other = tmp
87
+ if not isinstance(other, (int, Fraction)):
88
+ try:
89
+ other = other.__index__()
90
+ except Exception:
91
+ return NotImplemented
92
+ return Powers({name: n * other for name, n in self.__d.items()})
93
+
94
+ def __truediv__(self, other):
95
+ if isinstance(other, float):
96
+ tmp = Fraction(other).limit_denominator()
97
+ if other == float(tmp):
98
+ other = tmp
99
+ if not isinstance(other, (int, Fraction)):
100
+ return NotImplemented
101
+ return Powers({name: n / other for name, n in self.__d.items()})
102
+
103
+ def __neg__(self):
104
+ return Powers({name: -n for name, n in self.__d.items()})
105
+
106
+ def __bool__(self):
107
+ return bool(self.__d)
108
+
109
+ def items(self):
110
+ return self.__d.items()
111
+
112
+ def __str__(self):
113
+ return _join_factors(self.__d)
114
+
115
+ def __repr__(self):
116
+ return f"Powers({self})"
117
+
118
+
119
+ def _split_factors(s):
120
+ items = []
121
+ for factors in s.split("*"):
122
+ numer, *denoms = factors.split("/")
123
+ if numer:
124
+ base = numer.rstrip("0123456789")
125
+ num = Fraction(int(numer[len(base) :] or 1))
126
+ items.append((base, num))
127
+ elif items: # s contains "*/"
128
+ raise ValueError
129
+ for denom in denoms:
130
+ base = denom.rstrip("0123456789")
131
+ if base:
132
+ num = -Fraction(int(denom[len(base) :] or 1))
133
+ else: # fractional power
134
+ base, num = items.pop()
135
+ num /= int(denom)
136
+ items.append((base, num))
137
+ return dict(items)
138
+
139
+
140
+ def _join_factors(factors):
141
+ s = ""
142
+ for base, power in sorted(
143
+ factors.items(), key=lambda item: item[::-1], reverse=True
144
+ ):
145
+ if power < 0:
146
+ power = -power
147
+ s += "/"
148
+ else:
149
+ s += "*"
150
+ s += base
151
+ if power != 1:
152
+ s += (
153
+ str(power)
154
+ if power.denominator == 1
155
+ else f"{power.numerator}/{power.denominator}"
156
+ )
157
+ return s.lstrip("*")
@@ -0,0 +1,58 @@
1
+ PREFIX = dict(
2
+ Y=1e24,
3
+ Z=1e21,
4
+ E=1e18,
5
+ P=1e15,
6
+ T=1e12,
7
+ G=1e9,
8
+ M=1e6,
9
+ k=1e3,
10
+ h=1e2,
11
+ d=1e-1,
12
+ c=1e-2,
13
+ m=1e-3,
14
+ μ=1e-6,
15
+ n=1e-9,
16
+ p=1e-12,
17
+ f=1e-15,
18
+ a=1e-18,
19
+ z=1e-21,
20
+ y=1e-24,
21
+ )
22
+
23
+
24
+ class Units:
25
+ """Registry of numerical values.
26
+
27
+ The Units object is a registry of numerical values. Values can be assigned
28
+ under any name that is not previously used, and cannot be removed.
29
+
30
+ >>> u = Units()
31
+ >>> u.register("m", 5.)
32
+ >>> u.m
33
+ 5.0
34
+
35
+ Intended for units, the registry by default adds all metric prefixes with
36
+ approprately scaled values. If "m" represents the number 5, then "km"
37
+ represents 1000 times that number:
38
+
39
+ >>> u.km
40
+ 5000.0
41
+
42
+ Attempting to redefine a previously assigned value results in an error and
43
+ leaves the registry unmodified.
44
+
45
+ >>> u.register("am", 10.)
46
+ Traceback (most recent call last):
47
+ ...
48
+ ValueError: registration failed: unit collides with 'am'
49
+ """
50
+
51
+ def register(self, name, value, prefix=tuple(PREFIX)):
52
+ d = self.__dict__
53
+ new = [(name, value)]
54
+ new.extend((p + name, value * PREFIX[p]) for p in prefix)
55
+ collisions = ", ".join(repr(s) for s, v in new if s in d and d[s] != v)
56
+ if collisions:
57
+ raise ValueError(f"registration failed: unit collides with {collisions}")
58
+ d.update(new)