ucon 0.3.3rc2__py3-none-any.whl → 0.3.5__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.
- tests/ucon/__init__.py +3 -0
- tests/ucon/test_algebra.py +239 -0
- tests/ucon/test_core.py +607 -362
- tests/ucon/test_quantity.py +370 -0
- tests/ucon/test_units.py +7 -3
- ucon/__init__.py +9 -3
- ucon/algebra.py +216 -0
- ucon/core.py +703 -286
- ucon/quantity.py +196 -0
- ucon/units.py +5 -2
- {ucon-0.3.3rc2.dist-info → ucon-0.3.5.dist-info}/METADATA +49 -37
- ucon-0.3.5.dist-info/RECORD +16 -0
- {ucon-0.3.3rc2.dist-info → ucon-0.3.5.dist-info}/WHEEL +1 -1
- ucon-0.3.5.dist-info/licenses/LICENSE +202 -0
- ucon-0.3.5.dist-info/licenses/NOTICE +28 -0
- tests/ucon/test_dimension.py +0 -206
- tests/ucon/test_unit.py +0 -143
- ucon/dimension.py +0 -172
- ucon/unit.py +0 -92
- ucon-0.3.3rc2.dist-info/RECORD +0 -15
- ucon-0.3.3rc2.dist-info/licenses/LICENSE +0 -21
- {ucon-0.3.3rc2.dist-info → ucon-0.3.5.dist-info}/top_level.txt +0 -0
ucon/core.py
CHANGED
|
@@ -1,401 +1,818 @@
|
|
|
1
|
+
# © 2025 The Radiativity Company
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
# See the LICENSE file for details.
|
|
4
|
+
|
|
1
5
|
"""
|
|
2
6
|
ucon.core
|
|
3
7
|
==========
|
|
4
8
|
|
|
5
|
-
Implements the **
|
|
6
|
-
|
|
9
|
+
Implements the **ontological core** of the *ucon* system — the machinery that
|
|
10
|
+
defines the algebra of physical dimensions, magnitude prefixes, and units.
|
|
7
11
|
|
|
8
12
|
Classes
|
|
9
13
|
-------
|
|
10
|
-
- :class:`
|
|
11
|
-
- :class:`Scale` — Enumerates SI and binary magnitude prefixes
|
|
12
|
-
|
|
13
|
-
- :class:`
|
|
14
|
-
|
|
15
|
-
Together, these classes allow full arithmetic, conversion, and introspection
|
|
16
|
-
of physical quantities with explicit dimensional semantics.
|
|
14
|
+
- :class:`Dimension` — Enumerates physical dimensions with algebraic closure over *, /, and **
|
|
15
|
+
- :class:`Scale` — Enumerates SI and binary magnitude prefixes with algebraic closure over *, /
|
|
16
|
+
and with nearest-prefix lookup.
|
|
17
|
+
- :class:`Unit` — Measurable quantity descriptor with algebraic closure over *, /.
|
|
18
|
+
- :class:`UnitProduct` — Product/quotient of Units with simplification and readable rendering.
|
|
17
19
|
"""
|
|
18
|
-
from
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
19
23
|
from enum import Enum
|
|
20
24
|
from functools import lru_cache, reduce, total_ordering
|
|
21
|
-
from
|
|
22
|
-
from math import log10
|
|
25
|
+
from dataclasses import dataclass
|
|
23
26
|
from typing import Dict, Tuple, Union
|
|
24
27
|
|
|
25
|
-
from ucon import
|
|
26
|
-
from ucon.unit import Unit
|
|
28
|
+
from ucon.algebra import Exponent, Vector
|
|
27
29
|
|
|
28
30
|
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"""
|
|
33
|
-
Represents a **base–exponent pair** (e.g., 10³ or 2¹⁰).
|
|
34
|
-
|
|
35
|
-
Provides comparison and division semantics used internally to represent
|
|
36
|
-
magnitude prefixes (e.g., kilo, mega, micro).
|
|
31
|
+
# --------------------------------------------------------------------------------------
|
|
32
|
+
# Dimension
|
|
33
|
+
# --------------------------------------------------------------------------------------
|
|
37
34
|
|
|
38
|
-
|
|
35
|
+
class Dimension(Enum):
|
|
39
36
|
"""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
37
|
+
Represents a **physical dimension** defined by a :class:`Vector`.
|
|
38
|
+
Algebra over multiplication/division & exponentiation, with dynamic resolution.
|
|
39
|
+
"""
|
|
40
|
+
none = Vector()
|
|
41
|
+
|
|
42
|
+
# -- BASIS ---------------------------------------
|
|
43
|
+
time = Vector(1, 0, 0, 0, 0, 0, 0)
|
|
44
|
+
length = Vector(0, 1, 0, 0, 0, 0, 0)
|
|
45
|
+
mass = Vector(0, 0, 1, 0, 0, 0, 0)
|
|
46
|
+
current = Vector(0, 0, 0, 1, 0, 0, 0)
|
|
47
|
+
temperature = Vector(0, 0, 0, 0, 1, 0, 0)
|
|
48
|
+
luminous_intensity = Vector(0, 0, 0, 0, 0, 1, 0)
|
|
49
|
+
amount_of_substance = Vector(0, 0, 0, 0, 0, 0, 1)
|
|
50
|
+
# ------------------------------------------------
|
|
51
|
+
|
|
52
|
+
acceleration = Vector(-2, 1, 0, 0, 0, 0, 0)
|
|
53
|
+
angular_momentum = Vector(-1, 2, 1, 0, 0, 0, 0)
|
|
54
|
+
area = Vector(0, 2, 0, 0, 0, 0, 0)
|
|
55
|
+
capacitance = Vector(4, -2, -1, 2, 0, 0, 0)
|
|
56
|
+
charge = Vector(1, 0, 0, 1, 0, 0, 0)
|
|
57
|
+
conductance = Vector(3, -2, -1, 2, 0, 0, 0)
|
|
58
|
+
conductivity = Vector(3, -3, -1, 2, 0, 0, 0)
|
|
59
|
+
density = Vector(0, -3, 1, 0, 0, 0, 0)
|
|
60
|
+
electric_field_strength = Vector(-3, 1, 1, -1, 0, 0, 0)
|
|
61
|
+
energy = Vector(-2, 2, 1, 0, 0, 0, 0)
|
|
62
|
+
entropy = Vector(-2, 2, 1, 0, -1, 0, 0)
|
|
63
|
+
force = Vector(-2, 1, 1, 0, 0, 0, 0)
|
|
64
|
+
frequency = Vector(-1, 0, 0, 0, 0, 0, 0)
|
|
65
|
+
gravitation = Vector(-2, 3, -1, 0, 0, 0, 0)
|
|
66
|
+
illuminance = Vector(0, -2, 0, 0, 0, 1, 0)
|
|
67
|
+
inductance = Vector(-2, 2, 1, -2, 0, 0, 0)
|
|
68
|
+
magnetic_flux = Vector(-2, 2, 1, -1, 0, 0, 0)
|
|
69
|
+
magnetic_flux_density = Vector(-2, 0, 1, -1, 0, 0, 0)
|
|
70
|
+
magnetic_permeability = Vector(-2, 1, 1, -2, 0, 0, 0)
|
|
71
|
+
molar_mass = Vector(0, 0, 1, 0, 0, 0, -1)
|
|
72
|
+
molar_volume = Vector(0, 3, 0, 0, 0, 0, -1)
|
|
73
|
+
momentum = Vector(-1, 1, 1, 0, 0, 0, 0)
|
|
74
|
+
permittivity = Vector(4, -3, -1, 2, 0, 0, 0)
|
|
75
|
+
power = Vector(-3, 2, 1, 0, 0, 0, 0)
|
|
76
|
+
pressure = Vector(-2, -1, 1, 0, 0, 0, 0)
|
|
77
|
+
resistance = Vector(-3, 2, 1, -2, 0, 0, 0)
|
|
78
|
+
resistivity = Vector(-3, 3, 1, -2, 0, 0, 0)
|
|
79
|
+
specific_heat_capacity = Vector(-2, 2, 0, 0, -1, 0, 0)
|
|
80
|
+
thermal_conductivity = Vector(-3, 1, 1, 0, -1, 0, 0)
|
|
81
|
+
velocity = Vector(-1, 1, 0, 0, 0, 0, 0)
|
|
82
|
+
voltage = Vector(-3, 2, 1, -1, 0, 0, 0)
|
|
83
|
+
volume = Vector(0, 3, 0, 0, 0, 0, 0)
|
|
72
84
|
|
|
73
|
-
|
|
85
|
+
@classmethod
|
|
86
|
+
def _resolve(cls, vector: 'Vector') -> 'Dimension':
|
|
87
|
+
for dim in cls:
|
|
88
|
+
if dim.value == vector:
|
|
89
|
+
return dim
|
|
90
|
+
dyn = object.__new__(cls)
|
|
91
|
+
dyn._name_ = f"derived({vector})"
|
|
92
|
+
dyn._value_ = vector
|
|
93
|
+
return dyn
|
|
94
|
+
|
|
95
|
+
def __truediv__(self, dimension: 'Dimension') -> 'Dimension':
|
|
96
|
+
if not isinstance(dimension, Dimension):
|
|
97
|
+
raise TypeError(f"Cannot divide Dimension by non-Dimension type: {type(dimension)}")
|
|
98
|
+
return self._resolve(self.value - dimension.value)
|
|
99
|
+
|
|
100
|
+
def __mul__(self, dimension: 'Dimension') -> 'Dimension':
|
|
101
|
+
if not isinstance(dimension, Dimension):
|
|
102
|
+
raise TypeError(f"Cannot multiply Dimension by non-Dimension type: {type(dimension)}")
|
|
103
|
+
return self._resolve(self.value + dimension.value)
|
|
104
|
+
|
|
105
|
+
def __pow__(self, power: Union[int, float]) -> 'Dimension':
|
|
106
|
+
if power == 1:
|
|
107
|
+
return self
|
|
108
|
+
if power == 0:
|
|
109
|
+
return Dimension.none
|
|
110
|
+
new_vector = self.value * power
|
|
111
|
+
return self._resolve(new_vector)
|
|
74
112
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
- If bases differ, returns a numeric ratio (float).
|
|
80
|
-
"""
|
|
81
|
-
if not isinstance(other, Exponent):
|
|
82
|
-
return NotImplemented
|
|
83
|
-
if self.base == other.base:
|
|
84
|
-
return Exponent(self.base, self.power - other.power)
|
|
85
|
-
return self.evaluated / other.evaluated
|
|
113
|
+
def __eq__(self, dimension) -> bool:
|
|
114
|
+
if not isinstance(dimension, Dimension):
|
|
115
|
+
raise TypeError(f"Cannot compare Dimension with non-Dimension type: {type(dimension)}")
|
|
116
|
+
return self.value == dimension.value
|
|
86
117
|
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
return NotImplemented
|
|
90
|
-
if self.base == other.base:
|
|
91
|
-
return Exponent(self.base, self.power + other.power)
|
|
92
|
-
return float(self.evaluated * other.evaluated)
|
|
118
|
+
def __hash__(self) -> int:
|
|
119
|
+
return hash(self.value)
|
|
93
120
|
|
|
94
|
-
|
|
121
|
+
def __bool__(self):
|
|
122
|
+
return False if self is Dimension.none else True
|
|
95
123
|
|
|
96
|
-
def to_base(self, new_base: int) -> "Exponent":
|
|
97
|
-
"""
|
|
98
|
-
Convert this Exponent to another base representation.
|
|
99
124
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"""
|
|
104
|
-
if new_base not in self.bases:
|
|
105
|
-
supported = ", ".join(map(str, self.bases))
|
|
106
|
-
raise ValueError(f"Unsupported base {new_base!r}. Supported bases: {supported}")
|
|
107
|
-
new_power = self.bases[new_base](self.evaluated)
|
|
108
|
-
return Exponent(new_base, new_power)
|
|
125
|
+
# --------------------------------------------------------------------------------------
|
|
126
|
+
# Scale (with descriptor)
|
|
127
|
+
# --------------------------------------------------------------------------------------
|
|
109
128
|
|
|
110
|
-
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class ScaleDescriptor:
|
|
131
|
+
exponent: Exponent
|
|
132
|
+
shorthand: str
|
|
133
|
+
alias: str
|
|
111
134
|
|
|
112
|
-
|
|
113
|
-
|
|
135
|
+
@property
|
|
136
|
+
def evaluated(self) -> float:
|
|
137
|
+
return self.exponent.evaluated
|
|
114
138
|
|
|
115
|
-
|
|
116
|
-
|
|
139
|
+
@property
|
|
140
|
+
def base(self) -> int:
|
|
141
|
+
return self.exponent.base
|
|
117
142
|
|
|
118
|
-
|
|
143
|
+
@property
|
|
144
|
+
def power(self) -> Union[int, float]:
|
|
145
|
+
return self.exponent.power
|
|
119
146
|
|
|
120
|
-
def
|
|
121
|
-
return
|
|
147
|
+
def parts(self) -> Tuple[int, Union[int, float]]:
|
|
148
|
+
return (self.base, self.power)
|
|
122
149
|
|
|
123
|
-
def
|
|
124
|
-
|
|
150
|
+
def __repr__(self):
|
|
151
|
+
tag = self.alias or self.shorthand or "1"
|
|
152
|
+
return f"<ScaleDescriptor {tag}: {self.base}^{self.power}>"
|
|
125
153
|
|
|
126
154
|
|
|
155
|
+
@total_ordering
|
|
127
156
|
class Scale(Enum):
|
|
128
|
-
|
|
129
|
-
|
|
157
|
+
# Binary
|
|
158
|
+
gibi = ScaleDescriptor(Exponent(2, 30), "Gi", "gibi")
|
|
159
|
+
mebi = ScaleDescriptor(Exponent(2, 20), "Mi", "mebi")
|
|
160
|
+
kibi = ScaleDescriptor(Exponent(2, 10), "Ki", "kibi")
|
|
161
|
+
|
|
162
|
+
# Decimal
|
|
163
|
+
peta = ScaleDescriptor(Exponent(10, 15), "P", "peta")
|
|
164
|
+
tera = ScaleDescriptor(Exponent(10, 12), "T", "tera")
|
|
165
|
+
giga = ScaleDescriptor(Exponent(10, 9), "G", "giga")
|
|
166
|
+
mega = ScaleDescriptor(Exponent(10, 6), "M", "mega")
|
|
167
|
+
kilo = ScaleDescriptor(Exponent(10, 3), "k", "kilo")
|
|
168
|
+
hecto = ScaleDescriptor(Exponent(10, 2), "h", "hecto")
|
|
169
|
+
deca = ScaleDescriptor(Exponent(10, 1), "da", "deca")
|
|
170
|
+
one = ScaleDescriptor(Exponent(10, 0), "", "")
|
|
171
|
+
deci = ScaleDescriptor(Exponent(10,-1), "d", "deci")
|
|
172
|
+
centi = ScaleDescriptor(Exponent(10,-2), "c", "centi")
|
|
173
|
+
milli = ScaleDescriptor(Exponent(10,-3), "m", "milli")
|
|
174
|
+
micro = ScaleDescriptor(Exponent(10,-6), "µ", "micro")
|
|
175
|
+
nano = ScaleDescriptor(Exponent(10,-9), "n", "nano")
|
|
176
|
+
pico = ScaleDescriptor(Exponent(10,-12), "p", "pico")
|
|
177
|
+
femto = ScaleDescriptor(Exponent(10,-15), "f", "femto")
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def descriptor(self) -> ScaleDescriptor:
|
|
181
|
+
return self.value
|
|
130
182
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
183
|
+
@property
|
|
184
|
+
def shorthand(self) -> str:
|
|
185
|
+
return self.value.shorthand
|
|
134
186
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
mebi = Exponent(2, 20)
|
|
139
|
-
kibi = Exponent(2, 10)
|
|
140
|
-
giga = Exponent(10, 9)
|
|
141
|
-
mega = Exponent(10, 6)
|
|
142
|
-
kilo = Exponent(10, 3)
|
|
143
|
-
hecto = Exponent(10, 2)
|
|
144
|
-
deca = Exponent(10, 1)
|
|
145
|
-
one = Exponent(10, 0)
|
|
146
|
-
deci = Exponent(10,-1)
|
|
147
|
-
centi = Exponent(10,-2)
|
|
148
|
-
milli = Exponent(10,-3)
|
|
149
|
-
micro = Exponent(10,-6)
|
|
150
|
-
nano = Exponent(10,-9)
|
|
151
|
-
_kibi = Exponent(2,-10) # "kibi" inverse
|
|
152
|
-
_mebi = Exponent(2,-20) # "mebi" inverse
|
|
153
|
-
_gibi = Exponent(2,-30) # "gibi" inverse
|
|
187
|
+
@property
|
|
188
|
+
def alias(self) -> str:
|
|
189
|
+
return self.value.alias
|
|
154
190
|
|
|
155
191
|
@staticmethod
|
|
156
192
|
@lru_cache(maxsize=1)
|
|
157
193
|
def all() -> Dict[Tuple[int, int], str]:
|
|
158
|
-
"""Return a map from (base, power) → Scale name."""
|
|
159
194
|
return {(s.value.base, s.value.power): s.name for s in Scale}
|
|
160
195
|
|
|
161
|
-
@staticmethod
|
|
162
|
-
@lru_cache(maxsize=1)
|
|
163
|
-
def by_value() -> Dict[float, str]:
|
|
164
|
-
"""
|
|
165
|
-
Return a map from evaluated numeric value → Scale name.
|
|
166
|
-
Cached after first access.
|
|
167
|
-
"""
|
|
168
|
-
return {round(s.value.evaluated, 15): s.name for s in Scale}
|
|
169
|
-
|
|
170
196
|
@classmethod
|
|
171
197
|
@lru_cache(maxsize=1)
|
|
172
198
|
def _decimal_scales(cls):
|
|
173
|
-
|
|
174
|
-
return list(s for s in cls if s.value.base == 10)
|
|
199
|
+
return [s for s in cls if s.value.base == 10]
|
|
175
200
|
|
|
176
201
|
@classmethod
|
|
177
202
|
@lru_cache(maxsize=1)
|
|
178
203
|
def _binary_scales(cls):
|
|
179
|
-
|
|
180
|
-
return list(s for s in cls if s.value.base == 2)
|
|
204
|
+
return [s for s in cls if s.value.base == 2]
|
|
181
205
|
|
|
182
|
-
@
|
|
183
|
-
|
|
206
|
+
@staticmethod
|
|
207
|
+
@lru_cache(maxsize=1)
|
|
208
|
+
def by_value() -> Dict[float, str]:
|
|
184
209
|
"""
|
|
185
|
-
Return
|
|
186
|
-
|
|
210
|
+
Return a map from evaluated numeric value → Scale name.
|
|
211
|
+
Cached after first access.
|
|
187
212
|
"""
|
|
213
|
+
return {round(s.value.exponent.evaluated, 15): s.name for s in Scale}
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def nearest(cls, value: float, include_binary: bool = False, undershoot_bias: float = 0.75) -> "Scale":
|
|
188
217
|
if value == 0:
|
|
189
218
|
return Scale.one
|
|
190
|
-
|
|
191
219
|
abs_val = abs(value)
|
|
192
|
-
candidates = cls
|
|
220
|
+
candidates = list(cls) if include_binary else cls._decimal_scales()
|
|
193
221
|
|
|
194
222
|
def distance(scale: "Scale") -> float:
|
|
195
223
|
ratio = abs_val / scale.value.evaluated
|
|
196
|
-
diff = log10(ratio)
|
|
197
|
-
# Bias overshoots slightly more than undershoots
|
|
224
|
+
diff = math.log10(ratio)
|
|
198
225
|
if ratio < 1:
|
|
199
226
|
diff /= undershoot_bias
|
|
200
227
|
return abs(diff)
|
|
201
228
|
|
|
202
229
|
return min(candidates, key=distance)
|
|
203
230
|
|
|
204
|
-
def
|
|
205
|
-
|
|
206
|
-
Multiply two Scales together.
|
|
231
|
+
def __eq__(self, other: 'Scale'):
|
|
232
|
+
return self.value.exponent == other.value.exponent
|
|
207
233
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
234
|
+
def __gt__(self, other: 'Scale'):
|
|
235
|
+
return self.value.exponent > other.value.exponent
|
|
236
|
+
|
|
237
|
+
def __hash__(self):
|
|
238
|
+
e = self.value.exponent
|
|
239
|
+
return hash((e.base, round(e.power, 12)))
|
|
240
|
+
|
|
241
|
+
def __mul__(self, other):
|
|
242
|
+
# --- Case 1: applying Scale to simple Unit --------------------
|
|
243
|
+
if isinstance(other, Unit):
|
|
244
|
+
return UnitProduct({UnitFactor(unit=other, scale=self): 1})
|
|
245
|
+
|
|
246
|
+
# --- Case 2: other cases are NOT handled here -----------------
|
|
247
|
+
# UnitProduct scaling is handled solely by UnitProduct.__rmul__
|
|
248
|
+
if isinstance(other, UnitProduct):
|
|
212
249
|
return NotImplemented
|
|
213
250
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
251
|
+
# --- Case 3: Scale * Scale algebra ----------------------------
|
|
252
|
+
if isinstance(other, Scale):
|
|
253
|
+
if self is Scale.one:
|
|
254
|
+
return other
|
|
255
|
+
if other is Scale.one:
|
|
256
|
+
return self
|
|
218
257
|
|
|
219
|
-
|
|
220
|
-
|
|
258
|
+
result = self.value.exponent * other.value.exponent
|
|
259
|
+
include_binary = 2 in {self.value.base, other.value.base}
|
|
260
|
+
|
|
261
|
+
if isinstance(result, Exponent):
|
|
262
|
+
match = Scale.all().get(result.parts())
|
|
263
|
+
if match:
|
|
264
|
+
return Scale[match]
|
|
265
|
+
|
|
266
|
+
return Scale.nearest(float(result), include_binary=include_binary)
|
|
267
|
+
|
|
268
|
+
return NotImplemented
|
|
221
269
|
|
|
270
|
+
def __truediv__(self, other):
|
|
271
|
+
if not isinstance(other, Scale):
|
|
272
|
+
return NotImplemented
|
|
273
|
+
if self == other:
|
|
274
|
+
return Scale.one
|
|
275
|
+
result = self.value.exponent / other.value.exponent
|
|
222
276
|
if isinstance(result, Exponent):
|
|
223
277
|
match = Scale.all().get(result.parts())
|
|
224
278
|
if match:
|
|
225
279
|
return Scale[match]
|
|
280
|
+
include_binary = 2 in {self.value.base, other.value.base}
|
|
281
|
+
return Scale.nearest(float(result), include_binary=include_binary)
|
|
226
282
|
|
|
283
|
+
def __pow__(self, power):
|
|
284
|
+
result = self.value.exponent ** power
|
|
285
|
+
if isinstance(result, Exponent):
|
|
286
|
+
match = Scale.all().get(result.parts())
|
|
287
|
+
if match:
|
|
288
|
+
return Scale[match]
|
|
289
|
+
include_binary = self.value.base == 2
|
|
227
290
|
return Scale.nearest(float(result), include_binary=include_binary)
|
|
228
291
|
|
|
229
|
-
|
|
292
|
+
|
|
293
|
+
class Unit:
|
|
294
|
+
"""
|
|
295
|
+
Represents a **unit of measure** associated with a :class:`Dimension`.
|
|
296
|
+
|
|
297
|
+
A Unit is an atomic symbol with no scale information. Scale is handled
|
|
298
|
+
separately by UnitFactor, which pairs a Unit with a Scale.
|
|
299
|
+
|
|
300
|
+
Parameters
|
|
301
|
+
----------
|
|
302
|
+
*aliases : str
|
|
303
|
+
Optional shorthand symbols (e.g., "m", "sec").
|
|
304
|
+
name : str
|
|
305
|
+
Canonical name of the unit (e.g., "meter").
|
|
306
|
+
dimension : Dimension
|
|
307
|
+
The physical dimension this unit represents.
|
|
308
|
+
"""
|
|
309
|
+
def __init__(
|
|
310
|
+
self,
|
|
311
|
+
*aliases: str,
|
|
312
|
+
name: str = "",
|
|
313
|
+
dimension: Dimension = Dimension.none,
|
|
314
|
+
):
|
|
315
|
+
self.aliases = aliases
|
|
316
|
+
self.name = name
|
|
317
|
+
self.dimension = dimension
|
|
318
|
+
|
|
319
|
+
# ----------------- symbolic helpers -----------------
|
|
320
|
+
|
|
321
|
+
def _norm(self, aliases: tuple[str, ...]) -> tuple[str, ...]:
|
|
322
|
+
"""Normalize alias bag: drop empty/whitespace-only aliases."""
|
|
323
|
+
return tuple(a for a in aliases if a.strip())
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def shorthand(self) -> str:
|
|
230
327
|
"""
|
|
231
|
-
|
|
328
|
+
Symbol used in expressions (e.g., 'm', 's').
|
|
329
|
+
For dimensionless units, returns ''.
|
|
232
330
|
|
|
233
|
-
|
|
234
|
-
If no exact prefix match exists, returns the nearest known Scale.
|
|
331
|
+
Note: Scale prefixes are handled by UnitFactor.shorthand, not here.
|
|
235
332
|
"""
|
|
236
|
-
if
|
|
333
|
+
if self.dimension == Dimension.none:
|
|
334
|
+
return ""
|
|
335
|
+
base = (self.aliases[0] if self.aliases else self.name) or ""
|
|
336
|
+
return base.strip()
|
|
337
|
+
|
|
338
|
+
# ----------------- algebra -----------------
|
|
339
|
+
|
|
340
|
+
def __mul__(self, other):
|
|
341
|
+
"""
|
|
342
|
+
Unit * Unit -> UnitProduct
|
|
343
|
+
Unit * UnitProduct -> UnitProduct
|
|
344
|
+
"""
|
|
345
|
+
if isinstance(other, UnitProduct):
|
|
346
|
+
# let UnitProduct handle merging
|
|
347
|
+
return other.__rmul__(self)
|
|
348
|
+
|
|
349
|
+
if isinstance(other, Unit):
|
|
350
|
+
return UnitProduct({self: 1, other: 1})
|
|
351
|
+
|
|
352
|
+
return NotImplemented
|
|
353
|
+
|
|
354
|
+
def __truediv__(self, other):
|
|
355
|
+
"""
|
|
356
|
+
Unit / Unit or Unit / UnitProduct => UnitProduct
|
|
357
|
+
"""
|
|
358
|
+
if isinstance(other, UnitProduct):
|
|
359
|
+
combined = {self: 1.0}
|
|
360
|
+
for u, exp in other.factors.items():
|
|
361
|
+
combined[u] = combined.get(u, 0.0) - exp
|
|
362
|
+
return UnitProduct(combined)
|
|
363
|
+
|
|
364
|
+
if not isinstance(other, Unit):
|
|
237
365
|
return NotImplemented
|
|
238
366
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
367
|
+
# same physical unit → cancel to dimensionless
|
|
368
|
+
if (
|
|
369
|
+
self.dimension == other.dimension
|
|
370
|
+
and self.name == other.name
|
|
371
|
+
and self._norm(self.aliases) == self._norm(other.aliases)
|
|
372
|
+
):
|
|
373
|
+
return Unit() # dimensionless (matches units.none)
|
|
374
|
+
|
|
375
|
+
# dividing by dimensionless → no change
|
|
376
|
+
if other.dimension == Dimension.none:
|
|
242
377
|
return self
|
|
243
378
|
|
|
244
|
-
|
|
379
|
+
# general case: form composite (self^1 * other^-1)
|
|
380
|
+
return UnitProduct({self: 1, other: -1})
|
|
245
381
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return Scale.nearest(float(result), include_binary=should_consider_binary)
|
|
382
|
+
def __pow__(self, power):
|
|
383
|
+
"""
|
|
384
|
+
Unit ** n => UnitProduct with that exponent.
|
|
385
|
+
"""
|
|
386
|
+
return UnitProduct({self: power})
|
|
252
387
|
|
|
253
|
-
|
|
254
|
-
if isinstance(result, Exponent):
|
|
255
|
-
match = Scale.all().get(result.parts())
|
|
256
|
-
if match:
|
|
257
|
-
return Scale[match]
|
|
258
|
-
else:
|
|
259
|
-
return Scale.nearest(float(result), include_binary=should_consider_binary)
|
|
388
|
+
# ----------------- equality & hashing -----------------
|
|
260
389
|
|
|
261
|
-
def
|
|
262
|
-
|
|
390
|
+
def __eq__(self, other):
|
|
391
|
+
if isinstance(other, UnitProduct):
|
|
392
|
+
return other.__eq__(self)
|
|
393
|
+
if not isinstance(other, Unit):
|
|
394
|
+
return NotImplemented
|
|
395
|
+
return (
|
|
396
|
+
self.dimension == other.dimension
|
|
397
|
+
and self.name == other.name
|
|
398
|
+
and self._norm(self.aliases) == self._norm(other.aliases)
|
|
399
|
+
)
|
|
263
400
|
|
|
264
|
-
def
|
|
265
|
-
return
|
|
401
|
+
def __hash__(self):
|
|
402
|
+
return hash(
|
|
403
|
+
(
|
|
404
|
+
self.name,
|
|
405
|
+
self._norm(self.aliases),
|
|
406
|
+
self.dimension,
|
|
407
|
+
)
|
|
408
|
+
)
|
|
266
409
|
|
|
267
|
-
|
|
268
|
-
return self.value == other.value
|
|
410
|
+
# ----------------- representation -----------------
|
|
269
411
|
|
|
412
|
+
def __repr__(self):
|
|
413
|
+
"""
|
|
414
|
+
<Unit m>, <Unit kg>, <Unit>, <Unit | velocity>, etc.
|
|
415
|
+
"""
|
|
416
|
+
if self.shorthand:
|
|
417
|
+
return f"<Unit {self.shorthand}>"
|
|
418
|
+
if self.dimension == Dimension.none:
|
|
419
|
+
return "<Unit>"
|
|
420
|
+
return f"<Unit | {self.dimension.name}>"
|
|
270
421
|
|
|
271
|
-
Quantifiable = Union['Number', 'Ratio']
|
|
272
422
|
|
|
273
|
-
@dataclass
|
|
274
|
-
class
|
|
423
|
+
@dataclass(frozen=True)
|
|
424
|
+
class UnitFactor:
|
|
275
425
|
"""
|
|
276
|
-
|
|
426
|
+
A structural pair (unit, scale) used as the *key* inside UnitProduct.
|
|
427
|
+
|
|
428
|
+
- `unit` is a plain Unit (no extra meaning beyond dimension + aliases + name).
|
|
429
|
+
- `scale` is the *expression-level* Scale attached by the user (e.g. milli in mL).
|
|
430
|
+
|
|
431
|
+
Two UnitFactors are equal iff both `unit` and `scale` are equal, so components
|
|
432
|
+
with the same base unit and same scale truly merge.
|
|
277
433
|
|
|
278
|
-
|
|
279
|
-
|
|
434
|
+
NOTE: We also implement equality / hashing in a way that allows lookups
|
|
435
|
+
by the underlying Unit to keep working:
|
|
280
436
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
>>> speed
|
|
286
|
-
<2.5 (m/s)>
|
|
437
|
+
m in product.factors
|
|
438
|
+
product.factors[m]
|
|
439
|
+
|
|
440
|
+
still work even though the actual keys are UnitFactor instances.
|
|
287
441
|
"""
|
|
288
|
-
|
|
289
|
-
unit: Unit
|
|
290
|
-
scale: Scale
|
|
442
|
+
|
|
443
|
+
unit: "Unit"
|
|
444
|
+
scale: "Scale"
|
|
445
|
+
|
|
446
|
+
# ------------- Projections (Unit-like surface) -------------------------
|
|
291
447
|
|
|
292
448
|
@property
|
|
293
|
-
def
|
|
294
|
-
|
|
295
|
-
return round(self.quantity * self.scale.value.evaluated, 15)
|
|
449
|
+
def dimension(self):
|
|
450
|
+
return self.unit.dimension
|
|
296
451
|
|
|
297
|
-
|
|
298
|
-
|
|
452
|
+
@property
|
|
453
|
+
def aliases(self):
|
|
454
|
+
return getattr(self.unit, "aliases", ())
|
|
299
455
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return
|
|
456
|
+
@property
|
|
457
|
+
def name(self):
|
|
458
|
+
return getattr(self.unit, "name", "")
|
|
303
459
|
|
|
304
|
-
|
|
305
|
-
|
|
460
|
+
@property
|
|
461
|
+
def shorthand(self) -> str:
|
|
462
|
+
"""
|
|
463
|
+
Render something like 'mg' for UnitFactor(gram, milli),
|
|
464
|
+
or 'L' for UnitFactor(liter, one).
|
|
465
|
+
"""
|
|
466
|
+
base = ""
|
|
467
|
+
if self.aliases:
|
|
468
|
+
base = self.aliases[0]
|
|
469
|
+
elif self.name:
|
|
470
|
+
base = self.name
|
|
306
471
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
return NotImplemented
|
|
472
|
+
prefix = "" if self.scale is Scale.one else self.scale.shorthand
|
|
473
|
+
return f"{prefix}{base}".strip()
|
|
310
474
|
|
|
311
|
-
|
|
312
|
-
other = other.evaluate()
|
|
475
|
+
# ------------- Identity & hashing -------------------------------------
|
|
313
476
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
unit=self.unit * other.unit,
|
|
317
|
-
scale=self.scale * other.scale,
|
|
318
|
-
)
|
|
477
|
+
def __repr__(self) -> str:
|
|
478
|
+
return f"UnitFactor(unit={self.unit!r}, scale={self.scale!r})"
|
|
319
479
|
|
|
320
|
-
def
|
|
321
|
-
|
|
322
|
-
|
|
480
|
+
def __hash__(self) -> int:
|
|
481
|
+
# Important: share hash space with the underlying Unit so that
|
|
482
|
+
# lookups by Unit (e.g., factors[unit]) work against UnitFactor keys.
|
|
483
|
+
return hash(self.unit)
|
|
323
484
|
|
|
324
|
-
|
|
325
|
-
|
|
485
|
+
def __eq__(self, other):
|
|
486
|
+
# UnitFactor vs UnitFactor → structural equality
|
|
487
|
+
if isinstance(other, UnitFactor):
|
|
488
|
+
return (self.unit == other.unit) and (self.scale == other.scale)
|
|
326
489
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
490
|
+
# UnitFactor vs Unit → equal iff underlying unit matches AND
|
|
491
|
+
# this UnitFactor has Scale.one (since Unit has no scale).
|
|
492
|
+
# This lets `unit in factors` work when `factors` is keyed by UnitFactor.
|
|
493
|
+
if isinstance(other, Unit):
|
|
494
|
+
return self.unit == other and self.scale is Scale.one
|
|
332
495
|
|
|
333
|
-
|
|
334
|
-
if not isinstance(other, (Number, Ratio)):
|
|
335
|
-
raise TypeError(f'Cannot compare Number to non-Number/Ratio type: {type(other)}')
|
|
496
|
+
return NotImplemented
|
|
336
497
|
|
|
337
|
-
elif isinstance(other, Ratio):
|
|
338
|
-
other = other.evaluate()
|
|
339
498
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
499
|
+
class UnitProduct:
|
|
500
|
+
"""
|
|
501
|
+
Represents a product or quotient of Units.
|
|
502
|
+
|
|
503
|
+
Key properties:
|
|
504
|
+
- factors is a dict[UnitFactor, float] mapping (unit, scale) pairs to exponents.
|
|
505
|
+
- Nested UnitProduct instances are flattened.
|
|
506
|
+
- Identical UnitFactors (same underlying unit and same scale) merge exponents.
|
|
507
|
+
- Units with net exponent ~0 are dropped.
|
|
508
|
+
- Dimensionless units (Dimension.none) are dropped.
|
|
509
|
+
- Scaled variants of the same base unit (e.g. L and mL) are grouped by
|
|
510
|
+
(name, dimension, aliases) and their exponents combined; if the net exponent
|
|
511
|
+
is ~0, the whole group is cancelled.
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
_SUPERSCRIPTS = str.maketrans("0123456789-.", "⁰¹²³⁴⁵⁶⁷⁸⁹⁻·")
|
|
515
|
+
|
|
516
|
+
def __init__(self, factors: dict[Unit, float]):
|
|
517
|
+
"""
|
|
518
|
+
Build a UnitProduct with UnitFactor keys, preserving user-provided scales.
|
|
519
|
+
|
|
520
|
+
Key principles:
|
|
521
|
+
- Never canonicalize scale (no implicit preference for Scale.one).
|
|
522
|
+
- Only collapse scaled variants of the same base unit when total exponent == 0.
|
|
523
|
+
- If only one scale variant exists in a group, preserve it exactly.
|
|
524
|
+
- If multiple variants exist and the group exponent != 0, preserve the FIRST
|
|
525
|
+
encountered UnitFactor (keeps user-intent scale).
|
|
526
|
+
"""
|
|
527
|
+
|
|
528
|
+
self.name = ""
|
|
529
|
+
self.aliases = ()
|
|
530
|
+
|
|
531
|
+
merged: dict[UnitFactor, float] = {}
|
|
532
|
+
|
|
533
|
+
# -----------------------------------------------------
|
|
534
|
+
# Helper: normalize Units or UnitFactors to UnitFactor
|
|
535
|
+
# -----------------------------------------------------
|
|
536
|
+
def to_factored(unit_or_fu):
|
|
537
|
+
if isinstance(unit_or_fu, UnitFactor):
|
|
538
|
+
return unit_or_fu
|
|
539
|
+
# Plain Unit has no scale - wrap with Scale.one
|
|
540
|
+
return UnitFactor(unit_or_fu, Scale.one)
|
|
541
|
+
|
|
542
|
+
# -----------------------------------------------------
|
|
543
|
+
# Helper: merge UnitFactors by full (unit, scale) identity
|
|
544
|
+
# -----------------------------------------------------
|
|
545
|
+
def merge_fu(fu: UnitFactor, exponent: float):
|
|
546
|
+
for existing in merged:
|
|
547
|
+
if existing == fu: # UnitFactor.__eq__ handles scale & unit compare
|
|
548
|
+
merged[existing] += exponent
|
|
549
|
+
return
|
|
550
|
+
merged[fu] = merged.get(fu, 0.0) + exponent
|
|
551
|
+
|
|
552
|
+
# -----------------------------------------------------
|
|
553
|
+
# Step 1 — Flatten sources into UnitFactors
|
|
554
|
+
# -----------------------------------------------------
|
|
555
|
+
for key, exp in factors.items():
|
|
556
|
+
if isinstance(key, UnitProduct):
|
|
557
|
+
# Flatten nested UnitProducts
|
|
558
|
+
for inner_fu, inner_exp in key.factors.items():
|
|
559
|
+
merge_fu(inner_fu, inner_exp * exp)
|
|
560
|
+
else:
|
|
561
|
+
merge_fu(to_factored(key), exp)
|
|
562
|
+
|
|
563
|
+
# -----------------------------------------------------
|
|
564
|
+
# Step 2 — Remove exponent-zero & dimensionless UnitFactors
|
|
565
|
+
# -----------------------------------------------------
|
|
566
|
+
simplified: dict[UnitFactor, float] = {}
|
|
567
|
+
for fu, exp in merged.items():
|
|
568
|
+
if abs(exp) < 1e-12:
|
|
569
|
+
continue
|
|
570
|
+
if fu.dimension == Dimension.none:
|
|
571
|
+
continue
|
|
572
|
+
simplified[fu] = exp
|
|
573
|
+
|
|
574
|
+
# -----------------------------------------------------
|
|
575
|
+
# Step 3 — Group by base-unit identity (ignoring scale)
|
|
576
|
+
# -----------------------------------------------------
|
|
577
|
+
groups: dict[tuple, dict[UnitFactor, float]] = {}
|
|
578
|
+
|
|
579
|
+
for fu, exp in simplified.items():
|
|
580
|
+
alias_key = tuple(sorted(a for a in fu.aliases if a))
|
|
581
|
+
group_key = (fu.name, fu.dimension, alias_key)
|
|
582
|
+
groups.setdefault(group_key, {})
|
|
583
|
+
groups[group_key][fu] = groups[group_key].get(fu, 0.0) + exp
|
|
584
|
+
|
|
585
|
+
# -----------------------------------------------------
|
|
586
|
+
# Step 4 — Resolve groups while preserving user scale
|
|
587
|
+
# -----------------------------------------------------
|
|
588
|
+
final: dict[UnitFactor, float] = {}
|
|
589
|
+
|
|
590
|
+
# Track residual scale NUMERICALLY from cancelled units.
|
|
591
|
+
# This accumulates scale factors when units cancel dimensionally
|
|
592
|
+
# but have different scales (e.g., gram / decagram = factor of 0.1).
|
|
593
|
+
# We use a numeric value rather than Scale to preserve precision
|
|
594
|
+
# for arbitrary combinations (especially binary scales like kibi).
|
|
595
|
+
residual_scale_factor: float = 1.0
|
|
596
|
+
|
|
597
|
+
for group_key, bucket in groups.items():
|
|
598
|
+
total_exp = sum(bucket.values())
|
|
599
|
+
|
|
600
|
+
# 4A — Full cancellation (dimensionally)
|
|
601
|
+
# BUT: we must preserve the NET SCALE from the cancelled units!
|
|
602
|
+
if abs(total_exp) < 1e-12:
|
|
603
|
+
# Compute the scale contribution from this cancelled group
|
|
604
|
+
# Each factor contributes: factor.scale.value.evaluated ** exponent
|
|
605
|
+
for fu, exp in bucket.items():
|
|
606
|
+
residual_scale_factor *= fu.scale.value.evaluated ** exp
|
|
607
|
+
continue
|
|
608
|
+
|
|
609
|
+
# 4B — Only one scale variant → preserve exactly
|
|
610
|
+
if len(bucket) == 1:
|
|
611
|
+
fu, exp = next(iter(bucket.items()))
|
|
612
|
+
final[fu] = exp
|
|
613
|
+
continue
|
|
614
|
+
|
|
615
|
+
# 4C — Multiple scale variants, exponent != 0:
|
|
616
|
+
# preserve FIRST encountered UnitFactor.
|
|
617
|
+
# This ensures user scale is preserved.
|
|
618
|
+
# BUT: also accumulate scale from the OTHER variants
|
|
619
|
+
first_fu = next(iter(bucket.keys()))
|
|
620
|
+
final[first_fu] = total_exp
|
|
621
|
+
|
|
622
|
+
# The first_fu will be kept with total_exp, so its scale^total_exp
|
|
623
|
+
# will be folded normally. We need to account for the OTHER factors'
|
|
624
|
+
# scale contributions that are being "absorbed" into this representative.
|
|
625
|
+
for fu, exp in bucket.items():
|
|
626
|
+
if fu is not first_fu:
|
|
627
|
+
# This factor is being absorbed; its scale contribution
|
|
628
|
+
# relative to first_fu needs to be captured
|
|
629
|
+
residual_scale_factor *= fu.scale.value.evaluated ** exp
|
|
630
|
+
|
|
631
|
+
self.factors = final
|
|
632
|
+
|
|
633
|
+
# Store the residual scale factor from cancellations (numeric)
|
|
634
|
+
self._residual_scale_factor = residual_scale_factor
|
|
635
|
+
|
|
636
|
+
# -----------------------------------------------------
|
|
637
|
+
# Step 5 — Derive dimension via exponent algebra
|
|
638
|
+
# -----------------------------------------------------
|
|
639
|
+
self.dimension = reduce(
|
|
640
|
+
lambda acc, item: acc * (item[0].dimension ** item[1]),
|
|
641
|
+
self.factors.items(),
|
|
642
|
+
Dimension.none,
|
|
344
643
|
)
|
|
345
644
|
|
|
346
|
-
|
|
347
|
-
return f'<{self.quantity} {"" if self.scale.name == "one" else self.scale.name}{self.unit.name}>'
|
|
645
|
+
# ------------- Rendering -------------------------------------------------
|
|
348
646
|
|
|
647
|
+
@classmethod
|
|
648
|
+
def _append(cls, unit: Unit, power: float, num: list[str], den: list[str]) -> None:
|
|
649
|
+
"""
|
|
650
|
+
Append a unit^power into numerator or denominator list. Works with
|
|
651
|
+
both Unit and UnitFactor, since UnitFactor exposes dimension,
|
|
652
|
+
shorthand, name, and aliases.
|
|
653
|
+
"""
|
|
654
|
+
if unit.dimension == Dimension.none:
|
|
655
|
+
return
|
|
656
|
+
part = getattr(unit, "shorthand", "") or getattr(unit, "name", "") or ""
|
|
657
|
+
if not part:
|
|
658
|
+
return
|
|
659
|
+
if power > 0:
|
|
660
|
+
if power == 1:
|
|
661
|
+
num.append(part)
|
|
662
|
+
else:
|
|
663
|
+
num.append(part + str(power).translate(cls._SUPERSCRIPTS))
|
|
664
|
+
elif power < 0:
|
|
665
|
+
if power == -1:
|
|
666
|
+
den.append(part)
|
|
667
|
+
else:
|
|
668
|
+
den.append(part + str(-power).translate(cls._SUPERSCRIPTS))
|
|
349
669
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
670
|
+
@property
|
|
671
|
+
def shorthand(self) -> str:
|
|
672
|
+
"""
|
|
673
|
+
Human-readable composite unit string, e.g. 'kg·m/s²'.
|
|
674
|
+
"""
|
|
675
|
+
if not self.factors:
|
|
676
|
+
return ""
|
|
354
677
|
|
|
355
|
-
|
|
356
|
-
|
|
678
|
+
num: list[str] = []
|
|
679
|
+
den: list[str] = []
|
|
357
680
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
factor
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
def __truediv__(self, another_ratio: 'Ratio') -> 'Ratio':
|
|
386
|
-
return Ratio(
|
|
387
|
-
numerator=self.numerator * another_ratio.denominator,
|
|
388
|
-
denominator=self.denominator * another_ratio.numerator,
|
|
389
|
-
)
|
|
681
|
+
for u, power in self.factors.items():
|
|
682
|
+
self._append(u, power, num, den)
|
|
683
|
+
|
|
684
|
+
numerator = "·".join(num) or "1"
|
|
685
|
+
denominator = "·".join(den)
|
|
686
|
+
if not denominator:
|
|
687
|
+
return numerator
|
|
688
|
+
return f"{numerator}/{denominator}"
|
|
689
|
+
|
|
690
|
+
def fold_scale(self) -> float:
|
|
691
|
+
"""
|
|
692
|
+
Compute the overall numeric scale factor of this UnitProduct by folding
|
|
693
|
+
together the scales of each UnitFactor raised to its exponent,
|
|
694
|
+
plus any residual scale factor from cancelled units.
|
|
695
|
+
|
|
696
|
+
Returns
|
|
697
|
+
-------
|
|
698
|
+
float
|
|
699
|
+
The combined numeric scale factor.
|
|
700
|
+
"""
|
|
701
|
+
result = getattr(self, '_residual_scale_factor', 1.0)
|
|
702
|
+
for factor, power in self.factors.items():
|
|
703
|
+
result *= factor.scale.value.evaluated ** power
|
|
704
|
+
return result
|
|
705
|
+
|
|
706
|
+
# ------------- Helpers ---------------------------------------------------
|
|
390
707
|
|
|
391
|
-
def
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
708
|
+
def _norm(self, aliases: tuple[str, ...]) -> tuple[str, ...]:
|
|
709
|
+
"""Normalize alias bag: drop empty/whitespace-only aliases."""
|
|
710
|
+
return tuple(a for a in aliases if a.strip())
|
|
711
|
+
|
|
712
|
+
def __pow__(self, power):
|
|
713
|
+
"""UnitProduct ** n => new UnitProduct with scaled exponents."""
|
|
714
|
+
return UnitProduct({u: exp * power for u, exp in self.factors.items()})
|
|
715
|
+
|
|
716
|
+
# ------------- Algebra ---------------------------------------------------
|
|
717
|
+
|
|
718
|
+
def __mul__(self, other):
|
|
719
|
+
if isinstance(other, Unit):
|
|
720
|
+
combined = self.factors.copy()
|
|
721
|
+
combined[other] = combined.get(other, 0.0) + 1.0
|
|
722
|
+
return UnitProduct(combined)
|
|
723
|
+
|
|
724
|
+
if isinstance(other, UnitProduct):
|
|
725
|
+
combined = self.factors.copy()
|
|
726
|
+
for u, exp in other.factors.items():
|
|
727
|
+
combined[u] = combined.get(u, 0.0) + exp
|
|
728
|
+
return UnitProduct(combined)
|
|
729
|
+
|
|
730
|
+
if isinstance(other, Scale):
|
|
731
|
+
# respect the convention: Scale * Unit, not Unit * Scale
|
|
732
|
+
return NotImplemented
|
|
733
|
+
|
|
734
|
+
return NotImplemented
|
|
735
|
+
|
|
736
|
+
def __rmul__(self, other):
|
|
737
|
+
# Scale * UnitProduct → apply scale to a canonical sink unit
|
|
738
|
+
if isinstance(other, Scale):
|
|
739
|
+
if not self.factors:
|
|
740
|
+
return self
|
|
741
|
+
|
|
742
|
+
# heuristic: choose unit with positive exponent first, else first unit
|
|
743
|
+
items = list(self.factors.items())
|
|
744
|
+
positives = [(u, e) for (u, e) in items if e > 0]
|
|
745
|
+
sink, _ = (positives or items)[0]
|
|
746
|
+
|
|
747
|
+
# Normalize sink into a UnitFactor
|
|
748
|
+
if isinstance(sink, UnitFactor):
|
|
749
|
+
sink_fu = sink
|
|
750
|
+
else:
|
|
751
|
+
# Plain Unit has no scale
|
|
752
|
+
sink_fu = UnitFactor(unit=sink, scale=Scale.one)
|
|
753
|
+
|
|
754
|
+
# Combine scales (expression-level)
|
|
755
|
+
if sink_fu.scale is not Scale.one:
|
|
756
|
+
new_scale = other * sink_fu.scale
|
|
757
|
+
else:
|
|
758
|
+
new_scale = other
|
|
759
|
+
|
|
760
|
+
scaled_sink = UnitFactor(
|
|
761
|
+
unit=sink_fu.unit,
|
|
762
|
+
scale=new_scale,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
combined: dict[UnitFactor, float] = {}
|
|
766
|
+
for u, exp in self.factors.items():
|
|
767
|
+
# Normalize each key into UnitFactor as we go
|
|
768
|
+
if isinstance(u, UnitFactor):
|
|
769
|
+
fu = u
|
|
770
|
+
else:
|
|
771
|
+
# Plain Unit has no scale
|
|
772
|
+
fu = UnitFactor(unit=u, scale=Scale.one)
|
|
773
|
+
|
|
774
|
+
if fu is sink_fu:
|
|
775
|
+
combined[scaled_sink] = combined.get(scaled_sink, 0.0) + exp
|
|
776
|
+
else:
|
|
777
|
+
combined[fu] = combined.get(fu, 0.0) + exp
|
|
778
|
+
|
|
779
|
+
return UnitProduct(combined)
|
|
780
|
+
|
|
781
|
+
if isinstance(other, Unit):
|
|
782
|
+
combined: dict[Unit, float] = {other: 1.0}
|
|
783
|
+
for u, e in self.factors.items():
|
|
784
|
+
combined[u] = combined.get(u, 0.0) + e
|
|
785
|
+
return UnitProduct(combined)
|
|
786
|
+
|
|
787
|
+
return NotImplemented
|
|
788
|
+
|
|
789
|
+
def __truediv__(self, other):
|
|
790
|
+
if isinstance(other, Unit):
|
|
791
|
+
combined = self.factors.copy()
|
|
792
|
+
combined[other] = combined.get(other, 0.0) - 1.0
|
|
793
|
+
return UnitProduct(combined)
|
|
794
|
+
|
|
795
|
+
if isinstance(other, UnitProduct):
|
|
796
|
+
combined = self.factors.copy()
|
|
797
|
+
for u, exp in other.factors.items():
|
|
798
|
+
combined[u] = combined.get(u, 0.0) - exp
|
|
799
|
+
return UnitProduct(combined)
|
|
800
|
+
|
|
801
|
+
return NotImplemented
|
|
802
|
+
|
|
803
|
+
# ------------- Identity & hashing ---------------------------------------
|
|
398
804
|
|
|
399
805
|
def __repr__(self):
|
|
400
|
-
|
|
401
|
-
|
|
806
|
+
return f"<{self.__class__.__name__} {self.shorthand}>"
|
|
807
|
+
|
|
808
|
+
def __eq__(self, other):
|
|
809
|
+
if isinstance(other, Unit):
|
|
810
|
+
# Only equal to a plain Unit if we have exactly that unit^1
|
|
811
|
+
# Here, the tuple comparison will invoke UnitFactor.__eq__(Unit)
|
|
812
|
+
# on the key when factors are keyed by UnitFactor.
|
|
813
|
+
return len(self.factors) == 1 and list(self.factors.items()) == [(other, 1.0)]
|
|
814
|
+
return isinstance(other, UnitProduct) and self.factors == other.factors
|
|
815
|
+
|
|
816
|
+
def __hash__(self):
|
|
817
|
+
# Sort by name; UnitFactor exposes .name, so this is stable.
|
|
818
|
+
return hash(tuple(sorted(self.factors.items(), key=lambda x: x[0].name)))
|