ucon 0.5.1__py3-none-any.whl → 0.6.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/__init__.py CHANGED
@@ -38,20 +38,41 @@ Design Philosophy
38
38
  """
39
39
  from ucon import units
40
40
  from ucon.algebra import Exponent
41
- from ucon.core import Dimension, Scale, Unit, UnitFactor, UnitProduct, Number, Ratio
41
+ from ucon.core import (
42
+ BasisTransform,
43
+ Dimension,
44
+ DimensionNotCovered,
45
+ NonInvertibleTransform,
46
+ RebasedUnit,
47
+ Scale,
48
+ Unit,
49
+ UnitFactor,
50
+ UnitProduct,
51
+ UnitSystem,
52
+ Number,
53
+ Ratio,
54
+ )
42
55
  from ucon.graph import get_default_graph, using_graph
56
+ from ucon.units import UnknownUnitError, get_unit_by_name
43
57
 
44
58
 
45
59
  __all__ = [
46
- 'Exponent',
60
+ 'BasisTransform',
47
61
  'Dimension',
62
+ 'DimensionNotCovered',
63
+ 'Exponent',
64
+ 'NonInvertibleTransform',
48
65
  'Number',
49
66
  'Ratio',
67
+ 'RebasedUnit',
50
68
  'Scale',
51
69
  'Unit',
52
70
  'UnitFactor',
53
71
  'UnitProduct',
72
+ 'UnitSystem',
73
+ 'UnknownUnitError',
54
74
  'get_default_graph',
55
- 'using_graph',
75
+ 'get_unit_by_name',
56
76
  'units',
77
+ 'using_graph',
57
78
  ]
ucon/algebra.py CHANGED
@@ -20,13 +20,14 @@ Classes
20
20
  - :class:`Exponent` — Base/power pair supporting prefix arithmetic.
21
21
  """
22
22
  import math
23
- from dataclasses import dataclass
23
+ from dataclasses import dataclass, fields
24
+ from fractions import Fraction
24
25
  from functools import partial, reduce, total_ordering
25
26
  from operator import __sub__ as subtraction
26
27
  from typing import Callable, Iterable, Iterator, Tuple, Union
27
28
 
28
29
 
29
- diff: Callable[[Iterable], int] = partial(reduce, subtraction)
30
+ diff: Callable[[Iterable], Fraction] = partial(reduce, subtraction)
30
31
 
31
32
 
32
33
  @dataclass
@@ -43,22 +44,34 @@ class Vector:
43
44
  - Addition (`+`) → multiplication of quantities
44
45
  - Subtraction (`-`) → division of quantities
45
46
 
47
+ Components are stored as `Fraction` for exact arithmetic, enabling
48
+ fractional exponents (e.g., CGS-ESU charge: M^(1/2) · L^(3/2) · T^(-1)).
49
+ Integer and float inputs are automatically converted to Fraction.
50
+
46
51
  e.g.
47
52
  Vector(T=1, L=0, M=0, I=0, Θ=0, J=0, N=0, B=0) => "time"
48
53
  Vector(T=0, L=2, M=0, I=0, Θ=0, J=0, N=0, B=0) => "area"
49
54
  Vector(T=-2, L=1, M=1, I=0, Θ=0, J=0, N=0, B=0) => "force"
50
55
  Vector(T=0, L=0, M=0, I=0, Θ=0, J=0, N=0, B=1) => "information"
56
+ Vector(L=Fraction(3,2), M=Fraction(1,2), T=-1) => "esu_charge"
51
57
  """
52
- T: int = 0 # time
53
- L: int = 0 # length
54
- M: int = 0 # mass
55
- I: int = 0 # current
56
- Θ: int = 0 # temperature
57
- J: int = 0 # luminous intensity
58
- N: int = 0 # amount of substance
59
- B: int = 0 # information (bits)
60
-
61
- def __iter__(self) -> Iterator[int]:
58
+ T: Union[int, float, Fraction] = 0 # time
59
+ L: Union[int, float, Fraction] = 0 # length
60
+ M: Union[int, float, Fraction] = 0 # mass
61
+ I: Union[int, float, Fraction] = 0 # current
62
+ Θ: Union[int, float, Fraction] = 0 # temperature
63
+ J: Union[int, float, Fraction] = 0 # luminous intensity
64
+ N: Union[int, float, Fraction] = 0 # amount of substance
65
+ B: Union[int, float, Fraction] = 0 # information (bits)
66
+
67
+ def __post_init__(self):
68
+ """Convert all components to Fraction for exact arithmetic."""
69
+ for field in fields(self):
70
+ val = getattr(self, field.name)
71
+ if not isinstance(val, Fraction):
72
+ object.__setattr__(self, field.name, Fraction(val))
73
+
74
+ def __iter__(self) -> Iterator[Fraction]:
62
75
  yield self.T
63
76
  yield self.L
64
77
  yield self.M
@@ -90,7 +103,7 @@ class Vector:
90
103
  values = tuple(diff(pair) for pair in zip(tuple(self), tuple(vector)))
91
104
  return Vector(*values)
92
105
 
93
- def __mul__(self, scalar: Union[int, float]) -> 'Vector':
106
+ def __mul__(self, scalar: Union[int, float, Fraction]) -> 'Vector':
94
107
  """
95
108
  Scalar multiplication of the exponent vector.
96
109
 
@@ -99,9 +112,18 @@ class Vector:
99
112
  >>> Dimension.length ** 2 # area
100
113
  >>> Dimension.time ** -1 # frequency
101
114
  """
102
- values = tuple(component * scalar for component in tuple(self))
115
+ n = Fraction(scalar) if not isinstance(scalar, Fraction) else scalar
116
+ values = tuple(component * n for component in tuple(self))
103
117
  return Vector(*values)
104
118
 
119
+ def __rmul__(self, scalar: Union[int, float, Fraction]) -> 'Vector':
120
+ """Right multiplication: scalar * vector."""
121
+ return self.__mul__(scalar)
122
+
123
+ def __neg__(self) -> 'Vector':
124
+ """Negate the vector: -v."""
125
+ return Vector(*(-component for component in tuple(self)))
126
+
105
127
  def __eq__(self, other) -> bool:
106
128
  if not isinstance(other, Vector):
107
129
  return NotImplemented
ucon/core.py CHANGED
@@ -28,6 +28,20 @@ from typing import Dict, Tuple, Union
28
28
  from ucon.algebra import Exponent, Vector
29
29
 
30
30
 
31
+ # --------------------------------------------------------------------------------------
32
+ # Exceptions
33
+ # --------------------------------------------------------------------------------------
34
+
35
+ class DimensionNotCovered(Exception):
36
+ """Raised when a UnitSystem doesn't cover a requested dimension."""
37
+ pass
38
+
39
+
40
+ class NonInvertibleTransform(Exception):
41
+ """Raised when a BasisTransform is not invertible (non-square or singular)."""
42
+ pass
43
+
44
+
31
45
  # --------------------------------------------------------------------------------------
32
46
  # Dimension
33
47
  # --------------------------------------------------------------------------------------
@@ -465,6 +479,399 @@ class Unit:
465
479
  return Number(quantity=quantity, unit=UnitProduct.from_unit(self), uncertainty=uncertainty)
466
480
 
467
481
 
482
+ # --------------------------------------------------------------------------------------
483
+ # UnitSystem
484
+ # --------------------------------------------------------------------------------------
485
+
486
+ @dataclass(frozen=True)
487
+ class UnitSystem:
488
+ """
489
+ A named mapping from dimensions to base units.
490
+
491
+ Represents a coherent unit system like SI or Imperial, where each
492
+ covered dimension has exactly one base unit. Partial systems are
493
+ allowed (Imperial doesn't need mole).
494
+
495
+ Parameters
496
+ ----------
497
+ name : str
498
+ The name of the unit system (e.g., "SI", "Imperial").
499
+ bases : dict[Dimension, Unit]
500
+ Mapping from dimensions to their base units.
501
+
502
+ Raises
503
+ ------
504
+ ValueError
505
+ If name is empty, bases is empty, or a unit's dimension doesn't
506
+ match its declared dimension key.
507
+
508
+ Examples
509
+ --------
510
+ >>> si = UnitSystem(
511
+ ... name="SI",
512
+ ... bases={
513
+ ... Dimension.length: meter,
514
+ ... Dimension.mass: kilogram,
515
+ ... Dimension.time: second,
516
+ ... }
517
+ ... )
518
+ >>> si.base_for(Dimension.length)
519
+ <Unit m>
520
+ """
521
+ name: str
522
+ bases: Dict[Dimension, 'Unit']
523
+
524
+ def __post_init__(self):
525
+ if not self.name:
526
+ raise ValueError("UnitSystem must have a name")
527
+ if not self.bases:
528
+ raise ValueError("UnitSystem must have at least one base unit")
529
+
530
+ for dim, unit in self.bases.items():
531
+ if unit.dimension != dim:
532
+ raise ValueError(
533
+ f"Base unit {unit.name} has dimension {unit.dimension.name}, "
534
+ f"but was declared as base for {dim.name}"
535
+ )
536
+
537
+ def base_for(self, dim: Dimension) -> 'Unit':
538
+ """Return the base unit for a dimension.
539
+
540
+ Raises
541
+ ------
542
+ DimensionNotCovered
543
+ If this system has no base unit for the dimension.
544
+ """
545
+ if dim not in self.bases:
546
+ raise DimensionNotCovered(
547
+ f"{self.name} has no base unit for {dim.name}"
548
+ )
549
+ return self.bases[dim]
550
+
551
+ def covers(self, dim: Dimension) -> bool:
552
+ """Return True if this system has a base unit for the dimension."""
553
+ return dim in self.bases
554
+
555
+ @property
556
+ def dimensions(self) -> set:
557
+ """Return the set of dimensions covered by this system."""
558
+ return set(self.bases.keys())
559
+
560
+ def __hash__(self):
561
+ # Frozen dataclass with dict field needs custom hash
562
+ return hash((self.name, tuple(sorted(self.bases.items(), key=lambda x: x[0].name))))
563
+
564
+
565
+ # --------------------------------------------------------------------------------------
566
+ # BasisTransform
567
+ # --------------------------------------------------------------------------------------
568
+
569
+ @dataclass(frozen=True)
570
+ class BasisTransform:
571
+ """
572
+ A surjective map between dimensional exponent spaces.
573
+
574
+ Transforms exponent vectors from one system's basis to another's using
575
+ matrix multiplication. Columns correspond to source dimensions, rows
576
+ correspond to destination dimensions.
577
+
578
+ Parameters
579
+ ----------
580
+ src : UnitSystem
581
+ The source unit system.
582
+ dst : UnitSystem
583
+ The destination unit system.
584
+ src_dimensions : tuple[Dimension, ...]
585
+ Ordered source dimensions (matrix columns).
586
+ dst_dimensions : tuple[Dimension, ...]
587
+ Ordered destination dimensions (matrix rows).
588
+ matrix : tuple[tuple[Fraction, ...], ...]
589
+ Transformation matrix with exact Fraction arithmetic.
590
+
591
+ Raises
592
+ ------
593
+ ValueError
594
+ If matrix dimensions don't match the declared dimension counts.
595
+
596
+ Examples
597
+ --------
598
+ >>> # 1:1 dimension relabeling (esu_charge -> charge)
599
+ >>> esu_to_si = BasisTransform(
600
+ ... src=cgs_esu,
601
+ ... dst=si,
602
+ ... src_dimensions=(Dimension.esu_charge,),
603
+ ... dst_dimensions=(Dimension.charge,),
604
+ ... matrix=((1,),),
605
+ ... )
606
+ """
607
+ src: 'UnitSystem'
608
+ dst: 'UnitSystem'
609
+ src_dimensions: Tuple[Dimension, ...]
610
+ dst_dimensions: Tuple[Dimension, ...]
611
+ matrix: Tuple[Tuple, ...]
612
+
613
+ def __post_init__(self):
614
+ from fractions import Fraction
615
+
616
+ # Convert matrix entries to Fraction for exact arithmetic
617
+ converted = tuple(
618
+ tuple(Fraction(x) for x in row)
619
+ for row in self.matrix
620
+ )
621
+ object.__setattr__(self, 'matrix', converted)
622
+
623
+ # Validate matrix dimensions
624
+ rows = len(self.matrix)
625
+ cols = len(self.matrix[0]) if self.matrix else 0
626
+
627
+ if rows != len(self.dst_dimensions):
628
+ raise ValueError(
629
+ f"Matrix has {rows} rows but {len(self.dst_dimensions)} "
630
+ f"dst dimensions declared"
631
+ )
632
+ if cols != len(self.src_dimensions):
633
+ raise ValueError(
634
+ f"Matrix has {cols} columns but {len(self.src_dimensions)} "
635
+ f"src dimensions declared"
636
+ )
637
+
638
+ def transform(self, src_vector: Vector) -> Vector:
639
+ """Map exponent vector from src basis to dst basis."""
640
+ from fractions import Fraction
641
+
642
+ # Extract components corresponding to src_dimensions
643
+ src_components = []
644
+ for dim in self.src_dimensions:
645
+ src_components.append(self._get_component(src_vector, dim))
646
+
647
+ # Matrix multiply
648
+ result = {}
649
+ for i, dst_dim in enumerate(self.dst_dimensions):
650
+ val = sum(
651
+ self.matrix[i][j] * src_components[j]
652
+ for j in range(len(self.src_dimensions))
653
+ )
654
+ result[dst_dim] = val
655
+
656
+ return self._build_vector(result)
657
+
658
+ def _get_component(self, vector: Vector, dim: Dimension) -> 'Fraction':
659
+ """Extract the component of a vector corresponding to a dimension."""
660
+ from fractions import Fraction
661
+
662
+ dim_vector = dim.value
663
+ if isinstance(dim_vector, tuple):
664
+ dim_vector = dim_vector[0]
665
+
666
+ # Find which component is non-zero in the dimension's vector
667
+ for attr, val in [('T', dim_vector.T), ('L', dim_vector.L),
668
+ ('M', dim_vector.M), ('I', dim_vector.I),
669
+ ('Θ', dim_vector.Θ), ('J', dim_vector.J),
670
+ ('N', dim_vector.N), ('B', dim_vector.B)]:
671
+ if val == Fraction(1):
672
+ return getattr(vector, attr)
673
+
674
+ # For compound dimensions, return the dot product
675
+ return Fraction(0)
676
+
677
+ def _build_vector(self, dim_values: dict) -> Vector:
678
+ """Build a Vector from dimension -> value mapping."""
679
+ from fractions import Fraction
680
+
681
+ kwargs = {'T': Fraction(0), 'L': Fraction(0), 'M': Fraction(0),
682
+ 'I': Fraction(0), 'Θ': Fraction(0), 'J': Fraction(0),
683
+ 'N': Fraction(0), 'B': Fraction(0)}
684
+
685
+ for dim, val in dim_values.items():
686
+ dim_vector = dim.value
687
+ if isinstance(dim_vector, tuple):
688
+ dim_vector = dim_vector[0]
689
+
690
+ # Apply the value to the appropriate component
691
+ for attr, basis_val in [('T', dim_vector.T), ('L', dim_vector.L),
692
+ ('M', dim_vector.M), ('I', dim_vector.I),
693
+ ('Θ', dim_vector.Θ), ('J', dim_vector.J),
694
+ ('N', dim_vector.N), ('B', dim_vector.B)]:
695
+ if basis_val != Fraction(0):
696
+ kwargs[attr] = kwargs[attr] + val * basis_val
697
+
698
+ return Vector(**kwargs)
699
+
700
+ def validate_edge(self, src_unit: 'Unit', dst_unit: 'Unit') -> bool:
701
+ """Check if src transforms to dst's dimension."""
702
+ src_dim_vector = src_unit.dimension.value
703
+ if isinstance(src_dim_vector, tuple):
704
+ src_dim_vector = src_dim_vector[0]
705
+
706
+ transformed = self.transform(src_dim_vector)
707
+
708
+ dst_dim_vector = dst_unit.dimension.value
709
+ if isinstance(dst_dim_vector, tuple):
710
+ dst_dim_vector = dst_dim_vector[0]
711
+
712
+ return transformed == dst_dim_vector
713
+
714
+ @property
715
+ def is_square(self) -> bool:
716
+ """Return True if the matrix is square."""
717
+ return len(self.src_dimensions) == len(self.dst_dimensions)
718
+
719
+ @property
720
+ def is_invertible(self) -> bool:
721
+ """Return True if the matrix is invertible."""
722
+ from fractions import Fraction
723
+ if not self.is_square:
724
+ return False
725
+ return self._determinant() != Fraction(0)
726
+
727
+ def inverse(self) -> 'BasisTransform':
728
+ """Return the inverse transform.
729
+
730
+ Raises
731
+ ------
732
+ NonInvertibleTransform
733
+ If the transform is not invertible (non-square or singular).
734
+ """
735
+ if not self.is_invertible:
736
+ raise NonInvertibleTransform(
737
+ f"Transform {self.src.name} -> {self.dst.name} "
738
+ f"({len(self.dst_dimensions)}x{len(self.src_dimensions)}) "
739
+ f"is not invertible"
740
+ )
741
+ inv_matrix = self._invert_matrix()
742
+ return BasisTransform(
743
+ src=self.dst,
744
+ dst=self.src,
745
+ src_dimensions=self.dst_dimensions,
746
+ dst_dimensions=self.src_dimensions,
747
+ matrix=inv_matrix,
748
+ )
749
+
750
+ def _determinant(self) -> 'Fraction':
751
+ """Compute determinant for square matrices."""
752
+ from fractions import Fraction
753
+ n = len(self.matrix)
754
+ if n == 1:
755
+ return self.matrix[0][0]
756
+ if n == 2:
757
+ return (self.matrix[0][0] * self.matrix[1][1] -
758
+ self.matrix[0][1] * self.matrix[1][0])
759
+ # General case: cofactor expansion
760
+ det = Fraction(0)
761
+ for j in range(n):
762
+ minor = tuple(
763
+ tuple(self.matrix[i][k] for k in range(n) if k != j)
764
+ for i in range(1, n)
765
+ )
766
+ sign = Fraction((-1) ** j)
767
+ sub_det = BasisTransform._static_determinant(minor)
768
+ det += sign * self.matrix[0][j] * sub_det
769
+ return det
770
+
771
+ @staticmethod
772
+ def _static_determinant(matrix: tuple) -> 'Fraction':
773
+ """Recursive determinant for submatrices."""
774
+ from fractions import Fraction
775
+ n = len(matrix)
776
+ if n == 1:
777
+ return matrix[0][0]
778
+ if n == 2:
779
+ return matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0]
780
+ det = Fraction(0)
781
+ for j in range(n):
782
+ minor = tuple(
783
+ tuple(matrix[i][k] for k in range(n) if k != j)
784
+ for i in range(1, n)
785
+ )
786
+ det += Fraction((-1) ** j) * matrix[0][j] * BasisTransform._static_determinant(minor)
787
+ return det
788
+
789
+ def _invert_matrix(self) -> Tuple[Tuple, ...]:
790
+ """Gauss-Jordan elimination with exact Fraction arithmetic."""
791
+ from fractions import Fraction
792
+ n = len(self.matrix)
793
+
794
+ # Augment with identity
795
+ aug = [
796
+ [Fraction(self.matrix[i][j]) for j in range(n)] +
797
+ [Fraction(1) if i == j else Fraction(0) for j in range(n)]
798
+ for i in range(n)
799
+ ]
800
+
801
+ # Forward elimination with partial pivoting
802
+ for col in range(n):
803
+ pivot_row = None
804
+ for row in range(col, n):
805
+ if aug[row][col] != 0:
806
+ pivot_row = row
807
+ break
808
+ if pivot_row is None:
809
+ raise NonInvertibleTransform("Singular matrix")
810
+
811
+ aug[col], aug[pivot_row] = aug[pivot_row], aug[col]
812
+
813
+ pivot = aug[col][col]
814
+ aug[col] = [x / pivot for x in aug[col]]
815
+
816
+ for row in range(n):
817
+ if row != col:
818
+ factor = aug[row][col]
819
+ aug[row] = [a - factor * b for a, b in zip(aug[row], aug[col])]
820
+
821
+ return tuple(
822
+ tuple(aug[i][n + j] for j in range(n))
823
+ for i in range(n)
824
+ )
825
+
826
+
827
+ # --------------------------------------------------------------------------------------
828
+ # RebasedUnit
829
+ # --------------------------------------------------------------------------------------
830
+
831
+ @dataclass(frozen=True)
832
+ class RebasedUnit:
833
+ """
834
+ A unit whose dimension was transformed by a BasisTransform.
835
+
836
+ Lives in the destination partition but preserves provenance to the
837
+ original unit and the transform that created it.
838
+
839
+ Parameters
840
+ ----------
841
+ original : Unit
842
+ The original unit before transformation.
843
+ rebased_dimension : Dimension
844
+ The dimension in the destination system.
845
+ basis_transform : BasisTransform
846
+ The transform that rebased this unit.
847
+
848
+ Examples
849
+ --------
850
+ >>> rebased = RebasedUnit(
851
+ ... original=statcoulomb,
852
+ ... rebased_dimension=Dimension.charge,
853
+ ... basis_transform=esu_to_si,
854
+ ... )
855
+ >>> rebased.dimension
856
+ <Dimension.charge>
857
+ >>> rebased.name
858
+ 'statcoulomb'
859
+ """
860
+ original: 'Unit'
861
+ rebased_dimension: Dimension
862
+ basis_transform: 'BasisTransform'
863
+
864
+ @property
865
+ def dimension(self) -> Dimension:
866
+ """Return the rebased dimension (in the destination system)."""
867
+ return self.rebased_dimension
868
+
869
+ @property
870
+ def name(self) -> str:
871
+ """Return the name of the original unit."""
872
+ return self.original.name
873
+
874
+
468
875
  @dataclass(frozen=True)
469
876
  class UnitFactor:
470
877
  """
@@ -701,16 +1108,21 @@ class UnitProduct:
701
1108
  part = getattr(unit, "shorthand", "") or getattr(unit, "name", "") or ""
702
1109
  if not part:
703
1110
  return
1111
+
1112
+ def fmt_exp(p: float) -> str:
1113
+ """Format exponent, using int when possible to avoid '2.0' → '²·⁰'."""
1114
+ return str(int(p) if p == int(p) else p).translate(cls._SUPERSCRIPTS)
1115
+
704
1116
  if power > 0:
705
1117
  if power == 1:
706
1118
  num.append(part)
707
1119
  else:
708
- num.append(part + str(power).translate(cls._SUPERSCRIPTS))
1120
+ num.append(part + fmt_exp(power))
709
1121
  elif power < 0:
710
1122
  if power == -1:
711
1123
  den.append(part)
712
1124
  else:
713
- den.append(part + str(-power).translate(cls._SUPERSCRIPTS))
1125
+ den.append(part + fmt_exp(-power))
714
1126
 
715
1127
  @property
716
1128
  def shorthand(self) -> str: