ucon 0.3.5rc1__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tests/ucon/conversion/__init__.py +0 -0
- tests/ucon/conversion/test_graph.py +175 -0
- tests/ucon/conversion/test_map.py +163 -0
- tests/ucon/test_algebra.py +34 -34
- tests/ucon/test_core.py +20 -20
- tests/ucon/test_quantity.py +205 -14
- ucon/__init__.py +6 -2
- ucon/algebra.py +9 -5
- ucon/core.py +388 -68
- ucon/graph.py +415 -0
- ucon/maps.py +161 -0
- ucon/quantity.py +7 -186
- ucon/units.py +74 -31
- {ucon-0.3.5rc1.dist-info → ucon-0.4.0.dist-info}/METADATA +58 -30
- ucon-0.4.0.dist-info/RECORD +21 -0
- ucon-0.3.5rc1.dist-info/RECORD +0 -16
- {ucon-0.3.5rc1.dist-info → ucon-0.4.0.dist-info}/WHEEL +0 -0
- {ucon-0.3.5rc1.dist-info → ucon-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.3.5rc1.dist-info → ucon-0.4.0.dist-info}/licenses/NOTICE +0 -0
- {ucon-0.3.5rc1.dist-info → ucon-0.4.0.dist-info}/top_level.txt +0 -0
ucon/core.py
CHANGED
|
@@ -22,7 +22,7 @@ from __future__ import annotations
|
|
|
22
22
|
import math
|
|
23
23
|
from enum import Enum
|
|
24
24
|
from functools import lru_cache, reduce, total_ordering
|
|
25
|
-
from dataclasses import dataclass
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
26
|
from typing import Dict, Tuple, Union
|
|
27
27
|
|
|
28
28
|
from ucon.algebra import Exponent, Vector
|
|
@@ -40,47 +40,48 @@ class Dimension(Enum):
|
|
|
40
40
|
none = Vector()
|
|
41
41
|
|
|
42
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)
|
|
43
|
+
time = Vector(1, 0, 0, 0, 0, 0, 0, 0)
|
|
44
|
+
length = Vector(0, 1, 0, 0, 0, 0, 0, 0)
|
|
45
|
+
mass = Vector(0, 0, 1, 0, 0, 0, 0, 0)
|
|
46
|
+
current = Vector(0, 0, 0, 1, 0, 0, 0, 0)
|
|
47
|
+
temperature = Vector(0, 0, 0, 0, 1, 0, 0, 0)
|
|
48
|
+
luminous_intensity = Vector(0, 0, 0, 0, 0, 1, 0, 0)
|
|
49
|
+
amount_of_substance = Vector(0, 0, 0, 0, 0, 0, 1, 0)
|
|
50
|
+
information = Vector(0, 0, 0, 0, 0, 0, 0, 1)
|
|
50
51
|
# ------------------------------------------------
|
|
51
52
|
|
|
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)
|
|
53
|
+
acceleration = Vector(-2, 1, 0, 0, 0, 0, 0, 0)
|
|
54
|
+
angular_momentum = Vector(-1, 2, 1, 0, 0, 0, 0, 0)
|
|
55
|
+
area = Vector(0, 2, 0, 0, 0, 0, 0, 0)
|
|
56
|
+
capacitance = Vector(4, -2, -1, 2, 0, 0, 0, 0)
|
|
57
|
+
charge = Vector(1, 0, 0, 1, 0, 0, 0, 0)
|
|
58
|
+
conductance = Vector(3, -2, -1, 2, 0, 0, 0, 0)
|
|
59
|
+
conductivity = Vector(3, -3, -1, 2, 0, 0, 0, 0)
|
|
60
|
+
density = Vector(0, -3, 1, 0, 0, 0, 0, 0)
|
|
61
|
+
electric_field_strength = Vector(-3, 1, 1, -1, 0, 0, 0, 0)
|
|
62
|
+
energy = Vector(-2, 2, 1, 0, 0, 0, 0, 0)
|
|
63
|
+
entropy = Vector(-2, 2, 1, 0, -1, 0, 0, 0)
|
|
64
|
+
force = Vector(-2, 1, 1, 0, 0, 0, 0, 0)
|
|
65
|
+
frequency = Vector(-1, 0, 0, 0, 0, 0, 0, 0)
|
|
66
|
+
gravitation = Vector(-2, 3, -1, 0, 0, 0, 0, 0)
|
|
67
|
+
illuminance = Vector(0, -2, 0, 0, 0, 1, 0, 0)
|
|
68
|
+
inductance = Vector(-2, 2, 1, -2, 0, 0, 0, 0)
|
|
69
|
+
magnetic_flux = Vector(-2, 2, 1, -1, 0, 0, 0, 0)
|
|
70
|
+
magnetic_flux_density = Vector(-2, 0, 1, -1, 0, 0, 0, 0)
|
|
71
|
+
magnetic_permeability = Vector(-2, 1, 1, -2, 0, 0, 0, 0)
|
|
72
|
+
molar_mass = Vector(0, 0, 1, 0, 0, 0, -1, 0)
|
|
73
|
+
molar_volume = Vector(0, 3, 0, 0, 0, 0, -1, 0)
|
|
74
|
+
momentum = Vector(-1, 1, 1, 0, 0, 0, 0, 0)
|
|
75
|
+
permittivity = Vector(4, -3, -1, 2, 0, 0, 0, 0)
|
|
76
|
+
power = Vector(-3, 2, 1, 0, 0, 0, 0, 0)
|
|
77
|
+
pressure = Vector(-2, -1, 1, 0, 0, 0, 0, 0)
|
|
78
|
+
resistance = Vector(-3, 2, 1, -2, 0, 0, 0, 0)
|
|
79
|
+
resistivity = Vector(-3, 3, 1, -2, 0, 0, 0, 0)
|
|
80
|
+
specific_heat_capacity = Vector(-2, 2, 0, 0, -1, 0, 0, 0)
|
|
81
|
+
thermal_conductivity = Vector(-3, 1, 1, 0, -1, 0, 0, 0)
|
|
82
|
+
velocity = Vector(-1, 1, 0, 0, 0, 0, 0, 0)
|
|
83
|
+
voltage = Vector(-3, 2, 1, -1, 0, 0, 0, 0)
|
|
84
|
+
volume = Vector(0, 3, 0, 0, 0, 0, 0, 0)
|
|
84
85
|
|
|
85
86
|
@classmethod
|
|
86
87
|
def _resolve(cls, vector: 'Vector') -> 'Dimension':
|
|
@@ -240,8 +241,7 @@ class Scale(Enum):
|
|
|
240
241
|
|
|
241
242
|
def __mul__(self, other):
|
|
242
243
|
# --- Case 1: applying Scale to simple Unit --------------------
|
|
243
|
-
if isinstance(other, Unit)
|
|
244
|
-
# Unit no longer has scale attribute - always safe to apply
|
|
244
|
+
if isinstance(other, Unit):
|
|
245
245
|
return UnitProduct({UnitFactor(unit=other, scale=self): 1})
|
|
246
246
|
|
|
247
247
|
# --- Case 2: other cases are NOT handled here -----------------
|
|
@@ -291,6 +291,7 @@ class Scale(Enum):
|
|
|
291
291
|
return Scale.nearest(float(result), include_binary=include_binary)
|
|
292
292
|
|
|
293
293
|
|
|
294
|
+
@dataclass(frozen=True)
|
|
294
295
|
class Unit:
|
|
295
296
|
"""
|
|
296
297
|
Represents a **unit of measure** associated with a :class:`Dimension`.
|
|
@@ -300,26 +301,21 @@ class Unit:
|
|
|
300
301
|
|
|
301
302
|
Parameters
|
|
302
303
|
----------
|
|
303
|
-
*aliases : str
|
|
304
|
-
Optional shorthand symbols (e.g., "m", "sec").
|
|
305
304
|
name : str
|
|
306
305
|
Canonical name of the unit (e.g., "meter").
|
|
307
306
|
dimension : Dimension
|
|
308
307
|
The physical dimension this unit represents.
|
|
308
|
+
aliases : tuple[str, ...]
|
|
309
|
+
Optional shorthand symbols (e.g., ("m", "M")).
|
|
309
310
|
"""
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
name: str = "",
|
|
314
|
-
dimension: Dimension = Dimension.none,
|
|
315
|
-
):
|
|
316
|
-
self.aliases = aliases
|
|
317
|
-
self.name = name
|
|
318
|
-
self.dimension = dimension
|
|
311
|
+
name: str = ""
|
|
312
|
+
dimension: Dimension = field(default=Dimension.none)
|
|
313
|
+
aliases: tuple[str, ...] = ()
|
|
319
314
|
|
|
320
315
|
# ----------------- symbolic helpers -----------------
|
|
321
316
|
|
|
322
|
-
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _norm(aliases: tuple[str, ...]) -> tuple[str, ...]:
|
|
323
319
|
"""Normalize alias bag: drop empty/whitespace-only aliases."""
|
|
324
320
|
return tuple(a for a in aliases if a.strip())
|
|
325
321
|
|
|
@@ -343,8 +339,6 @@ class Unit:
|
|
|
343
339
|
Unit * Unit -> UnitProduct
|
|
344
340
|
Unit * UnitProduct -> UnitProduct
|
|
345
341
|
"""
|
|
346
|
-
from ucon.core import UnitProduct # local import to avoid circulars
|
|
347
|
-
|
|
348
342
|
if isinstance(other, UnitProduct):
|
|
349
343
|
# let UnitProduct handle merging
|
|
350
344
|
return other.__rmul__(self)
|
|
@@ -356,12 +350,13 @@ class Unit:
|
|
|
356
350
|
|
|
357
351
|
def __truediv__(self, other):
|
|
358
352
|
"""
|
|
359
|
-
Unit / Unit
|
|
360
|
-
- If same unit => dimensionless Unit()
|
|
361
|
-
- If denominator is dimensionless => self
|
|
362
|
-
- Else => UnitProduct
|
|
353
|
+
Unit / Unit or Unit / UnitProduct => UnitProduct
|
|
363
354
|
"""
|
|
364
|
-
|
|
355
|
+
if isinstance(other, UnitProduct):
|
|
356
|
+
combined = {self: 1.0}
|
|
357
|
+
for u, exp in other.factors.items():
|
|
358
|
+
combined[u] = combined.get(u, 0.0) - exp
|
|
359
|
+
return UnitProduct(combined)
|
|
365
360
|
|
|
366
361
|
if not isinstance(other, Unit):
|
|
367
362
|
return NotImplemented
|
|
@@ -385,13 +380,13 @@ class Unit:
|
|
|
385
380
|
"""
|
|
386
381
|
Unit ** n => UnitProduct with that exponent.
|
|
387
382
|
"""
|
|
388
|
-
from ucon.core import UnitProduct # local import
|
|
389
|
-
|
|
390
383
|
return UnitProduct({self: power})
|
|
391
384
|
|
|
392
385
|
# ----------------- equality & hashing -----------------
|
|
393
386
|
|
|
394
387
|
def __eq__(self, other):
|
|
388
|
+
if isinstance(other, UnitProduct):
|
|
389
|
+
return other.__eq__(self)
|
|
395
390
|
if not isinstance(other, Unit):
|
|
396
391
|
return NotImplemented
|
|
397
392
|
return (
|
|
@@ -421,6 +416,18 @@ class Unit:
|
|
|
421
416
|
return "<Unit>"
|
|
422
417
|
return f"<Unit | {self.dimension.name}>"
|
|
423
418
|
|
|
419
|
+
# ----------------- callable (creates Number) -----------------
|
|
420
|
+
|
|
421
|
+
def __call__(self, quantity: Union[int, float]) -> "Number":
|
|
422
|
+
"""Create a Number with this unit.
|
|
423
|
+
|
|
424
|
+
Example
|
|
425
|
+
-------
|
|
426
|
+
>>> meter(5)
|
|
427
|
+
<5 m>
|
|
428
|
+
"""
|
|
429
|
+
return Number(quantity=quantity, unit=UnitProduct.from_unit(self))
|
|
430
|
+
|
|
424
431
|
|
|
425
432
|
@dataclass(frozen=True)
|
|
426
433
|
class UnitFactor:
|
|
@@ -498,7 +505,7 @@ class UnitFactor:
|
|
|
498
505
|
return NotImplemented
|
|
499
506
|
|
|
500
507
|
|
|
501
|
-
class UnitProduct
|
|
508
|
+
class UnitProduct:
|
|
502
509
|
"""
|
|
503
510
|
Represents a product or quotient of Units.
|
|
504
511
|
|
|
@@ -527,8 +534,7 @@ class UnitProduct(Unit):
|
|
|
527
534
|
encountered UnitFactor (keeps user-intent scale).
|
|
528
535
|
"""
|
|
529
536
|
|
|
530
|
-
|
|
531
|
-
super().__init__(name="", dimension=Dimension.none)
|
|
537
|
+
self.name = ""
|
|
532
538
|
self.aliases = ()
|
|
533
539
|
|
|
534
540
|
merged: dict[UnitFactor, float] = {}
|
|
@@ -706,6 +712,35 @@ class UnitProduct(Unit):
|
|
|
706
712
|
result *= factor.scale.value.evaluated ** power
|
|
707
713
|
return result
|
|
708
714
|
|
|
715
|
+
# ------------- Helpers ---------------------------------------------------
|
|
716
|
+
|
|
717
|
+
@classmethod
|
|
718
|
+
def from_unit(cls, unit: Unit) -> 'UnitProduct':
|
|
719
|
+
"""Wrap a plain Unit as a UnitProduct with Scale.one."""
|
|
720
|
+
return cls({UnitFactor(unit, Scale.one): 1})
|
|
721
|
+
|
|
722
|
+
def factors_by_dimension(self) -> dict[Dimension, tuple[UnitFactor, float]]:
|
|
723
|
+
"""Group factors by dimension.
|
|
724
|
+
|
|
725
|
+
Returns a dict mapping each Dimension to (UnitFactor, exponent).
|
|
726
|
+
Raises ValueError if multiple factors share the same Dimension.
|
|
727
|
+
"""
|
|
728
|
+
result: dict[Dimension, tuple[UnitFactor, float]] = {}
|
|
729
|
+
for factor, exp in self.factors.items():
|
|
730
|
+
dim = factor.unit.dimension
|
|
731
|
+
if dim in result:
|
|
732
|
+
raise ValueError(f"Multiple factors for dimension {dim}")
|
|
733
|
+
result[dim] = (factor, exp)
|
|
734
|
+
return result
|
|
735
|
+
|
|
736
|
+
def _norm(self, aliases: tuple[str, ...]) -> tuple[str, ...]:
|
|
737
|
+
"""Normalize alias bag: drop empty/whitespace-only aliases."""
|
|
738
|
+
return tuple(a for a in aliases if a.strip())
|
|
739
|
+
|
|
740
|
+
def __pow__(self, power):
|
|
741
|
+
"""UnitProduct ** n => new UnitProduct with scaled exponents."""
|
|
742
|
+
return UnitProduct({u: exp * power for u, exp in self.factors.items()})
|
|
743
|
+
|
|
709
744
|
# ------------- Algebra ---------------------------------------------------
|
|
710
745
|
|
|
711
746
|
def __mul__(self, other):
|
|
@@ -799,7 +834,7 @@ class UnitProduct(Unit):
|
|
|
799
834
|
return f"<{self.__class__.__name__} {self.shorthand}>"
|
|
800
835
|
|
|
801
836
|
def __eq__(self, other):
|
|
802
|
-
if isinstance(other, Unit)
|
|
837
|
+
if isinstance(other, Unit):
|
|
803
838
|
# Only equal to a plain Unit if we have exactly that unit^1
|
|
804
839
|
# Here, the tuple comparison will invoke UnitFactor.__eq__(Unit)
|
|
805
840
|
# on the key when factors are keyed by UnitFactor.
|
|
@@ -808,4 +843,289 @@ class UnitProduct(Unit):
|
|
|
808
843
|
|
|
809
844
|
def __hash__(self):
|
|
810
845
|
# Sort by name; UnitFactor exposes .name, so this is stable.
|
|
811
|
-
return hash(tuple(sorted(self.factors.items(), key=lambda x: x[0].name)))
|
|
846
|
+
return hash(tuple(sorted(self.factors.items(), key=lambda x: x[0].name)))
|
|
847
|
+
|
|
848
|
+
def __call__(self, quantity: Union[int, float]) -> "Number":
|
|
849
|
+
"""Create a Number with this unit product.
|
|
850
|
+
|
|
851
|
+
Example
|
|
852
|
+
-------
|
|
853
|
+
>>> (meter / second)(10)
|
|
854
|
+
<10 m/s>
|
|
855
|
+
"""
|
|
856
|
+
return Number(quantity=quantity, unit=self)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
# --------------------------------------------------------------------------------------
|
|
860
|
+
# Number & Ratio (Value Layer)
|
|
861
|
+
# --------------------------------------------------------------------------------------
|
|
862
|
+
|
|
863
|
+
# Dimensionless unit for use as default in Number
|
|
864
|
+
_none = Unit()
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
Quantifiable = Union['Number', 'Ratio']
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
@dataclass
|
|
871
|
+
class Number:
|
|
872
|
+
"""
|
|
873
|
+
Represents a **numeric quantity** with an associated :class:`Unit` and :class:`Scale`.
|
|
874
|
+
|
|
875
|
+
Combines magnitude, unit, and scale into a single, composable object that
|
|
876
|
+
supports dimensional arithmetic and conversion:
|
|
877
|
+
|
|
878
|
+
>>> from ucon.units import meter, second
|
|
879
|
+
>>> length = meter(5)
|
|
880
|
+
>>> time = second(2)
|
|
881
|
+
>>> speed = length / time
|
|
882
|
+
>>> speed
|
|
883
|
+
<2.5 m/s>
|
|
884
|
+
"""
|
|
885
|
+
quantity: Union[float, int] = 1.0
|
|
886
|
+
unit: Union[Unit, UnitProduct] = None
|
|
887
|
+
|
|
888
|
+
def __post_init__(self):
|
|
889
|
+
if self.unit is None:
|
|
890
|
+
object.__setattr__(self, 'unit', _none)
|
|
891
|
+
|
|
892
|
+
@property
|
|
893
|
+
def value(self) -> float:
|
|
894
|
+
"""Return the numeric magnitude as-expressed (no scale folding).
|
|
895
|
+
|
|
896
|
+
Scale lives in the unit expression (e.g. kJ, mL) and is NOT
|
|
897
|
+
folded into the returned value. Use ``unit.fold_scale()`` on a
|
|
898
|
+
UnitProduct when you need the base-unit-equivalent magnitude.
|
|
899
|
+
"""
|
|
900
|
+
return round(self.quantity, 15)
|
|
901
|
+
|
|
902
|
+
@property
|
|
903
|
+
def _canonical_magnitude(self) -> float:
|
|
904
|
+
"""Quantity folded to base-unit scale (internal use for eq/div)."""
|
|
905
|
+
if isinstance(self.unit, UnitProduct):
|
|
906
|
+
return self.quantity * self.unit.fold_scale()
|
|
907
|
+
return self.quantity
|
|
908
|
+
|
|
909
|
+
def simplify(self) -> 'Number':
|
|
910
|
+
"""Return a new Number expressed in base scale (Scale.one).
|
|
911
|
+
|
|
912
|
+
This normalizes the unit expression by removing all scale prefixes
|
|
913
|
+
and adjusting the quantity accordingly. No conversion graph is needed
|
|
914
|
+
since this is purely a scale transformation.
|
|
915
|
+
|
|
916
|
+
Examples
|
|
917
|
+
--------
|
|
918
|
+
>>> from ucon import Scale, units
|
|
919
|
+
>>> km = Scale.kilo * units.meter
|
|
920
|
+
>>> km(5).simplify()
|
|
921
|
+
<5000 m>
|
|
922
|
+
>>> mg = Scale.milli * units.gram
|
|
923
|
+
>>> mg(500).simplify()
|
|
924
|
+
<0.5 g>
|
|
925
|
+
"""
|
|
926
|
+
if not isinstance(self.unit, UnitProduct):
|
|
927
|
+
# Plain Unit already has no scale
|
|
928
|
+
return Number(quantity=self.quantity, unit=self.unit)
|
|
929
|
+
|
|
930
|
+
# Compute the combined scale factor
|
|
931
|
+
scale_factor = self.unit.fold_scale()
|
|
932
|
+
|
|
933
|
+
# Create new unit with all factors at Scale.one
|
|
934
|
+
base_factors: dict[UnitFactor, float] = {}
|
|
935
|
+
for factor, exp in self.unit.factors.items():
|
|
936
|
+
base_factor = UnitFactor(unit=factor.unit, scale=Scale.one)
|
|
937
|
+
base_factors[base_factor] = exp
|
|
938
|
+
|
|
939
|
+
base_unit = UnitProduct(base_factors)
|
|
940
|
+
|
|
941
|
+
# Adjust quantity by the scale factor
|
|
942
|
+
return Number(quantity=self.quantity * scale_factor, unit=base_unit)
|
|
943
|
+
|
|
944
|
+
def to(self, target, graph=None):
|
|
945
|
+
"""Convert this Number to a different unit expression.
|
|
946
|
+
|
|
947
|
+
Parameters
|
|
948
|
+
----------
|
|
949
|
+
target : Unit or UnitProduct
|
|
950
|
+
The target unit to convert to.
|
|
951
|
+
graph : ConversionGraph, optional
|
|
952
|
+
The conversion graph to use. If not provided, uses the default graph.
|
|
953
|
+
|
|
954
|
+
Returns
|
|
955
|
+
-------
|
|
956
|
+
Number
|
|
957
|
+
A new Number with the converted quantity and target unit.
|
|
958
|
+
|
|
959
|
+
Examples
|
|
960
|
+
--------
|
|
961
|
+
>>> from ucon.units import meter, foot
|
|
962
|
+
>>> length = meter(100)
|
|
963
|
+
>>> length.to(foot)
|
|
964
|
+
<328.084 ft>
|
|
965
|
+
"""
|
|
966
|
+
from ucon.graph import get_default_graph
|
|
967
|
+
|
|
968
|
+
# Wrap plain Units as UnitProducts for uniform handling
|
|
969
|
+
src = self.unit if isinstance(self.unit, UnitProduct) else UnitProduct.from_unit(self.unit)
|
|
970
|
+
dst = target if isinstance(target, UnitProduct) else UnitProduct.from_unit(target)
|
|
971
|
+
|
|
972
|
+
# Scale-only conversion (same base unit, different scale)
|
|
973
|
+
if self._is_scale_only_conversion(src, dst):
|
|
974
|
+
factor = src.fold_scale() / dst.fold_scale()
|
|
975
|
+
return Number(quantity=self.quantity * factor, unit=target)
|
|
976
|
+
|
|
977
|
+
# Graph-based conversion (use default graph if none provided)
|
|
978
|
+
if graph is None:
|
|
979
|
+
graph = get_default_graph()
|
|
980
|
+
|
|
981
|
+
conversion_map = graph.convert(src=src, dst=dst)
|
|
982
|
+
# Use raw quantity - the conversion map handles scale via factorwise decomposition
|
|
983
|
+
converted_quantity = conversion_map(self.quantity)
|
|
984
|
+
return Number(quantity=converted_quantity, unit=target)
|
|
985
|
+
|
|
986
|
+
def _is_scale_only_conversion(self, src: UnitProduct, dst: UnitProduct) -> bool:
|
|
987
|
+
"""Check if conversion is just a scale change (same base units)."""
|
|
988
|
+
if len(src.factors) != len(dst.factors):
|
|
989
|
+
return False
|
|
990
|
+
|
|
991
|
+
src_by_dim = {}
|
|
992
|
+
dst_by_dim = {}
|
|
993
|
+
for f, exp in src.factors.items():
|
|
994
|
+
src_by_dim[f.unit.dimension] = (f.unit, exp)
|
|
995
|
+
for f, exp in dst.factors.items():
|
|
996
|
+
dst_by_dim[f.unit.dimension] = (f.unit, exp)
|
|
997
|
+
|
|
998
|
+
if src_by_dim.keys() != dst_by_dim.keys():
|
|
999
|
+
return False
|
|
1000
|
+
|
|
1001
|
+
for dim in src_by_dim:
|
|
1002
|
+
src_unit, src_exp = src_by_dim[dim]
|
|
1003
|
+
dst_unit, dst_exp = dst_by_dim[dim]
|
|
1004
|
+
if src_unit != dst_unit or abs(src_exp - dst_exp) > 1e-12:
|
|
1005
|
+
return False
|
|
1006
|
+
|
|
1007
|
+
return True
|
|
1008
|
+
|
|
1009
|
+
def as_ratio(self):
|
|
1010
|
+
return Ratio(self)
|
|
1011
|
+
|
|
1012
|
+
def __mul__(self, other: Quantifiable) -> 'Number':
|
|
1013
|
+
if isinstance(other, Ratio):
|
|
1014
|
+
other = other.evaluate()
|
|
1015
|
+
|
|
1016
|
+
if not isinstance(other, Number):
|
|
1017
|
+
return NotImplemented
|
|
1018
|
+
|
|
1019
|
+
return Number(
|
|
1020
|
+
quantity=self.quantity * other.quantity,
|
|
1021
|
+
unit=self.unit * other.unit,
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
def __truediv__(self, other: Quantifiable) -> "Number":
|
|
1025
|
+
# Allow dividing by a Ratio (interpret as its evaluated Number)
|
|
1026
|
+
if isinstance(other, Ratio):
|
|
1027
|
+
other = other.evaluate()
|
|
1028
|
+
|
|
1029
|
+
if not isinstance(other, Number):
|
|
1030
|
+
raise TypeError(f"Cannot divide Number by non-Number/Ratio type: {type(other)}")
|
|
1031
|
+
|
|
1032
|
+
# Symbolic quotient in the unit algebra
|
|
1033
|
+
unit_quot = self.unit / other.unit
|
|
1034
|
+
|
|
1035
|
+
# --- Case 1: Dimensionless result ----------------------------------
|
|
1036
|
+
# If the net dimension is none, we want a pure scalar:
|
|
1037
|
+
# fold *all* scale factors into the numeric magnitude.
|
|
1038
|
+
if not unit_quot.dimension:
|
|
1039
|
+
num = self._canonical_magnitude
|
|
1040
|
+
den = other._canonical_magnitude
|
|
1041
|
+
return Number(quantity=num / den, unit=_none)
|
|
1042
|
+
|
|
1043
|
+
# --- Case 2: Dimensionful result -----------------------------------
|
|
1044
|
+
# For "real" physical results like g/mL, m/s², etc., preserve the
|
|
1045
|
+
# user's chosen unit scales symbolically. Only divide the raw quantities.
|
|
1046
|
+
new_quantity = self.quantity / other.quantity
|
|
1047
|
+
return Number(quantity=new_quantity, unit=unit_quot)
|
|
1048
|
+
|
|
1049
|
+
def __eq__(self, other: Quantifiable) -> bool:
|
|
1050
|
+
if not isinstance(other, (Number, Ratio)):
|
|
1051
|
+
raise TypeError(
|
|
1052
|
+
f"Cannot compare Number to non-Number/Ratio type: {type(other)}"
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
# If comparing with a Ratio, evaluate it to a Number
|
|
1056
|
+
if isinstance(other, Ratio):
|
|
1057
|
+
other = other.evaluate()
|
|
1058
|
+
|
|
1059
|
+
# Dimensions must match
|
|
1060
|
+
if self.unit.dimension != other.unit.dimension:
|
|
1061
|
+
return False
|
|
1062
|
+
|
|
1063
|
+
# Compare magnitudes, scale-adjusted
|
|
1064
|
+
if abs(self._canonical_magnitude - other._canonical_magnitude) >= 1e-12:
|
|
1065
|
+
return False
|
|
1066
|
+
|
|
1067
|
+
return True
|
|
1068
|
+
|
|
1069
|
+
def __repr__(self):
|
|
1070
|
+
if not self.unit.dimension:
|
|
1071
|
+
return f"<{self.quantity}>"
|
|
1072
|
+
return f"<{self.quantity} {self.unit.shorthand}>"
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
class Ratio:
|
|
1076
|
+
"""
|
|
1077
|
+
Represents a **ratio of two Numbers**, preserving their unit semantics.
|
|
1078
|
+
|
|
1079
|
+
Useful for expressing physical relationships like efficiency, density,
|
|
1080
|
+
or dimensionless comparisons:
|
|
1081
|
+
|
|
1082
|
+
>>> ratio = Ratio(length, time)
|
|
1083
|
+
>>> ratio.evaluate()
|
|
1084
|
+
<2.5 m/s>
|
|
1085
|
+
"""
|
|
1086
|
+
def __init__(self, numerator: Number = None, denominator: Number = None):
|
|
1087
|
+
self.numerator = numerator if numerator is not None else Number()
|
|
1088
|
+
self.denominator = denominator if denominator is not None else Number()
|
|
1089
|
+
|
|
1090
|
+
def reciprocal(self) -> 'Ratio':
|
|
1091
|
+
return Ratio(numerator=self.denominator, denominator=self.numerator)
|
|
1092
|
+
|
|
1093
|
+
def evaluate(self) -> "Number":
|
|
1094
|
+
# Pure arithmetic, no scale normalization.
|
|
1095
|
+
numeric = self.numerator.quantity / self.denominator.quantity
|
|
1096
|
+
|
|
1097
|
+
# Pure unit division, with UnitFactor preservation.
|
|
1098
|
+
unit = self.numerator.unit / self.denominator.unit
|
|
1099
|
+
|
|
1100
|
+
# DO NOT normalize, DO NOT fold scale.
|
|
1101
|
+
return Number(quantity=numeric, unit=unit)
|
|
1102
|
+
|
|
1103
|
+
def __mul__(self, another_ratio: 'Ratio') -> 'Ratio':
|
|
1104
|
+
if self.numerator.unit == another_ratio.denominator.unit:
|
|
1105
|
+
factor = self.numerator / another_ratio.denominator
|
|
1106
|
+
numerator, denominator = factor * another_ratio.numerator, self.denominator
|
|
1107
|
+
elif self.denominator.unit == another_ratio.numerator.unit:
|
|
1108
|
+
factor = another_ratio.numerator / self.denominator
|
|
1109
|
+
numerator, denominator = factor * self.numerator, another_ratio.denominator
|
|
1110
|
+
else:
|
|
1111
|
+
factor = Number()
|
|
1112
|
+
another_number = another_ratio.evaluate()
|
|
1113
|
+
numerator, denominator = self.numerator * another_number, self.denominator
|
|
1114
|
+
return Ratio(numerator=numerator, denominator=denominator)
|
|
1115
|
+
|
|
1116
|
+
def __truediv__(self, another_ratio: 'Ratio') -> 'Ratio':
|
|
1117
|
+
return Ratio(
|
|
1118
|
+
numerator=self.numerator * another_ratio.denominator,
|
|
1119
|
+
denominator=self.denominator * another_ratio.numerator,
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
def __eq__(self, another_ratio: 'Ratio') -> bool:
|
|
1123
|
+
if isinstance(another_ratio, Ratio):
|
|
1124
|
+
return self.evaluate() == another_ratio.evaluate()
|
|
1125
|
+
elif isinstance(another_ratio, Number):
|
|
1126
|
+
return self.evaluate() == another_ratio
|
|
1127
|
+
else:
|
|
1128
|
+
raise ValueError(f'"{another_ratio}" is not a Ratio or Number. Comparison not possible.')
|
|
1129
|
+
|
|
1130
|
+
def __repr__(self):
|
|
1131
|
+
return f'{self.evaluate()}' if self.numerator == self.denominator else f'{self.numerator} / {self.denominator}'
|