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.
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
- 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
311
+ name: str = ""
312
+ dimension: Dimension = field(default=Dimension.none)
313
+ aliases: tuple[str, ...] = ()
318
314
 
319
315
  # ----------------- symbolic helpers -----------------
320
316
 
321
- def _norm(self, aliases: tuple[str, ...]) -> tuple[str, ...]:
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}'