ucon 0.5.1__py3-none-any.whl → 0.5.2__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/test_basis_transform.py +521 -0
- tests/ucon/test_graph_basis_transform.py +263 -0
- tests/ucon/test_rebased_unit.py +184 -0
- tests/ucon/test_unit_system.py +174 -0
- tests/ucon/test_vector_fraction.py +185 -0
- ucon/__init__.py +20 -2
- ucon/algebra.py +36 -14
- ucon/core.py +407 -0
- ucon/graph.py +167 -10
- ucon/units.py +28 -1
- {ucon-0.5.1.dist-info → ucon-0.5.2.dist-info}/METADATA +84 -3
- {ucon-0.5.1.dist-info → ucon-0.5.2.dist-info}/RECORD +16 -11
- {ucon-0.5.1.dist-info → ucon-0.5.2.dist-info}/WHEEL +0 -0
- {ucon-0.5.1.dist-info → ucon-0.5.2.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.5.1.dist-info → ucon-0.5.2.dist-info}/licenses/NOTICE +0 -0
- {ucon-0.5.1.dist-info → ucon-0.5.2.dist-info}/top_level.txt +0 -0
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
|
"""
|
ucon/graph.py
CHANGED
|
@@ -28,7 +28,15 @@ from contextvars import ContextVar
|
|
|
28
28
|
from dataclasses import dataclass, field
|
|
29
29
|
from typing import Union
|
|
30
30
|
|
|
31
|
-
from ucon.core import
|
|
31
|
+
from ucon.core import (
|
|
32
|
+
BasisTransform,
|
|
33
|
+
Dimension,
|
|
34
|
+
RebasedUnit,
|
|
35
|
+
Unit,
|
|
36
|
+
UnitFactor,
|
|
37
|
+
UnitProduct,
|
|
38
|
+
Scale,
|
|
39
|
+
)
|
|
32
40
|
from ucon.maps import Map, LinearMap, AffineMap
|
|
33
41
|
|
|
34
42
|
|
|
@@ -66,6 +74,9 @@ class ConversionGraph:
|
|
|
66
74
|
# Edges between UnitProducts (keyed by frozen factor representation)
|
|
67
75
|
_product_edges: dict[tuple, dict[tuple, Map]] = field(default_factory=dict)
|
|
68
76
|
|
|
77
|
+
# Rebased units: original unit → RebasedUnit (for cross-basis edges)
|
|
78
|
+
_rebased: dict[Unit, RebasedUnit] = field(default_factory=dict)
|
|
79
|
+
|
|
69
80
|
# ------------- Edge Management -------------------------------------------
|
|
70
81
|
|
|
71
82
|
def add_edge(
|
|
@@ -74,6 +85,7 @@ class ConversionGraph:
|
|
|
74
85
|
src: Union[Unit, UnitProduct],
|
|
75
86
|
dst: Union[Unit, UnitProduct],
|
|
76
87
|
map: Map,
|
|
88
|
+
basis_transform: BasisTransform | None = None,
|
|
77
89
|
) -> None:
|
|
78
90
|
"""Register a conversion edge. Also registers the inverse.
|
|
79
91
|
|
|
@@ -85,15 +97,31 @@ class ConversionGraph:
|
|
|
85
97
|
Destination unit expression.
|
|
86
98
|
map : Map
|
|
87
99
|
The conversion morphism (src → dst).
|
|
100
|
+
basis_transform : BasisTransform, optional
|
|
101
|
+
If provided, creates a cross-basis edge. The src unit is rebased
|
|
102
|
+
to the dst's dimension and the edge connects the rebased unit
|
|
103
|
+
to dst.
|
|
88
104
|
|
|
89
105
|
Raises
|
|
90
106
|
------
|
|
91
107
|
DimensionMismatch
|
|
92
|
-
If src and dst have different dimensions.
|
|
108
|
+
If src and dst have different dimensions (and no basis_transform).
|
|
93
109
|
CyclicInconsistency
|
|
94
110
|
If the reverse edge exists and round-trip is not identity.
|
|
95
111
|
"""
|
|
96
|
-
#
|
|
112
|
+
# Cross-basis edge with BasisTransform
|
|
113
|
+
if basis_transform is not None:
|
|
114
|
+
if isinstance(src, Unit) and not isinstance(src, UnitProduct):
|
|
115
|
+
if isinstance(dst, Unit) and not isinstance(dst, UnitProduct):
|
|
116
|
+
self._add_cross_basis_edge(
|
|
117
|
+
src=src,
|
|
118
|
+
dst=dst,
|
|
119
|
+
map=map,
|
|
120
|
+
basis_transform=basis_transform,
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Handle Unit vs UnitProduct dispatch (normal case)
|
|
97
125
|
if isinstance(src, Unit) and not isinstance(src, UnitProduct):
|
|
98
126
|
if isinstance(dst, Unit) and not isinstance(dst, UnitProduct):
|
|
99
127
|
self._add_unit_edge(src=src, dst=dst, map=map)
|
|
@@ -142,6 +170,114 @@ class ConversionGraph:
|
|
|
142
170
|
self._product_edges.setdefault(src_key, {})[dst_key] = map
|
|
143
171
|
self._product_edges.setdefault(dst_key, {})[src_key] = map.inverse()
|
|
144
172
|
|
|
173
|
+
def _add_cross_basis_edge(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
src: Unit,
|
|
177
|
+
dst: Unit,
|
|
178
|
+
map: Map,
|
|
179
|
+
basis_transform: BasisTransform,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Add cross-basis edge between Units via BasisTransform.
|
|
182
|
+
|
|
183
|
+
Creates a RebasedUnit for src in the destination's dimension partition,
|
|
184
|
+
then stores the edge from the rebased unit to dst.
|
|
185
|
+
"""
|
|
186
|
+
# Validate that the transform maps src to dst's dimension
|
|
187
|
+
if not basis_transform.validate_edge(src, dst):
|
|
188
|
+
raise DimensionMismatch(
|
|
189
|
+
f"Transform {basis_transform.src.name} -> {basis_transform.dst.name} "
|
|
190
|
+
f"does not map {src.name} to {dst.name}'s dimension"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Create RebasedUnit in destination's dimension partition
|
|
194
|
+
rebased = RebasedUnit(
|
|
195
|
+
original=src,
|
|
196
|
+
rebased_dimension=dst.dimension,
|
|
197
|
+
basis_transform=basis_transform,
|
|
198
|
+
)
|
|
199
|
+
self._rebased[src] = rebased
|
|
200
|
+
|
|
201
|
+
# Store edge from rebased to dst (same dimension now)
|
|
202
|
+
dim = dst.dimension
|
|
203
|
+
self._ensure_dimension(dim)
|
|
204
|
+
self._unit_edges[dim].setdefault(rebased, {})[dst] = map
|
|
205
|
+
self._unit_edges[dim].setdefault(dst, {})[rebased] = map.inverse()
|
|
206
|
+
|
|
207
|
+
def connect_systems(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
basis_transform: BasisTransform,
|
|
211
|
+
edges: dict[tuple[Unit, Unit], Map],
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Bulk-add edges between systems.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
basis_transform : BasisTransform
|
|
218
|
+
The transform bridging the two systems.
|
|
219
|
+
edges : dict
|
|
220
|
+
Mapping from (src_unit, dst_unit) to Map.
|
|
221
|
+
"""
|
|
222
|
+
for (src, dst), edge_map in edges.items():
|
|
223
|
+
self.add_edge(
|
|
224
|
+
src=src,
|
|
225
|
+
dst=dst,
|
|
226
|
+
map=edge_map,
|
|
227
|
+
basis_transform=basis_transform,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def list_rebased_units(self) -> dict[Unit, RebasedUnit]:
|
|
231
|
+
"""Return all rebased units in the graph.
|
|
232
|
+
|
|
233
|
+
Returns
|
|
234
|
+
-------
|
|
235
|
+
dict[Unit, RebasedUnit]
|
|
236
|
+
Mapping from original unit to its RebasedUnit.
|
|
237
|
+
"""
|
|
238
|
+
return dict(self._rebased)
|
|
239
|
+
|
|
240
|
+
def list_transforms(self) -> list[BasisTransform]:
|
|
241
|
+
"""Return all BasisTransforms active in the graph.
|
|
242
|
+
|
|
243
|
+
Returns
|
|
244
|
+
-------
|
|
245
|
+
list[BasisTransform]
|
|
246
|
+
Unique transforms used by rebased units.
|
|
247
|
+
"""
|
|
248
|
+
seen = set()
|
|
249
|
+
result = []
|
|
250
|
+
for rebased in self._rebased.values():
|
|
251
|
+
bt = rebased.basis_transform
|
|
252
|
+
if id(bt) not in seen:
|
|
253
|
+
seen.add(id(bt))
|
|
254
|
+
result.append(bt)
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
def edges_for_transform(self, transform: BasisTransform) -> list[tuple[Unit, Unit]]:
|
|
258
|
+
"""Return all edges that use a specific BasisTransform.
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
transform : BasisTransform
|
|
263
|
+
The transform to filter by.
|
|
264
|
+
|
|
265
|
+
Returns
|
|
266
|
+
-------
|
|
267
|
+
list[tuple[Unit, Unit]]
|
|
268
|
+
List of (original_unit, destination_unit) pairs.
|
|
269
|
+
"""
|
|
270
|
+
result = []
|
|
271
|
+
for original, rebased in self._rebased.items():
|
|
272
|
+
if rebased.basis_transform == transform:
|
|
273
|
+
# Find the destination unit (the one the rebased unit connects to)
|
|
274
|
+
dim = rebased.dimension
|
|
275
|
+
if dim in self._unit_edges and rebased in self._unit_edges[dim]:
|
|
276
|
+
for dst in self._unit_edges[dim][rebased]:
|
|
277
|
+
if not isinstance(dst, RebasedUnit):
|
|
278
|
+
result.append((original, dst))
|
|
279
|
+
return result
|
|
280
|
+
|
|
145
281
|
def _ensure_dimension(self, dim: Dimension) -> None:
|
|
146
282
|
if dim not in self._unit_edges:
|
|
147
283
|
self._unit_edges[dim] = {}
|
|
@@ -206,10 +342,28 @@ class ConversionGraph:
|
|
|
206
342
|
return self._convert_products(src=src_prod, dst=dst_prod)
|
|
207
343
|
|
|
208
344
|
def _convert_units(self, *, src: Unit, dst: Unit) -> Map:
|
|
209
|
-
"""Convert between plain Units via BFS.
|
|
345
|
+
"""Convert between plain Units via BFS.
|
|
346
|
+
|
|
347
|
+
Handles cross-basis conversions via rebased units.
|
|
348
|
+
"""
|
|
210
349
|
if src == dst:
|
|
211
350
|
return LinearMap.identity()
|
|
212
351
|
|
|
352
|
+
# Check if src has a rebased version that can reach dst
|
|
353
|
+
if src in self._rebased:
|
|
354
|
+
rebased = self._rebased[src]
|
|
355
|
+
if rebased.dimension == dst.dimension:
|
|
356
|
+
# Convert via the rebased unit
|
|
357
|
+
return self._bfs_convert(start=rebased, target=dst, dim=dst.dimension)
|
|
358
|
+
|
|
359
|
+
# Check if dst has a rebased version (inverse conversion)
|
|
360
|
+
if dst in self._rebased:
|
|
361
|
+
rebased_dst = self._rebased[dst]
|
|
362
|
+
if rebased_dst.dimension == src.dimension:
|
|
363
|
+
# Convert from src to the rebased dst
|
|
364
|
+
return self._bfs_convert(start=src, target=rebased_dst, dim=src.dimension)
|
|
365
|
+
|
|
366
|
+
# Check for dimension mismatch
|
|
213
367
|
if src.dimension != dst.dimension:
|
|
214
368
|
raise DimensionMismatch(f"{src.dimension} != {dst.dimension}")
|
|
215
369
|
|
|
@@ -217,13 +371,16 @@ class ConversionGraph:
|
|
|
217
371
|
if self._has_direct_unit_edge(src=src, dst=dst):
|
|
218
372
|
return self._get_direct_unit_edge(src=src, dst=dst)
|
|
219
373
|
|
|
220
|
-
# BFS
|
|
221
|
-
|
|
374
|
+
# BFS in same dimension
|
|
375
|
+
return self._bfs_convert(start=src, target=dst, dim=src.dimension)
|
|
376
|
+
|
|
377
|
+
def _bfs_convert(self, *, start, target, dim: Dimension) -> Map:
|
|
378
|
+
"""BFS to find conversion path within a dimension."""
|
|
222
379
|
if dim not in self._unit_edges:
|
|
223
380
|
raise ConversionNotFound(f"No edges for dimension {dim}")
|
|
224
381
|
|
|
225
|
-
visited: dict
|
|
226
|
-
queue = deque([
|
|
382
|
+
visited: dict = {start: LinearMap.identity()}
|
|
383
|
+
queue = deque([start])
|
|
227
384
|
|
|
228
385
|
while queue:
|
|
229
386
|
current = queue.popleft()
|
|
@@ -239,12 +396,12 @@ class ConversionGraph:
|
|
|
239
396
|
composed = edge_map @ current_map
|
|
240
397
|
visited[neighbor] = composed
|
|
241
398
|
|
|
242
|
-
if neighbor ==
|
|
399
|
+
if neighbor == target:
|
|
243
400
|
return composed
|
|
244
401
|
|
|
245
402
|
queue.append(neighbor)
|
|
246
403
|
|
|
247
|
-
raise ConversionNotFound(f"No path from {
|
|
404
|
+
raise ConversionNotFound(f"No path from {start} to {target}")
|
|
248
405
|
|
|
249
406
|
def _convert_products(self, *, src: UnitProduct, dst: UnitProduct) -> Map:
|
|
250
407
|
"""Convert between UnitProducts.
|