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.
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 **quantitative core** of the *ucon* system — the machinery that
6
- manages numeric quantities, scaling prefixes, and dimensional relationships.
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:`Exponent` — Represents an exponential base/power pair (e.g., 10³).
11
- - :class:`Scale` — Enumerates SI and binary magnitude prefixes (kilo, milli, etc.).
12
- - :class:`Number` — Couples a numeric value with a unit and scale.
13
- - :class:`Ratio` — Represents a ratio between two :class:`Number` objects.
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 dataclasses import dataclass, field
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 math import log2
22
- from math import log10
25
+ from dataclasses import dataclass
23
26
  from typing import Dict, Tuple, Union
24
27
 
25
- from ucon import units
26
- from ucon.unit import Unit
28
+ from ucon.algebra import Exponent, Vector
27
29
 
28
30
 
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
-
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
- TODO (wittwemms): embrace fractional exponents for closure on multiplication/division.
35
+ class Dimension(Enum):
39
36
  """
40
- bases = {2: log2, 10: log10}
41
-
42
- __slots__ = ("base", "power")
43
-
44
- def __init__(self, base: int, power: Union[int, float]):
45
- if base not in self.bases.keys():
46
- raise ValueError(f'Only the following bases are supported: {reduce(lambda a,b: f"{a}, {b}", self.bases.keys())}')
47
- self.base = base
48
- self.power = power
49
-
50
- @property
51
- def evaluated(self) -> float:
52
- """Return the numeric value of base ** power."""
53
- return self.base ** self.power
54
-
55
- def parts(self) -> Tuple[int, Union[int, float]]:
56
- """Return (base, power) tuple, used for Scale lookups."""
57
- return self.base, self.power
58
-
59
- def __eq__(self, other: 'Exponent'):
60
- if not isinstance(other, Exponent):
61
- raise TypeError(f'Cannot compare Exponent to non-Exponent type: {type(other)}')
62
- return self.evaluated == other.evaluated
63
-
64
- def __lt__(self, other: 'Exponent'):
65
- if not isinstance(other, Exponent):
66
- return NotImplemented
67
- return self.evaluated < other.evaluated
68
-
69
- def __hash__(self):
70
- # Hash by rounded numeric equivalence to maintain cross-base consistency
71
- return hash(round(self.evaluated, 15))
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
- # ---------- Arithmetic Semantics ----------
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 __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
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 __mul__(self, other: 'Exponent'):
88
- if not isinstance(other, Exponent):
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
- # ---------- Conversion Utilities ----------
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
- 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)
125
+ # --------------------------------------------------------------------------------------
126
+ # Scale (with descriptor)
127
+ # --------------------------------------------------------------------------------------
109
128
 
110
- # ---------- Numeric Interop ----------
129
+ @dataclass(frozen=True)
130
+ class ScaleDescriptor:
131
+ exponent: Exponent
132
+ shorthand: str
133
+ alias: str
111
134
 
112
- def __float__(self) -> float:
113
- return float(self.evaluated)
135
+ @property
136
+ def evaluated(self) -> float:
137
+ return self.exponent.evaluated
114
138
 
115
- def __int__(self) -> int:
116
- return int(self.evaluated)
139
+ @property
140
+ def base(self) -> int:
141
+ return self.exponent.base
117
142
 
118
- # ---------- Representation ----------
143
+ @property
144
+ def power(self) -> Union[int, float]:
145
+ return self.exponent.power
119
146
 
120
- def __repr__(self) -> str:
121
- return f"Exponent(base={self.base}, power={self.power})"
147
+ def parts(self) -> Tuple[int, Union[int, float]]:
148
+ return (self.base, self.power)
122
149
 
123
- def __str__(self) -> str:
124
- return f"{self.base}^{self.power}"
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
- Enumerates common **magnitude prefixes** for units and quantities.
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
- Examples include:
132
- - Binary prefixes (kibi, mebi)
133
- - Decimal prefixes (milli, kilo, mega)
183
+ @property
184
+ def shorthand(self) -> str:
185
+ return self.value.shorthand
134
186
 
135
- Each entry stores its numeric scaling factor (e.g., `kilo = 10³`).
136
- """
137
- gibi = Exponent(2, 30)
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
- """Return decimal (base-10) scales only."""
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
- """Return binary (base-2) scales only."""
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
- @classmethod
183
- def nearest(cls, value: float, include_binary: bool = False, undershoot_bias: float = 0.75) -> "Scale":
206
+ @staticmethod
207
+ @lru_cache(maxsize=1)
208
+ def by_value() -> Dict[float, str]:
184
209
  """
185
- Return the Scale that best normalizes `value` toward 1 in log-space.
186
- Optionally restricts to base-10 prefixes unless `include_binary=True`.
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._decimal_scales() if not include_binary else list(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 __mul__(self, other: 'Scale'):
205
- """
206
- Multiply two Scales together.
231
+ def __eq__(self, other: 'Scale'):
232
+ return self.value.exponent == other.value.exponent
207
233
 
208
- Always returns a `Scale`, representing the resulting order of magnitude.
209
- If no exact prefix match exists, returns the nearest known Scale.
210
- """
211
- if not isinstance(other, Scale):
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
- if self is Scale.one:
215
- return other
216
- if other is Scale.one:
217
- return self
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
- result = self.value * other.value # delegates to Exponent.__mul__
220
- include_binary = 2 in {self.value.base, other.value.base}
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
- def __truediv__(self, other: 'Scale'):
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
- Divide one Scale by another.
328
+ Symbol used in expressions (e.g., 'm', 's').
329
+ For dimensionless units, returns ''.
232
330
 
233
- Always returns a `Scale`, representing the resulting order of magnitude.
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 not isinstance(other, Scale):
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
- if self == other:
240
- return Scale.one
241
- if other is Scale.one:
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
- should_consider_binary = (self.value.base == 2) or (other.value.base == 2)
379
+ # general case: form composite (self^1 * other^-1)
380
+ return UnitProduct({self: 1, other: -1})
245
381
 
246
- if self is Scale.one:
247
- result = Exponent(other.value.base, -other.value.power)
248
- name = Scale.all().get((result.base, result.power))
249
- if name:
250
- return Scale[name]
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
- result: Union[Exponent, float] = self.value / other.value
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 __lt__(self, other: 'Scale'):
262
- return self.value < other.value
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 __gt__(self, other: 'Scale'):
265
- return self.value > other.value
401
+ def __hash__(self):
402
+ return hash(
403
+ (
404
+ self.name,
405
+ self._norm(self.aliases),
406
+ self.dimension,
407
+ )
408
+ )
266
409
 
267
- def __eq__(self, other: 'Scale'):
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 Number:
423
+ @dataclass(frozen=True)
424
+ class UnitFactor:
275
425
  """
276
- Represents a **numeric quantity** with an associated :class:`Unit` and :class:`Scale`.
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
- Combines magnitude, unit, and scale into a single, composable object that
279
- supports dimensional arithmetic and conversion:
434
+ NOTE: We also implement equality / hashing in a way that allows lookups
435
+ by the underlying Unit to keep working:
280
436
 
281
- >>> from ucon import core, units
282
- >>> length = core.Number(unit=units.meter, quantity=5)
283
- >>> time = core.Number(unit=units.second, quantity=2)
284
- >>> speed = length / time
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
- quantity: Union[float, int] = 1.0
289
- unit: Unit = units.none
290
- scale: Scale = field(default_factory=lambda: Scale.one)
442
+
443
+ unit: "Unit"
444
+ scale: "Scale"
445
+
446
+ # ------------- Projections (Unit-like surface) -------------------------
291
447
 
292
448
  @property
293
- def value(self) -> float:
294
- """Return numeric magnitude as quantity × scale factor."""
295
- return round(self.quantity * self.scale.value.evaluated, 15)
449
+ def dimension(self):
450
+ return self.unit.dimension
296
451
 
297
- def simplify(self):
298
- return Number(unit=self.unit, quantity=self.value)
452
+ @property
453
+ def aliases(self):
454
+ return getattr(self.unit, "aliases", ())
299
455
 
300
- def to(self, new_scale: Scale):
301
- new_quantity = self.quantity / new_scale.value.evaluated
302
- return Number(unit=self.unit, scale=new_scale, quantity=new_quantity)
456
+ @property
457
+ def name(self):
458
+ return getattr(self.unit, "name", "")
303
459
 
304
- def as_ratio(self):
305
- return Ratio(self)
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
- def __mul__(self, other: Quantifiable) -> 'Number':
308
- if not isinstance(other, (Number, Ratio)):
309
- return NotImplemented
472
+ prefix = "" if self.scale is Scale.one else self.scale.shorthand
473
+ return f"{prefix}{base}".strip()
310
474
 
311
- if isinstance(other, Ratio):
312
- other = other.evaluate()
475
+ # ------------- Identity & hashing -------------------------------------
313
476
 
314
- return Number(
315
- quantity=self.quantity * other.quantity,
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 __truediv__(self, other: Quantifiable) -> 'Number':
321
- if not isinstance(other, (Number, Ratio)):
322
- return NotImplemented
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
- if isinstance(other, Ratio):
325
- other = other.evaluate()
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
- return Number(
328
- quantity=self.quantity / other.quantity,
329
- unit=self.unit / other.unit,
330
- scale=self.scale / other.scale,
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
- def __eq__(self, other: Quantifiable) -> bool:
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
- # Compare on evaluated numeric magnitude and exact unit
341
- return (
342
- self.unit == other.unit and
343
- abs(self.value - other.value) < 1e-12
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
- def __repr__(self):
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
- # TODO -- consider using a dataclass
351
- class Ratio:
352
- """
353
- Represents a **ratio of two Numbers**, preserving their unit semantics.
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
- Useful for expressing physical relationships like efficiency, density,
356
- or dimensionless comparisons:
678
+ num: list[str] = []
679
+ den: list[str] = []
357
680
 
358
- >>> ratio = Ratio(length, time)
359
- >>> ratio.evaluate()
360
- <2.5 (m/s)>
361
- """
362
- def __init__(self, numerator: Number = Number(), denominator: Number = Number()):
363
- self.numerator = numerator
364
- self.denominator = denominator
365
-
366
- def reciprocal(self) -> 'Ratio':
367
- return Ratio(numerator=self.denominator, denominator=self.numerator)
368
-
369
- def evaluate(self) -> Number:
370
- return self.numerator / self.denominator
371
-
372
- def __mul__(self, another_ratio: 'Ratio') -> 'Ratio':
373
- if self.numerator.unit == another_ratio.denominator.unit:
374
- factor = self.numerator / another_ratio.denominator
375
- numerator, denominator = factor * another_ratio.numerator, self.denominator
376
- elif self.denominator.unit == another_ratio.numerator.unit:
377
- factor = another_ratio.numerator / self.denominator
378
- numerator, denominator = factor * self.numerator, another_ratio.denominator
379
- else:
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
- )
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 __eq__(self, another_ratio: 'Ratio') -> bool:
392
- if isinstance(another_ratio, Ratio):
393
- return self.evaluate() == another_ratio.evaluate()
394
- elif isinstance(another_ratio, Number):
395
- return self.evaluate() == another_ratio
396
- else:
397
- raise ValueError(f'"{another_ratio}" is not a Ratio or Number. Comparison not possible.')
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
- # TODO -- resolve int/float inconsistency
401
- return f'{self.evaluate()}' if self.numerator == self.denominator else f'{self.numerator} / {self.denominator}'
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)))