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.
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 Dimension, Unit, UnitFactor, UnitProduct, Scale
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
- # Handle Unit vs UnitProduct dispatch
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
- dim = src.dimension
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[Unit, Map] = {src: LinearMap.identity()}
226
- queue = deque([src])
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 == dst:
399
+ if neighbor == target:
243
400
  return composed
244
401
 
245
402
  queue.append(neighbor)
246
403
 
247
- raise ConversionNotFound(f"No path from {src} to {dst}")
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.