unitful 1.0.0__py3-none-any.whl
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.
- unitful/__init__.py +167 -0
- unitful/decorators.py +142 -0
- unitful/dimension.py +160 -0
- unitful/exceptions.py +106 -0
- unitful/formatting.py +114 -0
- unitful/numpy_support.py +313 -0
- unitful/py.typed +1 -0
- unitful/quantity.py +303 -0
- unitful/registry.py +145 -0
- unitful/serialization.py +39 -0
- unitful/units.py +156 -0
- unitful-1.0.0.dist-info/METADATA +112 -0
- unitful-1.0.0.dist-info/RECORD +15 -0
- unitful-1.0.0.dist-info/WHEEL +4 -0
- unitful-1.0.0.dist-info/licenses/LICENSE +21 -0
unitful/__init__.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Public API"""
|
|
2
|
+
|
|
3
|
+
# Import numpy_support to install the ndarray patch (no-op if numpy absent)
|
|
4
|
+
from . import numpy_support # noqa: F401
|
|
5
|
+
from .decorators import Dim, requires, returns
|
|
6
|
+
from .dimension import Dimension, dimensionless
|
|
7
|
+
from .exceptions import DimensionError
|
|
8
|
+
from .numpy_support import QuantityArray
|
|
9
|
+
from .quantity import Quantity
|
|
10
|
+
from .registry import Unit, registry
|
|
11
|
+
from .serialization import from_json, to_json
|
|
12
|
+
|
|
13
|
+
# All built-in unit constants are imported from units.py
|
|
14
|
+
from .units import (
|
|
15
|
+
GB,
|
|
16
|
+
KB,
|
|
17
|
+
MB,
|
|
18
|
+
MJ,
|
|
19
|
+
MN,
|
|
20
|
+
MW,
|
|
21
|
+
TB,
|
|
22
|
+
B,
|
|
23
|
+
GiB,
|
|
24
|
+
# Energy
|
|
25
|
+
J,
|
|
26
|
+
# Temperature
|
|
27
|
+
K,
|
|
28
|
+
KiB,
|
|
29
|
+
MiB,
|
|
30
|
+
MPa,
|
|
31
|
+
# Force
|
|
32
|
+
N_unit,
|
|
33
|
+
# Pressure
|
|
34
|
+
Pa,
|
|
35
|
+
# Power
|
|
36
|
+
W,
|
|
37
|
+
arcmin,
|
|
38
|
+
arcsec,
|
|
39
|
+
atm,
|
|
40
|
+
bar,
|
|
41
|
+
# Data
|
|
42
|
+
bit,
|
|
43
|
+
cal,
|
|
44
|
+
cm,
|
|
45
|
+
day,
|
|
46
|
+
deg,
|
|
47
|
+
degC,
|
|
48
|
+
degF,
|
|
49
|
+
eV,
|
|
50
|
+
ft,
|
|
51
|
+
g,
|
|
52
|
+
h,
|
|
53
|
+
hp,
|
|
54
|
+
inch,
|
|
55
|
+
kcal,
|
|
56
|
+
# Mass
|
|
57
|
+
kg,
|
|
58
|
+
kJ,
|
|
59
|
+
km,
|
|
60
|
+
kN,
|
|
61
|
+
knot,
|
|
62
|
+
kPa,
|
|
63
|
+
kW,
|
|
64
|
+
kWh,
|
|
65
|
+
lb,
|
|
66
|
+
lbf,
|
|
67
|
+
ly,
|
|
68
|
+
# Length
|
|
69
|
+
m,
|
|
70
|
+
mach,
|
|
71
|
+
mg,
|
|
72
|
+
mile,
|
|
73
|
+
min,
|
|
74
|
+
mm,
|
|
75
|
+
# Speed
|
|
76
|
+
mph,
|
|
77
|
+
ms,
|
|
78
|
+
nm,
|
|
79
|
+
nmi,
|
|
80
|
+
ns,
|
|
81
|
+
oz,
|
|
82
|
+
psi,
|
|
83
|
+
# Angle
|
|
84
|
+
rad,
|
|
85
|
+
# Time
|
|
86
|
+
s,
|
|
87
|
+
stone,
|
|
88
|
+
t,
|
|
89
|
+
ug,
|
|
90
|
+
um,
|
|
91
|
+
us,
|
|
92
|
+
week,
|
|
93
|
+
yd,
|
|
94
|
+
year,
|
|
95
|
+
μg,
|
|
96
|
+
μm,
|
|
97
|
+
μs,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Newton exported as N (N_unit avoids clash with the Amount dimension symbol)
|
|
101
|
+
N = N_unit
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def new_dimension(name: str, symbol: str = "") -> Dimension:
|
|
105
|
+
"""Register and return a new base dimension
|
|
106
|
+
|
|
107
|
+
Example::
|
|
108
|
+
|
|
109
|
+
px = new_dimension("pixel", symbol="px")
|
|
110
|
+
"""
|
|
111
|
+
return registry.new_dimension(name, symbol)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def define_unit(name: str, quantity: Quantity, symbol: str = "") -> Quantity:
|
|
115
|
+
"""Register a new unit derived from an existing Quantity
|
|
116
|
+
|
|
117
|
+
Example::
|
|
118
|
+
|
|
119
|
+
define_unit("furlong", 201.168 * m)
|
|
120
|
+
define_unit("fortnight", 14 * day)
|
|
121
|
+
"""
|
|
122
|
+
unit = registry.define_unit(name, quantity, symbol)
|
|
123
|
+
return Quantity(1.0, unit)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = [
|
|
127
|
+
# Core types
|
|
128
|
+
"Quantity",
|
|
129
|
+
"QuantityArray",
|
|
130
|
+
"Dimension",
|
|
131
|
+
"dimensionless",
|
|
132
|
+
"DimensionError",
|
|
133
|
+
"Unit",
|
|
134
|
+
# Registry helpers
|
|
135
|
+
"new_dimension",
|
|
136
|
+
"define_unit",
|
|
137
|
+
# Decorators
|
|
138
|
+
"requires",
|
|
139
|
+
"returns",
|
|
140
|
+
"Dim",
|
|
141
|
+
# Serialization
|
|
142
|
+
"to_json",
|
|
143
|
+
"from_json",
|
|
144
|
+
# Length
|
|
145
|
+
"m", "km", "cm", "mm", "μm", "um", "nm",
|
|
146
|
+
"inch", "ft", "yd", "mile", "nmi", "ly",
|
|
147
|
+
# Mass
|
|
148
|
+
"kg", "g", "mg", "μg", "ug", "t", "lb", "oz", "stone",
|
|
149
|
+
# Time
|
|
150
|
+
"s", "ms", "μs", "us", "ns", "min", "h", "day", "week", "year",
|
|
151
|
+
# Temperature
|
|
152
|
+
"K", "degC", "degF",
|
|
153
|
+
# Force
|
|
154
|
+
"N", "kN", "MN", "lbf",
|
|
155
|
+
# Energy
|
|
156
|
+
"J", "kJ", "MJ", "cal", "kcal", "eV", "kWh",
|
|
157
|
+
# Power
|
|
158
|
+
"W", "kW", "MW", "hp",
|
|
159
|
+
# Pressure
|
|
160
|
+
"Pa", "kPa", "MPa", "bar", "atm", "psi",
|
|
161
|
+
# Speed
|
|
162
|
+
"mph", "knot", "mach",
|
|
163
|
+
# Data
|
|
164
|
+
"bit", "B", "KB", "MB", "GB", "TB", "KiB", "MiB", "GiB",
|
|
165
|
+
# Angle
|
|
166
|
+
"rad", "deg", "arcmin", "arcsec",
|
|
167
|
+
]
|
unitful/decorators.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""@requires and @returns decorators for dimension-safe function signatures"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .dimension import Dimension
|
|
10
|
+
from .exceptions import DimensionError
|
|
11
|
+
from .quantity import Quantity
|
|
12
|
+
from .registry import registry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Dim:
|
|
16
|
+
"""Declare an expected dimension from a unit expression string
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
@requires(speed=Dim("m/s"), time=Dim("s"))
|
|
21
|
+
def distance_traveled(speed, time):
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
The string is parsed by dividing the named units from the registry
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, expr: str) -> None:
|
|
28
|
+
self._expr = expr
|
|
29
|
+
self._dim = _parse_dim_expr(expr)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def dimension(self) -> Dimension:
|
|
33
|
+
return self._dim
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
return f"Dim({self._expr!r})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_dim_expr(expr: str) -> Dimension:
|
|
40
|
+
"""Parse an expression like 'm/s', 'm/s^2', 'kg*m/s^2' into a Dimension"""
|
|
41
|
+
# Split on '/' once: numerator * denominator^-1
|
|
42
|
+
parts = expr.split("/", 1)
|
|
43
|
+
num_dim = _parse_product(parts[0])
|
|
44
|
+
if len(parts) == 2:
|
|
45
|
+
den_dim = _parse_product(parts[1])
|
|
46
|
+
return num_dim / den_dim
|
|
47
|
+
return num_dim
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_product(expr: str) -> Dimension:
|
|
51
|
+
"""Parse 'kg*m' or 'kg' or 'm^2' into a Dimension"""
|
|
52
|
+
dim = Dimension()
|
|
53
|
+
for token in expr.split("*"):
|
|
54
|
+
token = token.strip()
|
|
55
|
+
if not token:
|
|
56
|
+
continue
|
|
57
|
+
if "^" in token:
|
|
58
|
+
base, exp_str = token.split("^", 1)
|
|
59
|
+
exp = float(exp_str)
|
|
60
|
+
else:
|
|
61
|
+
base, exp = token, 1.0
|
|
62
|
+
base = base.strip()
|
|
63
|
+
try:
|
|
64
|
+
unit = registry.get(base)
|
|
65
|
+
except KeyError:
|
|
66
|
+
raise ValueError(f"Unknown unit in dimension expression: {base!r}") from None
|
|
67
|
+
dim = dim * (unit.dimension ** exp)
|
|
68
|
+
return dim
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def requires(**expected_dims: Dim) -> Callable[..., Any]:
|
|
72
|
+
"""Validate that keyword arguments have the expected physical dimensions
|
|
73
|
+
|
|
74
|
+
If an argument has compatible dimensions but a different unit, it is
|
|
75
|
+
automatically converted before being passed to the function
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
79
|
+
@functools.wraps(func)
|
|
80
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
81
|
+
import inspect
|
|
82
|
+
sig = inspect.signature(func)
|
|
83
|
+
bound = sig.bind(*args, **kwargs)
|
|
84
|
+
bound.apply_defaults()
|
|
85
|
+
|
|
86
|
+
for param_name, dim_spec in expected_dims.items():
|
|
87
|
+
if param_name not in bound.arguments:
|
|
88
|
+
continue
|
|
89
|
+
val = bound.arguments[param_name]
|
|
90
|
+
expected = dim_spec.dimension
|
|
91
|
+
|
|
92
|
+
if not isinstance(val, Quantity):
|
|
93
|
+
raise DimensionError.bare_value(
|
|
94
|
+
func.__name__, param_name, expected.label(), val
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
got_dim = val.dimension
|
|
98
|
+
if got_dim != expected:
|
|
99
|
+
raise DimensionError.wrong_argument(
|
|
100
|
+
func.__name__, param_name, expected.label(), val, got_dim.label()
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Auto-convert to the canonical SI unit for that dimension so
|
|
104
|
+
# the function receives a consistent unit. We keep the original
|
|
105
|
+
# unit if no mismatch was detected above.
|
|
106
|
+
# (Conversion already succeeded in the dimension check.)
|
|
107
|
+
bound.arguments[param_name] = val
|
|
108
|
+
|
|
109
|
+
return func(*bound.args, **bound.kwargs)
|
|
110
|
+
|
|
111
|
+
return wrapper
|
|
112
|
+
|
|
113
|
+
return decorator
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def returns(dim_spec: Dim) -> Callable[..., Any]:
|
|
117
|
+
"""Validate that the return value has the expected physical dimensions"""
|
|
118
|
+
|
|
119
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
120
|
+
@functools.wraps(func)
|
|
121
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
122
|
+
result = func(*args, **kwargs)
|
|
123
|
+
expected = dim_spec.dimension
|
|
124
|
+
|
|
125
|
+
if not isinstance(result, Quantity):
|
|
126
|
+
# Treat bare numbers as dimensionless.
|
|
127
|
+
from .dimension import dimensionless
|
|
128
|
+
if expected != dimensionless:
|
|
129
|
+
raise DimensionError.wrong_return(
|
|
130
|
+
func.__name__, expected.label(), result, "dimensionless"
|
|
131
|
+
)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
if result.dimension != expected:
|
|
135
|
+
raise DimensionError.wrong_return(
|
|
136
|
+
func.__name__, expected.label(), result, result.dimension.label()
|
|
137
|
+
)
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
return wrapper
|
|
141
|
+
|
|
142
|
+
return decorator
|
unitful/dimension.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Immutable exponent vector over the 7 SI base quantities"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from fractions import Fraction
|
|
7
|
+
|
|
8
|
+
# Ordered base dimension symbols used throughout the library
|
|
9
|
+
BASE_DIMS = ("L", "M", "T", "I", "Theta", "N", "J")
|
|
10
|
+
|
|
11
|
+
# Human-readable names for error messages
|
|
12
|
+
_DIM_NAMES: dict[str, str] = {
|
|
13
|
+
"L": "Length",
|
|
14
|
+
"M": "Mass",
|
|
15
|
+
"T": "Time",
|
|
16
|
+
"I": "Current",
|
|
17
|
+
"Theta": "Temperature",
|
|
18
|
+
"N": "Amount",
|
|
19
|
+
"J": "Luminosity",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Dimension:
|
|
24
|
+
"""Immutable vector of rational exponents over the SI base dimensions
|
|
25
|
+
|
|
26
|
+
Each position corresponds to L, M, T, I, Theta, N, J in that order
|
|
27
|
+
Arithmetic on Dimension objects implements the algebra of physical dimensions
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("_exponents",)
|
|
31
|
+
|
|
32
|
+
def __init__(self, **exponents: int | float | Fraction) -> None:
|
|
33
|
+
"""Create from keyword arguments, e.g. Dimension(L=1, T=-1)"""
|
|
34
|
+
exp: dict[str, Fraction] = {}
|
|
35
|
+
for dim in BASE_DIMS:
|
|
36
|
+
val = exponents.get(dim, 0)
|
|
37
|
+
exp[dim] = Fraction(val).limit_denominator(1000)
|
|
38
|
+
self._exponents: dict[str, Fraction] = exp
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def _from_dict(cls, d: dict[str, Fraction]) -> Dimension:
|
|
42
|
+
obj = object.__new__(cls)
|
|
43
|
+
obj._exponents = dict(d)
|
|
44
|
+
return obj
|
|
45
|
+
|
|
46
|
+
# --- custom dimension support ---
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def custom(cls, name: str) -> Dimension:
|
|
50
|
+
"""Create a dimension with a single non-standard base dimension"""
|
|
51
|
+
obj = object.__new__(cls)
|
|
52
|
+
obj._exponents = {dim: Fraction(0) for dim in BASE_DIMS}
|
|
53
|
+
obj._exponents[name] = Fraction(1)
|
|
54
|
+
return obj
|
|
55
|
+
|
|
56
|
+
# --- arithmetic ---
|
|
57
|
+
|
|
58
|
+
def __mul__(self, other: Dimension) -> Dimension:
|
|
59
|
+
keys = set(self._exponents) | set(other._exponents)
|
|
60
|
+
result = {k: self._exponents.get(k, Fraction(0)) + other._exponents.get(k, Fraction(0)) for k in keys}
|
|
61
|
+
return Dimension._from_dict(result)
|
|
62
|
+
|
|
63
|
+
def __truediv__(self, other: Dimension) -> Dimension:
|
|
64
|
+
keys = set(self._exponents) | set(other._exponents)
|
|
65
|
+
result = {k: self._exponents.get(k, Fraction(0)) - other._exponents.get(k, Fraction(0)) for k in keys}
|
|
66
|
+
return Dimension._from_dict(result)
|
|
67
|
+
|
|
68
|
+
def __pow__(self, exp: int | float | Fraction) -> Dimension:
|
|
69
|
+
f = Fraction(exp).limit_denominator(1000)
|
|
70
|
+
result = {k: v * f for k, v in self._exponents.items()}
|
|
71
|
+
return Dimension._from_dict(result)
|
|
72
|
+
|
|
73
|
+
def __eq__(self, other: object) -> bool:
|
|
74
|
+
if not isinstance(other, Dimension):
|
|
75
|
+
return NotImplemented
|
|
76
|
+
keys = set(self._exponents) | set(other._exponents)
|
|
77
|
+
return all(
|
|
78
|
+
self._exponents.get(k, Fraction(0)) == other._exponents.get(k, Fraction(0))
|
|
79
|
+
for k in keys
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def __hash__(self) -> int:
|
|
83
|
+
return hash(tuple(sorted((k, v) for k, v in self._exponents.items() if v != 0)))
|
|
84
|
+
|
|
85
|
+
def is_dimensionless(self) -> bool:
|
|
86
|
+
return all(v == 0 for v in self._exponents.values())
|
|
87
|
+
|
|
88
|
+
def keys(self) -> Iterator[str]:
|
|
89
|
+
return iter(self._exponents)
|
|
90
|
+
|
|
91
|
+
def __getitem__(self, key: str) -> Fraction:
|
|
92
|
+
return self._exponents.get(key, Fraction(0))
|
|
93
|
+
|
|
94
|
+
# --- display ---
|
|
95
|
+
|
|
96
|
+
def __str__(self) -> str:
|
|
97
|
+
parts = []
|
|
98
|
+
for k, v in self._exponents.items():
|
|
99
|
+
if v == 0:
|
|
100
|
+
continue
|
|
101
|
+
if v == 1:
|
|
102
|
+
parts.append(k)
|
|
103
|
+
else:
|
|
104
|
+
# Use integer display when possible
|
|
105
|
+
exp_str = str(int(v)) if v.denominator == 1 else str(v)
|
|
106
|
+
parts.append(f"{k}^{exp_str}")
|
|
107
|
+
return "*".join(parts) if parts else "dimensionless"
|
|
108
|
+
|
|
109
|
+
def __repr__(self) -> str:
|
|
110
|
+
parts = {k: v for k, v in self._exponents.items() if v != 0}
|
|
111
|
+
return f"Dimension({parts!r})"
|
|
112
|
+
|
|
113
|
+
def label(self) -> str:
|
|
114
|
+
"""Human-readable label for error messages, e.g. 'Length/Time'"""
|
|
115
|
+
pos = []
|
|
116
|
+
neg = []
|
|
117
|
+
for k, v in self._exponents.items():
|
|
118
|
+
if v == 0:
|
|
119
|
+
continue
|
|
120
|
+
name = _DIM_NAMES.get(k, k)
|
|
121
|
+
if v > 0:
|
|
122
|
+
if v == 1:
|
|
123
|
+
pos.append(name)
|
|
124
|
+
else:
|
|
125
|
+
exp_str = str(int(v)) if v.denominator == 1 else str(v)
|
|
126
|
+
pos.append(f"{name}^{exp_str}")
|
|
127
|
+
else:
|
|
128
|
+
abs_v = -v
|
|
129
|
+
if abs_v == 1:
|
|
130
|
+
neg.append(name)
|
|
131
|
+
else:
|
|
132
|
+
exp_str = str(int(abs_v)) if abs_v.denominator == 1 else str(abs_v)
|
|
133
|
+
neg.append(f"{name}^{exp_str}")
|
|
134
|
+
if not pos and not neg:
|
|
135
|
+
return "dimensionless"
|
|
136
|
+
result = "*".join(pos) if pos else "1"
|
|
137
|
+
if neg:
|
|
138
|
+
result += "/" + "*".join(neg)
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
def si_str(self) -> str:
|
|
142
|
+
"""Exponent notation used in error messages, e.g. 'L^1*T^-1'"""
|
|
143
|
+
parts = []
|
|
144
|
+
for k, v in self._exponents.items():
|
|
145
|
+
if v == 0:
|
|
146
|
+
continue
|
|
147
|
+
exp_str = str(int(v)) if v.denominator == 1 else str(v)
|
|
148
|
+
parts.append(f"{k}^{exp_str}")
|
|
149
|
+
return "*".join(parts) if parts else "1"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Convenience singletons for the 7 SI base dimensions.
|
|
153
|
+
dimensionless = Dimension()
|
|
154
|
+
Length = Dimension(L=1)
|
|
155
|
+
Mass = Dimension(M=1)
|
|
156
|
+
Time = Dimension(T=1)
|
|
157
|
+
Current = Dimension(I=1)
|
|
158
|
+
Temperature = Dimension(Theta=1)
|
|
159
|
+
Amount = Dimension(N=1)
|
|
160
|
+
Luminosity = Dimension(J=1)
|
unitful/exceptions.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Exceptions raised by unitful"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DimensionError(TypeError):
|
|
7
|
+
"""Raised when a dimensional operation is invalid
|
|
8
|
+
|
|
9
|
+
Provides a human-readable message describing the mismatch, including the
|
|
10
|
+
values involved and their dimensions
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def incompatible(
|
|
15
|
+
cls,
|
|
16
|
+
op: str,
|
|
17
|
+
left: object,
|
|
18
|
+
right: object,
|
|
19
|
+
left_dim: str,
|
|
20
|
+
right_dim: str,
|
|
21
|
+
) -> DimensionError:
|
|
22
|
+
"""Mismatch between two operands (e.g. add/subtract)"""
|
|
23
|
+
msg = (
|
|
24
|
+
f"Cannot {op} [{left_dim}] and [{right_dim}]\n"
|
|
25
|
+
f" left: {left!r} -> dimensions: {left_dim}\n"
|
|
26
|
+
f" right: {right!r} -> dimensions: {right_dim}"
|
|
27
|
+
)
|
|
28
|
+
return cls(msg)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def wrong_unit(
|
|
32
|
+
cls,
|
|
33
|
+
expected_dim: str,
|
|
34
|
+
got_dim: str,
|
|
35
|
+
got: object,
|
|
36
|
+
) -> DimensionError:
|
|
37
|
+
"""Conversion to an incompatible unit"""
|
|
38
|
+
msg = (
|
|
39
|
+
f"Cannot convert [{got_dim}] to [{expected_dim}]\n"
|
|
40
|
+
f" value: {got!r} -> dimensions: {got_dim}\n"
|
|
41
|
+
f" expected: dimensions: {expected_dim}"
|
|
42
|
+
)
|
|
43
|
+
return cls(msg)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def wrong_argument(
|
|
47
|
+
cls,
|
|
48
|
+
func_name: str,
|
|
49
|
+
param: str,
|
|
50
|
+
expected_dim: str,
|
|
51
|
+
got: object,
|
|
52
|
+
got_dim: str,
|
|
53
|
+
) -> DimensionError:
|
|
54
|
+
"""Argument passed to a @requires-decorated function has wrong dimensions"""
|
|
55
|
+
msg = (
|
|
56
|
+
f"Expected [{expected_dim}], got [{got_dim}]\n"
|
|
57
|
+
f" function: {func_name}({param}, ...)\n"
|
|
58
|
+
f" parameter: {param}\n"
|
|
59
|
+
f" got: {got!r} -> dimensions: {got_dim}\n"
|
|
60
|
+
f" expected: dimensions: {expected_dim}"
|
|
61
|
+
)
|
|
62
|
+
return cls(msg)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def bare_value(
|
|
66
|
+
cls,
|
|
67
|
+
func_name: str,
|
|
68
|
+
param: str,
|
|
69
|
+
expected_dim: str,
|
|
70
|
+
got: object,
|
|
71
|
+
) -> DimensionError:
|
|
72
|
+
"""A bare number was passed where a Quantity is required"""
|
|
73
|
+
msg = (
|
|
74
|
+
f"Expected [{expected_dim}], got a bare number\n"
|
|
75
|
+
f" function: {func_name}({param}, ...)\n"
|
|
76
|
+
f" parameter: {param}\n"
|
|
77
|
+
f" got: {got!r} (no unit)\n"
|
|
78
|
+
f" expected: dimensions: {expected_dim}"
|
|
79
|
+
)
|
|
80
|
+
return cls(msg)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def wrong_return(
|
|
84
|
+
cls,
|
|
85
|
+
func_name: str,
|
|
86
|
+
expected_dim: str,
|
|
87
|
+
got: object,
|
|
88
|
+
got_dim: str,
|
|
89
|
+
) -> DimensionError:
|
|
90
|
+
"""Return value of a @returns-decorated function has wrong dimensions"""
|
|
91
|
+
msg = (
|
|
92
|
+
f"Return value has wrong dimensions: expected [{expected_dim}], got [{got_dim}]\n"
|
|
93
|
+
f" function: {func_name}\n"
|
|
94
|
+
f" got: {got!r} -> dimensions: {got_dim}\n"
|
|
95
|
+
f" expected: dimensions: {expected_dim}"
|
|
96
|
+
)
|
|
97
|
+
return cls(msg)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def temperature_arithmetic(cls, op: str) -> DimensionError:
|
|
101
|
+
"""Arithmetic on offset temperature units is ambiguous"""
|
|
102
|
+
msg = (
|
|
103
|
+
f"Cannot {op} offset temperature quantities (degC / degF) directly.\n"
|
|
104
|
+
" Convert to Kelvin first: q.to(K)"
|
|
105
|
+
)
|
|
106
|
+
return cls(msg)
|
unitful/formatting.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Formatting helpers for Quantity.__format__"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from re import Match
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _split_spec(format_spec: str) -> tuple[str, str]:
|
|
10
|
+
"""Split '0.2f~P' into ('0.2f', 'P'). Returns ('', '') for empty spec"""
|
|
11
|
+
match = re.match(r"^([^~]*)(?:~([A-Za-z]+))?$", format_spec)
|
|
12
|
+
if not match:
|
|
13
|
+
return format_spec, ""
|
|
14
|
+
num_spec, mode = match.group(1) or "", match.group(2) or ""
|
|
15
|
+
return num_spec, mode.upper()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _format_number(value: float, num_spec: str) -> str:
|
|
19
|
+
"""Format a bare float with a numeric format spec"""
|
|
20
|
+
if num_spec:
|
|
21
|
+
return format(value, num_spec)
|
|
22
|
+
return str(value)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Unicode superscript digits/signs
|
|
26
|
+
_SUPERSCRIPTS = str.maketrans("0123456789+-", "\u2070\u00b9\u00b2\u00b3\u2074\u2075\u2076\u2077\u2078\u2079\u207a\u207b")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _to_superscript(s: str) -> str:
|
|
30
|
+
return s.translate(_SUPERSCRIPTS)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _unit_to_unicode(unit_str: str) -> str:
|
|
34
|
+
"""Convert 'm/s^2' style unit string to Unicode superscript form"""
|
|
35
|
+
# Replace ^ followed by digits/sign with superscript chars
|
|
36
|
+
def replace_exp(m: Match[str]) -> str:
|
|
37
|
+
return _to_superscript(m.group(1))
|
|
38
|
+
|
|
39
|
+
result = re.sub(r"\^([-+]?\d+(?:/\d+)?)", replace_exp, unit_str)
|
|
40
|
+
# Replace * with middle dot (Unicode U+00B7)
|
|
41
|
+
result = result.replace("*", "\u00b7")
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _unit_to_latex(unit_str: str) -> str:
|
|
46
|
+
"""Convert unit string to LaTeX \\mathrm notation"""
|
|
47
|
+
# Split numerator / denominator on '/'
|
|
48
|
+
if "/" in unit_str:
|
|
49
|
+
parts = unit_str.split("/", 1)
|
|
50
|
+
num = _part_to_latex(parts[0])
|
|
51
|
+
den = _part_to_latex(parts[1])
|
|
52
|
+
return rf"\frac{{{num}}}{{{den}}}"
|
|
53
|
+
return _part_to_latex(unit_str)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _part_to_latex(s: str) -> str:
|
|
57
|
+
tokens = s.split("*")
|
|
58
|
+
result = []
|
|
59
|
+
for tok in tokens:
|
|
60
|
+
if "^" in tok:
|
|
61
|
+
base, exp = tok.split("^", 1)
|
|
62
|
+
result.append(rf"\mathrm{{{base}}}^{{{exp}}}")
|
|
63
|
+
else:
|
|
64
|
+
result.append(rf"\mathrm{{{tok}}}")
|
|
65
|
+
return r"\," .join(result)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _sci_to_unicode(value: float, num_spec: str) -> str:
|
|
69
|
+
"""Format value with possible scientific notation in Unicode style"""
|
|
70
|
+
formatted = _format_number(value, num_spec)
|
|
71
|
+
# Check for e-notation
|
|
72
|
+
if "e" in formatted or "E" in formatted:
|
|
73
|
+
mantissa, exp_part = re.split(r"[eE]", formatted, maxsplit=1)
|
|
74
|
+
exp_int = int(exp_part)
|
|
75
|
+
return f"{mantissa} \u00d7 10{_to_superscript(str(exp_int))}"
|
|
76
|
+
return formatted
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def format_plain(value: float, unit_str: str, num_spec: str) -> str:
|
|
80
|
+
return f"{_format_number(value, num_spec)} {unit_str}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def format_unicode(value: float, unit_str: str, num_spec: str) -> str:
|
|
84
|
+
num_part = _sci_to_unicode(value, num_spec)
|
|
85
|
+
unit_part = _unit_to_unicode(unit_str)
|
|
86
|
+
return f"{num_part} {unit_part}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def format_latex(value: float, unit_str: str, num_spec: str) -> str:
|
|
90
|
+
formatted = _format_number(value, num_spec)
|
|
91
|
+
if "e" in formatted or "E" in formatted:
|
|
92
|
+
mantissa, exp_part = re.split(r"[eE]", formatted, maxsplit=1)
|
|
93
|
+
exp_int = int(exp_part)
|
|
94
|
+
num_part = rf"{mantissa} \times 10^{{{exp_int}}}"
|
|
95
|
+
else:
|
|
96
|
+
num_part = formatted
|
|
97
|
+
unit_part = _unit_to_latex(unit_str)
|
|
98
|
+
return rf"{num_part}\,{unit_part}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def format_html(value: float, unit_str: str, num_spec: str) -> str:
|
|
102
|
+
inner = format_unicode(value, unit_str, num_spec)
|
|
103
|
+
return f"<span>{inner}</span>"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def apply_format(value: float, unit_str: str, format_spec: str) -> str:
|
|
107
|
+
num_spec, mode = _split_spec(format_spec)
|
|
108
|
+
if mode == "P":
|
|
109
|
+
return format_unicode(value, unit_str, num_spec)
|
|
110
|
+
if mode == "L":
|
|
111
|
+
return format_latex(value, unit_str, num_spec)
|
|
112
|
+
if mode == "H":
|
|
113
|
+
return format_html(value, unit_str, num_spec)
|
|
114
|
+
return format_plain(value, unit_str, num_spec)
|