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