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