ucon 0.5.0__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
  # --------------------------------------------------------------------------------------
@@ -445,15 +459,417 @@ class Unit:
445
459
 
446
460
  # ----------------- callable (creates Number) -----------------
447
461
 
448
- def __call__(self, quantity: Union[int, float]) -> "Number":
462
+ def __call__(self, quantity: Union[int, float], uncertainty: Union[float, None] = None) -> "Number":
449
463
  """Create a Number with this unit.
450
464
 
465
+ Parameters
466
+ ----------
467
+ quantity : int or float
468
+ The numeric value.
469
+ uncertainty : float, optional
470
+ The measurement uncertainty.
471
+
451
472
  Example
452
473
  -------
453
474
  >>> meter(5)
454
475
  <5 m>
476
+ >>> meter(1.234, uncertainty=0.005)
477
+ <1.234 ± 0.005 m>
455
478
  """
456
- return Number(quantity=quantity, unit=UnitProduct.from_unit(self))
479
+ return Number(quantity=quantity, unit=UnitProduct.from_unit(self), uncertainty=uncertainty)
480
+
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
457
873
 
458
874
 
459
875
  @dataclass(frozen=True)
@@ -872,15 +1288,24 @@ class UnitProduct:
872
1288
  # Sort by name; UnitFactor exposes .name, so this is stable.
873
1289
  return hash(tuple(sorted(self.factors.items(), key=lambda x: x[0].name)))
874
1290
 
875
- def __call__(self, quantity: Union[int, float]) -> "Number":
1291
+ def __call__(self, quantity: Union[int, float], uncertainty: Union[float, None] = None) -> "Number":
876
1292
  """Create a Number with this unit product.
877
1293
 
1294
+ Parameters
1295
+ ----------
1296
+ quantity : int or float
1297
+ The numeric value.
1298
+ uncertainty : float, optional
1299
+ The measurement uncertainty.
1300
+
878
1301
  Example
879
1302
  -------
880
1303
  >>> (meter / second)(10)
881
1304
  <10 m/s>
1305
+ >>> (meter / second)(10, uncertainty=0.5)
1306
+ <10 ± 0.5 m/s>
882
1307
  """
883
- return Number(quantity=quantity, unit=self)
1308
+ return Number(quantity=quantity, unit=self, uncertainty=uncertainty)
884
1309
 
885
1310
 
886
1311
  # --------------------------------------------------------------------------------------
@@ -908,9 +1333,16 @@ class Number:
908
1333
  >>> speed = length / time
909
1334
  >>> speed
910
1335
  <2.5 m/s>
1336
+
1337
+ Optionally includes measurement uncertainty for error propagation:
1338
+
1339
+ >>> length = meter(1.234, uncertainty=0.005)
1340
+ >>> length
1341
+ <1.234 ± 0.005 m>
911
1342
  """
912
1343
  quantity: Union[float, int] = 1.0
913
1344
  unit: Union[Unit, UnitProduct] = None
1345
+ uncertainty: Union[float, None] = None
914
1346
 
915
1347
  def __post_init__(self):
916
1348
  if self.unit is None:
@@ -952,7 +1384,7 @@ class Number:
952
1384
  """
953
1385
  if not isinstance(self.unit, UnitProduct):
954
1386
  # Plain Unit already has no scale
955
- return Number(quantity=self.quantity, unit=self.unit)
1387
+ return Number(quantity=self.quantity, unit=self.unit, uncertainty=self.uncertainty)
956
1388
 
957
1389
  # Compute the combined scale factor
958
1390
  scale_factor = self.unit.fold_scale()
@@ -965,8 +1397,16 @@ class Number:
965
1397
 
966
1398
  base_unit = UnitProduct(base_factors)
967
1399
 
968
- # Adjust quantity by the scale factor
969
- return Number(quantity=self.quantity * scale_factor, unit=base_unit)
1400
+ # Adjust quantity and uncertainty by the scale factor
1401
+ new_uncertainty = None
1402
+ if self.uncertainty is not None:
1403
+ new_uncertainty = self.uncertainty * abs(scale_factor)
1404
+
1405
+ return Number(
1406
+ quantity=self.quantity * scale_factor,
1407
+ unit=base_unit,
1408
+ uncertainty=new_uncertainty,
1409
+ )
970
1410
 
971
1411
  def to(self, target, graph=None):
972
1412
  """Convert this Number to a different unit expression.
@@ -999,7 +1439,10 @@ class Number:
999
1439
  # Scale-only conversion (same base unit, different scale)
1000
1440
  if self._is_scale_only_conversion(src, dst):
1001
1441
  factor = src.fold_scale() / dst.fold_scale()
1002
- return Number(quantity=self.quantity * factor, unit=target)
1442
+ new_uncertainty = None
1443
+ if self.uncertainty is not None:
1444
+ new_uncertainty = self.uncertainty * abs(factor)
1445
+ return Number(quantity=self.quantity * factor, unit=target, uncertainty=new_uncertainty)
1003
1446
 
1004
1447
  # Graph-based conversion (use default graph if none provided)
1005
1448
  if graph is None:
@@ -1008,7 +1451,14 @@ class Number:
1008
1451
  conversion_map = graph.convert(src=src, dst=dst)
1009
1452
  # Use raw quantity - the conversion map handles scale via factorwise decomposition
1010
1453
  converted_quantity = conversion_map(self.quantity)
1011
- return Number(quantity=converted_quantity, unit=target)
1454
+
1455
+ # Propagate uncertainty through conversion using derivative
1456
+ new_uncertainty = None
1457
+ if self.uncertainty is not None:
1458
+ derivative = abs(conversion_map.derivative(self.quantity))
1459
+ new_uncertainty = derivative * self.uncertainty
1460
+
1461
+ return Number(quantity=converted_quantity, unit=target, uncertainty=new_uncertainty)
1012
1462
 
1013
1463
  def _is_scale_only_conversion(self, src: UnitProduct, dst: UnitProduct) -> bool:
1014
1464
  """Check if conversion is just a scale change (same base units)."""
@@ -1040,12 +1490,82 @@ class Number:
1040
1490
  if isinstance(other, Ratio):
1041
1491
  other = other.evaluate()
1042
1492
 
1493
+ # Scalar multiplication
1494
+ if isinstance(other, (int, float)):
1495
+ new_uncertainty = None
1496
+ if self.uncertainty is not None:
1497
+ new_uncertainty = abs(other) * self.uncertainty
1498
+ return Number(
1499
+ quantity=self.quantity * other,
1500
+ unit=self.unit,
1501
+ uncertainty=new_uncertainty,
1502
+ )
1503
+
1043
1504
  if not isinstance(other, Number):
1044
1505
  return NotImplemented
1045
1506
 
1507
+ # Uncertainty propagation for multiplication
1508
+ # δc = |c| * sqrt((δa/a)² + (δb/b)²)
1509
+ new_uncertainty = None
1510
+ result_quantity = self.quantity * other.quantity
1511
+ if self.uncertainty is not None or other.uncertainty is not None:
1512
+ rel_a = (self.uncertainty / abs(self.quantity)) if (self.uncertainty and self.quantity != 0) else 0
1513
+ rel_b = (other.uncertainty / abs(other.quantity)) if (other.uncertainty and other.quantity != 0) else 0
1514
+ rel_c = math.sqrt(rel_a**2 + rel_b**2)
1515
+ new_uncertainty = abs(result_quantity) * rel_c if rel_c > 0 else None
1516
+
1046
1517
  return Number(
1047
- quantity=self.quantity * other.quantity,
1518
+ quantity=result_quantity,
1048
1519
  unit=self.unit * other.unit,
1520
+ uncertainty=new_uncertainty,
1521
+ )
1522
+
1523
+ def __add__(self, other: 'Number') -> 'Number':
1524
+ if not isinstance(other, Number):
1525
+ return NotImplemented
1526
+
1527
+ # Dimensions must match for addition
1528
+ if self.unit.dimension != other.unit.dimension:
1529
+ raise TypeError(
1530
+ f"Cannot add Numbers with different dimensions: "
1531
+ f"{self.unit.dimension} vs {other.unit.dimension}"
1532
+ )
1533
+
1534
+ # Uncertainty propagation for addition: δc = sqrt(δa² + δb²)
1535
+ new_uncertainty = None
1536
+ if self.uncertainty is not None or other.uncertainty is not None:
1537
+ ua = self.uncertainty if self.uncertainty is not None else 0
1538
+ ub = other.uncertainty if other.uncertainty is not None else 0
1539
+ new_uncertainty = math.sqrt(ua**2 + ub**2)
1540
+
1541
+ return Number(
1542
+ quantity=self.quantity + other.quantity,
1543
+ unit=self.unit,
1544
+ uncertainty=new_uncertainty,
1545
+ )
1546
+
1547
+ def __sub__(self, other: 'Number') -> 'Number':
1548
+ if not isinstance(other, Number):
1549
+ return NotImplemented
1550
+
1551
+ # Dimensions must match for subtraction
1552
+ if self.unit.dimension != other.unit.dimension:
1553
+ raise TypeError(
1554
+ f"Cannot subtract Numbers with different dimensions: "
1555
+ f"{self.unit.dimension} vs {other.unit.dimension}"
1556
+ )
1557
+
1558
+ # Uncertainty propagation for subtraction: δc = sqrt(δa² + δb²)
1559
+ new_uncertainty = None
1560
+ if self.uncertainty is not None or other.uncertainty is not None:
1561
+ ua = self.uncertainty if self.uncertainty is not None else 0
1562
+ ub = other.uncertainty if other.uncertainty is not None else 0
1563
+ new_uncertainty = math.sqrt(ua**2 + ub**2)
1564
+
1565
+ return Number(
1566
+ quantity=self.quantity - other.quantity,
1567
+ unit=self.unit,
1568
+ uncertainty=new_uncertainty,
1049
1569
  )
1050
1570
 
1051
1571
  def __truediv__(self, other: Quantifiable) -> "Number":
@@ -1053,25 +1573,47 @@ class Number:
1053
1573
  if isinstance(other, Ratio):
1054
1574
  other = other.evaluate()
1055
1575
 
1576
+ # Scalar division
1577
+ if isinstance(other, (int, float)):
1578
+ new_uncertainty = None
1579
+ if self.uncertainty is not None:
1580
+ new_uncertainty = self.uncertainty / abs(other)
1581
+ return Number(
1582
+ quantity=self.quantity / other,
1583
+ unit=self.unit,
1584
+ uncertainty=new_uncertainty,
1585
+ )
1586
+
1056
1587
  if not isinstance(other, Number):
1057
1588
  raise TypeError(f"Cannot divide Number by non-Number/Ratio type: {type(other)}")
1058
1589
 
1059
1590
  # Symbolic quotient in the unit algebra
1060
1591
  unit_quot = self.unit / other.unit
1061
1592
 
1593
+ # Uncertainty propagation for division
1594
+ # δc = |c| * sqrt((δa/a)² + (δb/b)²)
1595
+ def compute_uncertainty(result_quantity):
1596
+ if self.uncertainty is None and other.uncertainty is None:
1597
+ return None
1598
+ rel_a = (self.uncertainty / abs(self.quantity)) if (self.uncertainty and self.quantity != 0) else 0
1599
+ rel_b = (other.uncertainty / abs(other.quantity)) if (other.uncertainty and other.quantity != 0) else 0
1600
+ rel_c = math.sqrt(rel_a**2 + rel_b**2)
1601
+ return abs(result_quantity) * rel_c if rel_c > 0 else None
1602
+
1062
1603
  # --- Case 1: Dimensionless result ----------------------------------
1063
1604
  # If the net dimension is none, we want a pure scalar:
1064
1605
  # fold *all* scale factors into the numeric magnitude.
1065
1606
  if not unit_quot.dimension:
1066
1607
  num = self._canonical_magnitude
1067
1608
  den = other._canonical_magnitude
1068
- return Number(quantity=num / den, unit=_none)
1609
+ result = num / den
1610
+ return Number(quantity=result, unit=_none, uncertainty=compute_uncertainty(result))
1069
1611
 
1070
1612
  # --- Case 2: Dimensionful result -----------------------------------
1071
1613
  # For "real" physical results like g/mL, m/s², etc., preserve the
1072
1614
  # user's chosen unit scales symbolically. Only divide the raw quantities.
1073
1615
  new_quantity = self.quantity / other.quantity
1074
- return Number(quantity=new_quantity, unit=unit_quot)
1616
+ return Number(quantity=new_quantity, unit=unit_quot, uncertainty=compute_uncertainty(new_quantity))
1075
1617
 
1076
1618
  def __eq__(self, other: Quantifiable) -> bool:
1077
1619
  if not isinstance(other, (Number, Ratio)):
@@ -1094,6 +1636,10 @@ class Number:
1094
1636
  return True
1095
1637
 
1096
1638
  def __repr__(self):
1639
+ if self.uncertainty is not None:
1640
+ if not self.unit.dimension:
1641
+ return f"<{self.quantity} ± {self.uncertainty}>"
1642
+ return f"<{self.quantity} ± {self.uncertainty} {self.unit.shorthand}>"
1097
1643
  if not self.unit.dimension:
1098
1644
  return f"<{self.quantity}>"
1099
1645
  return f"<{self.quantity} {self.unit.shorthand}>"