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.
ucon/core.py CHANGED
@@ -2,400 +2,805 @@
2
2
  ucon.core
3
3
  ==========
4
4
 
5
- Implements the **quantitative core** of the *ucon* system — the machinery that
6
- manages numeric quantities, scaling prefixes, and dimensional relationships.
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:`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.
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 dataclasses import dataclass, field
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 math import log2
22
- from math import log10
21
+ from dataclasses import dataclass
23
22
  from typing import Dict, Tuple, Union
24
23
 
25
- from ucon import units
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
- Provides comparison and division semantics used internally to represent
36
- magnitude prefixes (e.g., kilo, mega, micro).
27
+ # --------------------------------------------------------------------------------------
28
+ # Dimension
29
+ # --------------------------------------------------------------------------------------
37
30
 
38
- TODO (wittwemms): embrace fractional exponents for closure on multiplication/division.
31
+ class Dimension(Enum):
39
32
  """
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
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
- 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
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 __lt__(self, other: 'Exponent'):
65
- if not isinstance(other, Exponent):
66
- return NotImplemented
67
- return self.evaluated < other.evaluated
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
- # Hash by rounded numeric equivalence to maintain cross-base consistency
71
- return hash(round(self.evaluated, 15))
114
+ def __hash__(self) -> int:
115
+ return hash(self.value)
72
116
 
73
- # ---------- Arithmetic Semantics ----------
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
- 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)
121
+ # --------------------------------------------------------------------------------------
122
+ # Scale (with descriptor)
123
+ # --------------------------------------------------------------------------------------
93
124
 
94
- # ---------- Conversion Utilities ----------
125
+ @dataclass(frozen=True)
126
+ class ScaleDescriptor:
127
+ exponent: Exponent
128
+ shorthand: str
129
+ alias: str
95
130
 
96
- def to_base(self, new_base: int) -> "Exponent":
97
- """
98
- Convert this Exponent to another base representation.
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
- def __int__(self) -> int:
116
- return int(self.evaluated)
135
+ @property
136
+ def base(self) -> int:
137
+ return self.exponent.base
117
138
 
118
- # ---------- Representation ----------
139
+ @property
140
+ def power(self) -> Union[int, float]:
141
+ return self.exponent.power
119
142
 
120
- def __repr__(self) -> str:
121
- return f"Exponent(base={self.base}, power={self.power})"
143
+ def parts(self) -> Tuple[int, Union[int, float]]:
144
+ return (self.base, self.power)
122
145
 
123
- def __str__(self) -> str:
124
- return f"{self.base}^{self.power}"
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
- Enumerates common **magnitude prefixes** for units and quantities.
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
- Examples include:
132
- - Binary prefixes (kibi, mebi)
133
- - Decimal prefixes (milli, kilo, mega)
179
+ @property
180
+ def shorthand(self) -> str:
181
+ return self.value.shorthand
134
182
 
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
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
- """Return decimal (base-10) scales only."""
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
- """Return binary (base-2) scales only."""
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
- @classmethod
183
- def nearest(cls, value: float, include_binary: bool = False, undershoot_bias: float = 0.75) -> "Scale":
202
+ @staticmethod
203
+ @lru_cache(maxsize=1)
204
+ def by_value() -> Dict[float, str]:
184
205
  """
185
- Return the Scale that best normalizes `value` toward 1 in log-space.
186
- Optionally restricts to base-10 prefixes unless `include_binary=True`.
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._decimal_scales() if not include_binary else list(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 __mul__(self, other: 'Scale'):
205
- """
206
- Multiply two Scales together.
227
+ def __eq__(self, other: 'Scale'):
228
+ return self.value.exponent == other.value.exponent
207
229
 
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):
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
- if self is Scale.one:
215
- return other
216
- if other is Scale.one:
217
- return self
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
- result = self.value * other.value # delegates to Exponent.__mul__
220
- include_binary = 2 in {self.value.base, other.value.base}
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
- def __truediv__(self, other: 'Scale'):
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
- Divide one Scale by another.
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
- Always returns a `Scale`, representing the resulting order of magnitude.
234
- If no exact prefix match exists, returns the nearest known Scale.
341
+ # ----------------- algebra -----------------
342
+
343
+ def __mul__(self, other):
235
344
  """
236
- if not isinstance(other, Scale):
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
- if self == other:
240
- return Scale.one
241
- if other is Scale.one:
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
- should_consider_binary = (self.value.base == 2) or (other.value.base == 2)
403
+ # general case: form composite (self^1 * other^-1)
404
+ return CompositeUnit({self: 1, other: -1})
245
405
 
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)
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
- 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)
412
+ return CompositeUnit({self: power})
260
413
 
261
- def __lt__(self, other: 'Scale'):
262
- return self.value < other.value
414
+ # ----------------- equality & hashing -----------------
263
415
 
264
- def __gt__(self, other: 'Scale'):
265
- return self.value > other.value
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 __eq__(self, other: 'Scale'):
268
- return self.value == other.value
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
- Quantifiable = Union['Number', 'Ratio']
449
+ from dataclasses import dataclass
272
450
 
273
- @dataclass
274
- class Number:
451
+ @dataclass(frozen=True)
452
+ class FactoredUnit:
275
453
  """
276
- Represents a **numeric quantity** with an associated :class:`Unit` and :class:`Scale`.
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
- Combines magnitude, unit, and scale into a single, composable object that
279
- supports dimensional arithmetic and conversion:
462
+ NOTE: We also implement equality / hashing in a way that allows lookups
463
+ by the underlying Unit to keep working:
280
464
 
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)>
465
+ m in composite.components
466
+ composite.components[m]
467
+
468
+ still work even though the actual keys are FactoredUnit instances.
287
469
  """
288
- quantity: Union[float, int] = 1.0
289
- unit: Unit = units.none
290
- scale: Scale = field(default_factory=lambda: Scale.one)
470
+
471
+ unit: "Unit"
472
+ scale: "Scale"
473
+
474
+ # ------------- Projections (Unit-like surface) -------------------------
291
475
 
292
476
  @property
293
- def value(self) -> float:
294
- """Return numeric magnitude as quantity × scale factor."""
295
- return round(self.quantity * self.scale.value.evaluated, 15)
477
+ def dimension(self):
478
+ return self.unit.dimension
296
479
 
297
- def simplify(self):
298
- return Number(unit=self.unit, quantity=self.value)
480
+ @property
481
+ def aliases(self):
482
+ return getattr(self.unit, "aliases", ())
299
483
 
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)
484
+ @property
485
+ def name(self):
486
+ return getattr(self.unit, "name", "")
303
487
 
304
- def as_ratio(self):
305
- return Ratio(self)
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
- def __mul__(self, other: Quantifiable) -> 'Number':
308
- if not isinstance(other, (Number, Ratio)):
309
- return NotImplemented
500
+ prefix = "" if self.scale is Scale.one else self.scale.shorthand
501
+ return f"{prefix}{base}".strip()
310
502
 
311
- if isinstance(other, Ratio):
312
- other = other.evaluate()
503
+ # ------------- Identity & hashing -------------------------------------
313
504
 
314
- return Number(
315
- quantity=self.quantity * other.quantity,
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 __truediv__(self, other: Quantifiable) -> 'Number':
321
- if not isinstance(other, (Number, Ratio)):
322
- return NotImplemented
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
- if isinstance(other, Ratio):
325
- other = other.evaluate()
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
- return Number(
328
- quantity=self.quantity / other.quantity,
329
- unit=self.unit / other.unit,
330
- scale=self.scale / other.scale,
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
- 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)}')
527
+ return NotImplemented
336
528
 
337
- elif isinstance(other, Ratio):
338
- other = other.evaluate()
339
529
 
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
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
- def __repr__(self):
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
- # TODO -- consider using a dataclass
351
- class Ratio:
352
- """
353
- Represents a **ratio of two Numbers**, preserving their unit semantics.
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
- Useful for expressing physical relationships like efficiency, density,
356
- or dimensionless comparisons:
688
+ num: list[str] = []
689
+ den: list[str] = []
357
690
 
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
- )
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
- 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.')
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
- # TODO -- resolve int/float inconsistency
401
- return f'{self.evaluate()}' if self.numerator == self.denominator else f'{self.numerator} / {self.denominator}'
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)))