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.
- 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_uncertainty.py +264 -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 +558 -12
- ucon/graph.py +167 -10
- ucon/maps.py +22 -1
- ucon/units.py +28 -1
- {ucon-0.5.0.dist-info → ucon-0.5.2.dist-info}/METADATA +105 -3
- ucon-0.5.2.dist-info/RECORD +29 -0
- ucon-0.5.0.dist-info/RECORD +0 -23
- {ucon-0.5.0.dist-info → ucon-0.5.2.dist-info}/WHEEL +0 -0
- {ucon-0.5.0.dist-info → ucon-0.5.2.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.5.0.dist-info → ucon-0.5.2.dist-info}/licenses/NOTICE +0 -0
- {ucon-0.5.0.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
|
# --------------------------------------------------------------------------------------
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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}>"
|