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.
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) and not isinstance(other, UnitProduct):
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
- def __init__(
311
- self,
312
- *aliases: str,
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
- def _norm(self, aliases: tuple[str, ...]) -> tuple[str, ...]:
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
- from ucon.core import UnitProduct # local import
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(Unit):
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
- # UnitProduct always starts dimensionless
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) and not isinstance(other, UnitProduct):
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}'