ucon 0.3.5rc2__py3-none-any.whl → 0.4.1__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 +409 -0
- tests/ucon/conversion/test_map.py +409 -0
- tests/ucon/test_algebra.py +34 -34
- tests/ucon/test_core.py +25 -26
- tests/ucon/test_default_graph_conversions.py +443 -0
- tests/ucon/test_quantity.py +246 -61
- ucon/__init__.py +6 -2
- ucon/algebra.py +9 -5
- ucon/core.py +366 -53
- ucon/graph.py +423 -0
- ucon/maps.py +161 -0
- ucon/quantity.py +7 -186
- ucon/units.py +79 -31
- {ucon-0.3.5rc2.dist-info → ucon-0.4.1.dist-info}/METADATA +28 -10
- ucon-0.4.1.dist-info/RECORD +22 -0
- ucon-0.3.5rc2.dist-info/RECORD +0 -16
- {ucon-0.3.5rc2.dist-info → ucon-0.4.1.dist-info}/WHEEL +0 -0
- {ucon-0.3.5rc2.dist-info → ucon-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.3.5rc2.dist-info → ucon-0.4.1.dist-info}/licenses/NOTICE +0 -0
- {ucon-0.3.5rc2.dist-info → ucon-0.4.1.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':
|
|
@@ -290,6 +291,7 @@ class Scale(Enum):
|
|
|
290
291
|
return Scale.nearest(float(result), include_binary=include_binary)
|
|
291
292
|
|
|
292
293
|
|
|
294
|
+
@dataclass(frozen=True)
|
|
293
295
|
class Unit:
|
|
294
296
|
"""
|
|
295
297
|
Represents a **unit of measure** associated with a :class:`Dimension`.
|
|
@@ -299,26 +301,21 @@ class Unit:
|
|
|
299
301
|
|
|
300
302
|
Parameters
|
|
301
303
|
----------
|
|
302
|
-
*aliases : str
|
|
303
|
-
Optional shorthand symbols (e.g., "m", "sec").
|
|
304
304
|
name : str
|
|
305
305
|
Canonical name of the unit (e.g., "meter").
|
|
306
306
|
dimension : Dimension
|
|
307
307
|
The physical dimension this unit represents.
|
|
308
|
+
aliases : tuple[str, ...]
|
|
309
|
+
Optional shorthand symbols (e.g., ("m", "M")).
|
|
308
310
|
"""
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
name: str = "",
|
|
313
|
-
dimension: Dimension = Dimension.none,
|
|
314
|
-
):
|
|
315
|
-
self.aliases = aliases
|
|
316
|
-
self.name = name
|
|
317
|
-
self.dimension = dimension
|
|
311
|
+
name: str = ""
|
|
312
|
+
dimension: Dimension = field(default=Dimension.none)
|
|
313
|
+
aliases: tuple[str, ...] = ()
|
|
318
314
|
|
|
319
315
|
# ----------------- symbolic helpers -----------------
|
|
320
316
|
|
|
321
|
-
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _norm(aliases: tuple[str, ...]) -> tuple[str, ...]:
|
|
322
319
|
"""Normalize alias bag: drop empty/whitespace-only aliases."""
|
|
323
320
|
return tuple(a for a in aliases if a.strip())
|
|
324
321
|
|
|
@@ -419,6 +416,18 @@ class Unit:
|
|
|
419
416
|
return "<Unit>"
|
|
420
417
|
return f"<Unit | {self.dimension.name}>"
|
|
421
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
|
+
|
|
422
431
|
|
|
423
432
|
@dataclass(frozen=True)
|
|
424
433
|
class UnitFactor:
|
|
@@ -705,6 +714,25 @@ class UnitProduct:
|
|
|
705
714
|
|
|
706
715
|
# ------------- Helpers ---------------------------------------------------
|
|
707
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
|
+
|
|
708
736
|
def _norm(self, aliases: tuple[str, ...]) -> tuple[str, ...]:
|
|
709
737
|
"""Normalize alias bag: drop empty/whitespace-only aliases."""
|
|
710
738
|
return tuple(a for a in aliases if a.strip())
|
|
@@ -815,4 +843,289 @@ class UnitProduct:
|
|
|
815
843
|
|
|
816
844
|
def __hash__(self):
|
|
817
845
|
# Sort by name; UnitFactor exposes .name, so this is stable.
|
|
818
|
-
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}'
|