ucon 0.3.1rc1__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/__init__.py +0 -0
- tests/ucon/test_core.py +433 -0
- tests/ucon/test_dimension.py +206 -0
- tests/ucon/test_unit.py +143 -0
- tests/ucon/test_units.py +21 -0
- ucon/__init__.py +49 -0
- ucon/core.py +293 -0
- ucon/dimension.py +172 -0
- ucon/unit.py +92 -0
- ucon/units.py +84 -0
- ucon-0.3.1rc1.dist-info/METADATA +219 -0
- ucon-0.3.1rc1.dist-info/RECORD +15 -0
- ucon-0.3.1rc1.dist-info/WHEEL +5 -0
- ucon-0.3.1rc1.dist-info/licenses/LICENSE +21 -0
- ucon-0.3.1rc1.dist-info/top_level.txt +2 -0
tests/ucon/test_unit.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
from unittest import TestCase
|
|
4
|
+
|
|
5
|
+
from ucon.dimension import Dimension
|
|
6
|
+
from ucon.unit import Unit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestUnit(TestCase):
|
|
10
|
+
|
|
11
|
+
unit_name = 'second'
|
|
12
|
+
unit_type = 'time'
|
|
13
|
+
unit_aliases = ('seconds', 'secs', 's', 'S')
|
|
14
|
+
unit = Unit(*unit_aliases, name=unit_name, dimension=Dimension.time)
|
|
15
|
+
|
|
16
|
+
def test___repr__(self):
|
|
17
|
+
self.assertEqual(f'<{self.unit_type} | {self.unit_name}>', str(self.unit))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestUnitEdgeCases(TestCase):
|
|
21
|
+
|
|
22
|
+
# --- Initialization & representation -----------------------------------
|
|
23
|
+
|
|
24
|
+
def test_default_unit_is_dimensionless(self):
|
|
25
|
+
u = Unit()
|
|
26
|
+
self.assertEqual(u.dimension, Dimension.none)
|
|
27
|
+
self.assertEqual(u.name, '')
|
|
28
|
+
self.assertEqual(u.aliases, ())
|
|
29
|
+
self.assertEqual(u.shorthand, '')
|
|
30
|
+
self.assertEqual(repr(u), '<none>')
|
|
31
|
+
|
|
32
|
+
def test_unit_with_aliases_and_name(self):
|
|
33
|
+
u = Unit('m', 'M', name='meter', dimension=Dimension.length)
|
|
34
|
+
self.assertEqual(u.shorthand, 'm')
|
|
35
|
+
self.assertIn('m', u.aliases)
|
|
36
|
+
self.assertIn('M', u.aliases)
|
|
37
|
+
self.assertIn('length', repr(u))
|
|
38
|
+
self.assertIn('meter', repr(u))
|
|
39
|
+
|
|
40
|
+
def test_hash_and_equality_consistency(self):
|
|
41
|
+
u1 = Unit('m', name='meter', dimension=Dimension.length)
|
|
42
|
+
u2 = Unit('m', name='meter', dimension=Dimension.length)
|
|
43
|
+
u3 = Unit('s', name='second', dimension=Dimension.time)
|
|
44
|
+
self.assertEqual(u1, u2)
|
|
45
|
+
self.assertEqual(hash(u1), hash(u2))
|
|
46
|
+
self.assertNotEqual(u1, u3)
|
|
47
|
+
self.assertNotEqual(hash(u1), hash(u3))
|
|
48
|
+
|
|
49
|
+
def test_units_with_same_name_but_different_dimension_not_equal(self):
|
|
50
|
+
u1 = Unit(name='amp', dimension=Dimension.current)
|
|
51
|
+
u2 = Unit(name='amp', dimension=Dimension.time)
|
|
52
|
+
self.assertNotEqual(u1, u2)
|
|
53
|
+
|
|
54
|
+
# --- generate_name edge cases -----------------------------------------
|
|
55
|
+
|
|
56
|
+
def test_generate_name_both_have_shorthand(self):
|
|
57
|
+
u1 = Unit('m', name='meter', dimension=Dimension.length)
|
|
58
|
+
u2 = Unit('s', name='second', dimension=Dimension.time)
|
|
59
|
+
result = u1.generate_name(u2, '*')
|
|
60
|
+
self.assertEqual(result, '(m*s)')
|
|
61
|
+
|
|
62
|
+
def test_generate_name_missing_left_shorthand(self):
|
|
63
|
+
u1 = Unit(name='unitless', dimension=Dimension.none)
|
|
64
|
+
u2 = Unit('s', name='second', dimension=Dimension.time)
|
|
65
|
+
self.assertEqual(u1.generate_name(u2, '/'), 'second')
|
|
66
|
+
|
|
67
|
+
def test_generate_name_missing_right_shorthand(self):
|
|
68
|
+
u1 = Unit('m', name='meter', dimension=Dimension.length)
|
|
69
|
+
u2 = Unit(name='none', dimension=Dimension.none)
|
|
70
|
+
self.assertEqual(u1.generate_name(u2, '*'), 'meter')
|
|
71
|
+
|
|
72
|
+
def test_generate_name_no_aliases_on_either_side(self):
|
|
73
|
+
u1 = Unit(name='foo', dimension=Dimension.length)
|
|
74
|
+
u2 = Unit(name='bar', dimension=Dimension.time)
|
|
75
|
+
self.assertEqual(u1.generate_name(u2, '*'), '(foo*bar)')
|
|
76
|
+
|
|
77
|
+
# --- arithmetic behavior ----------------------------------------------
|
|
78
|
+
|
|
79
|
+
def test_multiplication_produces_composite_unit(self):
|
|
80
|
+
m = Unit('m', name='meter', dimension=Dimension.length)
|
|
81
|
+
s = Unit('s', name='second', dimension=Dimension.time)
|
|
82
|
+
v = m / s
|
|
83
|
+
self.assertIsInstance(v, Unit)
|
|
84
|
+
self.assertEqual(v.dimension, Dimension.velocity)
|
|
85
|
+
self.assertIn('/', v.name)
|
|
86
|
+
|
|
87
|
+
def test_division_with_dimensionless_denominator_returns_self(self):
|
|
88
|
+
m = Unit('m', name='meter', dimension=Dimension.length)
|
|
89
|
+
none = Unit(name='none', dimension=Dimension.none)
|
|
90
|
+
result = m / none
|
|
91
|
+
self.assertEqual(result, m)
|
|
92
|
+
|
|
93
|
+
def test_division_of_identical_units_returns_dimensionless(self):
|
|
94
|
+
m1 = Unit('m', name='meter', dimension=Dimension.length)
|
|
95
|
+
m2 = Unit('m', name='meter', dimension=Dimension.length)
|
|
96
|
+
result = m1 / m2
|
|
97
|
+
self.assertEqual(result.dimension, Dimension.none)
|
|
98
|
+
self.assertEqual(result.name, '')
|
|
99
|
+
|
|
100
|
+
def test_multiplying_with_dimensionless_returns_self(self):
|
|
101
|
+
m = Unit('m', name='meter', dimension=Dimension.length)
|
|
102
|
+
none = Unit(name='none', dimension=Dimension.none)
|
|
103
|
+
result = m * none
|
|
104
|
+
self.assertEqual(result.dimension, Dimension.length)
|
|
105
|
+
self.assertIn('m', result.name)
|
|
106
|
+
|
|
107
|
+
def test_invalid_dimension_combinations_raise_value_error(self):
|
|
108
|
+
m = Unit('m', name='meter', dimension=Dimension.length)
|
|
109
|
+
c = Unit('C', name='coulomb', dimension=Dimension.charge)
|
|
110
|
+
# The result of dividing these is undefined (no such Dimension)
|
|
111
|
+
with self.assertRaises(ValueError):
|
|
112
|
+
_ = m / c
|
|
113
|
+
with self.assertRaises(ValueError):
|
|
114
|
+
_ = c * m
|
|
115
|
+
|
|
116
|
+
# --- equality, hashing, immutability ----------------------------------
|
|
117
|
+
|
|
118
|
+
def test_equality_with_non_unit(self):
|
|
119
|
+
with self.assertRaises(TypeError):
|
|
120
|
+
Unit('m', name='meter', dimension=Dimension.length) == 'meter'
|
|
121
|
+
|
|
122
|
+
def test_hash_stability_in_collections(self):
|
|
123
|
+
m1 = Unit('m', name='meter', dimension=Dimension.length)
|
|
124
|
+
s = set([m1])
|
|
125
|
+
self.assertIn(Unit('m', name='meter', dimension=Dimension.length), s)
|
|
126
|
+
|
|
127
|
+
def test_operations_do_not_mutate_operands(self):
|
|
128
|
+
m = Unit('m', name='meter', dimension=Dimension.length)
|
|
129
|
+
s = Unit('s', name='second', dimension=Dimension.time)
|
|
130
|
+
_ = m / s
|
|
131
|
+
self.assertEqual(m.dimension, Dimension.length)
|
|
132
|
+
self.assertEqual(s.dimension, Dimension.time)
|
|
133
|
+
|
|
134
|
+
# --- operator edge cases ----------------------------------------------
|
|
135
|
+
|
|
136
|
+
def test_generate_name_handles_empty_names_and_aliases(self):
|
|
137
|
+
a = Unit()
|
|
138
|
+
b = Unit()
|
|
139
|
+
self.assertEqual(a.generate_name(b, '*'), '')
|
|
140
|
+
|
|
141
|
+
def test_repr_contains_dimension_name_even_without_name(self):
|
|
142
|
+
u = Unit(dimension=Dimension.force)
|
|
143
|
+
self.assertIn('force', repr(u))
|
tests/ucon/test_units.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
from unittest import TestCase
|
|
4
|
+
|
|
5
|
+
from ucon import units
|
|
6
|
+
from ucon.dimension import Dimension
|
|
7
|
+
from ucon.unit import Unit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestUnits(TestCase):
|
|
11
|
+
|
|
12
|
+
def test_has_expected_basic_units(self):
|
|
13
|
+
expected_basic_units = {'none', 'volt', 'liter', 'gram', 'second', 'kelvin', 'mole', 'coulomb'}
|
|
14
|
+
missing = {name for name in expected_basic_units if not units.have(name)}
|
|
15
|
+
assert not missing, f"Missing expected units: {missing}"
|
|
16
|
+
|
|
17
|
+
def test___truediv__(self):
|
|
18
|
+
self.assertEqual(units.none, units.gram / units.gram)
|
|
19
|
+
self.assertEqual(units.gram, units.gram / units.none)
|
|
20
|
+
|
|
21
|
+
self.assertEqual(Unit(name='(g/L)', dimension=Dimension.density), units.gram / units.liter)
|
ucon/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ucon
|
|
3
|
+
====
|
|
4
|
+
|
|
5
|
+
*ucon* (Unit Conversion & Dimensional Analysis) is a lightweight,
|
|
6
|
+
introspective library for reasoning about physical quantities.
|
|
7
|
+
|
|
8
|
+
Unlike conventional unit libraries that focus purely on arithmetic convenience,
|
|
9
|
+
*ucon* models the **semantics** of measurement-—exposing the algebra of
|
|
10
|
+
dimensions and the structure of compound units.
|
|
11
|
+
|
|
12
|
+
Overview
|
|
13
|
+
--------
|
|
14
|
+
*ucon* is organized into a small set of composable modules:
|
|
15
|
+
|
|
16
|
+
- :mod:`ucon.dimension` — defines the algebra of physical dimensions using
|
|
17
|
+
exponent vectors. Provides the foundation for all dimensional reasoning.
|
|
18
|
+
- :mod:`ucon.unit` — defines the :class:`Unit` abstraction, representing a
|
|
19
|
+
measurable quantity with an associated dimension, factor, and offset.
|
|
20
|
+
- :mod:`ucon.core` — implements numeric handling via :class:`Number`,
|
|
21
|
+
:class:`Scale`, and :class:`Ratio`, along with unified conversion logic.
|
|
22
|
+
- :mod:`ucon.units` — declares canonical SI base and derived units for immediate use.
|
|
23
|
+
|
|
24
|
+
Design Philosophy
|
|
25
|
+
-----------------
|
|
26
|
+
*ucon* treats unit conversion not as a lookup problem but as an **algebra**:
|
|
27
|
+
|
|
28
|
+
- **Dimensional Algebra** — all physical quantities are represented as
|
|
29
|
+
exponent vectors over the seven SI bases, ensuring strict composability.
|
|
30
|
+
- **Explicit Semantics** — units, dimensions, and scales are first-class
|
|
31
|
+
objects, not strings or tokens.
|
|
32
|
+
- **Unified Conversion Model** — all conversions are expressed through one
|
|
33
|
+
data-driven framework that is generalizable to arbitrary unit systems.
|
|
34
|
+
"""
|
|
35
|
+
from ucon import units
|
|
36
|
+
from ucon.unit import Unit
|
|
37
|
+
from ucon.core import Exponent, Number, Scale, Ratio
|
|
38
|
+
from ucon.dimension import Dimension
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
'Exponent',
|
|
43
|
+
'Dimension',
|
|
44
|
+
'Number',
|
|
45
|
+
'Ratio',
|
|
46
|
+
'Scale',
|
|
47
|
+
'Unit',
|
|
48
|
+
'units',
|
|
49
|
+
]
|
ucon/core.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ucon.core
|
|
3
|
+
==========
|
|
4
|
+
|
|
5
|
+
Implements the **quantitative core** of the *ucon* system — the machinery that
|
|
6
|
+
manages numeric quantities, scaling prefixes, and dimensional relationships.
|
|
7
|
+
|
|
8
|
+
Classes
|
|
9
|
+
-------
|
|
10
|
+
- :class:`Exponent` — Represents an exponential base/power pair (e.g., 10³).
|
|
11
|
+
- :class:`Scale` — Enumerates SI and binary magnitude prefixes (kilo, milli, etc.).
|
|
12
|
+
- :class:`Number` — Couples a numeric value with a unit and scale.
|
|
13
|
+
- :class:`Ratio` — Represents a ratio between two :class:`Number` objects.
|
|
14
|
+
|
|
15
|
+
Together, these classes allow full arithmetic, conversion, and introspection
|
|
16
|
+
of physical quantities with explicit dimensional semantics.
|
|
17
|
+
"""
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from functools import reduce, total_ordering
|
|
20
|
+
from math import log2
|
|
21
|
+
from math import log10
|
|
22
|
+
from typing import Tuple, Union
|
|
23
|
+
|
|
24
|
+
from ucon import units
|
|
25
|
+
from ucon.unit import Unit
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# TODO -- consider using a dataclass
|
|
29
|
+
@total_ordering
|
|
30
|
+
class Exponent:
|
|
31
|
+
"""
|
|
32
|
+
Represents a **base–exponent pair** (e.g., 10³ or 2¹⁰).
|
|
33
|
+
|
|
34
|
+
Provides comparison and division semantics used internally to represent
|
|
35
|
+
magnitude prefixes (e.g., kilo, mega, micro).
|
|
36
|
+
"""
|
|
37
|
+
bases = {2: log2, 10: log10}
|
|
38
|
+
|
|
39
|
+
__slots__ = ("base", "power")
|
|
40
|
+
|
|
41
|
+
def __init__(self, base: int, power: Union[int, float]):
|
|
42
|
+
if base not in self.bases.keys():
|
|
43
|
+
raise ValueError(f'Only the following bases are supported: {reduce(lambda a,b: f"{a}, {b}", self.bases.keys())}')
|
|
44
|
+
self.base = base
|
|
45
|
+
self.power = power
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def evaluated(self) -> float:
|
|
49
|
+
"""Return the numeric value of base ** power."""
|
|
50
|
+
return self.base ** self.power
|
|
51
|
+
|
|
52
|
+
def parts(self) -> Tuple[int, Union[int, float]]:
|
|
53
|
+
"""Return (base, power) tuple, used for Scale lookups."""
|
|
54
|
+
return self.base, self.power
|
|
55
|
+
|
|
56
|
+
def __eq__(self, other: 'Exponent'):
|
|
57
|
+
if not isinstance(other, Exponent):
|
|
58
|
+
raise TypeError(f'Cannot compare Exponent to non-Exponent type: {type(other)}')
|
|
59
|
+
return self.evaluated == other.evaluated
|
|
60
|
+
|
|
61
|
+
def __lt__(self, other: 'Exponent'):
|
|
62
|
+
if not isinstance(other, Exponent):
|
|
63
|
+
return NotImplemented
|
|
64
|
+
return self.evaluated < other.evaluated
|
|
65
|
+
|
|
66
|
+
def __hash__(self):
|
|
67
|
+
# Hash by rounded numeric equivalence to maintain cross-base consistency
|
|
68
|
+
return hash(round(self.evaluated, 15))
|
|
69
|
+
|
|
70
|
+
# ---------- Arithmetic Semantics ----------
|
|
71
|
+
|
|
72
|
+
def __truediv__(self, other: 'Exponent'):
|
|
73
|
+
"""
|
|
74
|
+
Divide two Exponents.
|
|
75
|
+
- If bases match, returns a relative Exponent.
|
|
76
|
+
- If bases differ, returns a numeric ratio (float).
|
|
77
|
+
"""
|
|
78
|
+
if not isinstance(other, Exponent):
|
|
79
|
+
return NotImplemented
|
|
80
|
+
if self.base == other.base:
|
|
81
|
+
return Exponent(self.base, self.power - other.power)
|
|
82
|
+
return self.evaluated / other.evaluated
|
|
83
|
+
|
|
84
|
+
def __mul__(self, other: 'Exponent'):
|
|
85
|
+
if not isinstance(other, Exponent):
|
|
86
|
+
return NotImplemented
|
|
87
|
+
if self.base == other.base:
|
|
88
|
+
return Exponent(self.base, self.power + other.power)
|
|
89
|
+
return float(self.evaluated * other.evaluated)
|
|
90
|
+
|
|
91
|
+
# ---------- Conversion Utilities ----------
|
|
92
|
+
|
|
93
|
+
def to_base(self, new_base: int) -> "Exponent":
|
|
94
|
+
"""
|
|
95
|
+
Convert this Exponent to another base representation.
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
Exponent(2, 10).to_base(10)
|
|
99
|
+
# → Exponent(base=10, power=3.010299956639812)
|
|
100
|
+
"""
|
|
101
|
+
if new_base not in self.bases:
|
|
102
|
+
supported = ", ".join(map(str, self.bases))
|
|
103
|
+
raise ValueError(f"Unsupported base {new_base!r}. Supported bases: {supported}")
|
|
104
|
+
new_power = self.bases[new_base](self.evaluated)
|
|
105
|
+
return Exponent(new_base, new_power)
|
|
106
|
+
|
|
107
|
+
# ---------- Numeric Interop ----------
|
|
108
|
+
|
|
109
|
+
def __float__(self) -> float:
|
|
110
|
+
return float(self.evaluated)
|
|
111
|
+
|
|
112
|
+
def __int__(self) -> int:
|
|
113
|
+
return int(self.evaluated)
|
|
114
|
+
|
|
115
|
+
# ---------- Representation ----------
|
|
116
|
+
|
|
117
|
+
def __repr__(self) -> str:
|
|
118
|
+
return f"Exponent(base={self.base}, power={self.power})"
|
|
119
|
+
|
|
120
|
+
def __str__(self) -> str:
|
|
121
|
+
return f"{self.base}^{self.power}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Scale(Enum):
|
|
125
|
+
"""
|
|
126
|
+
Enumerates common **magnitude prefixes** for units and quantities.
|
|
127
|
+
|
|
128
|
+
Examples include:
|
|
129
|
+
- Binary prefixes (kibi, mebi)
|
|
130
|
+
- Decimal prefixes (milli, kilo, mega)
|
|
131
|
+
|
|
132
|
+
Each entry stores its numeric scaling factor (e.g., `kilo = 10³`).
|
|
133
|
+
"""
|
|
134
|
+
mebi = Exponent(2, 20)
|
|
135
|
+
kibi = Exponent(2, 10)
|
|
136
|
+
mega = Exponent(10, 6)
|
|
137
|
+
kilo = Exponent(10, 3)
|
|
138
|
+
hecto = Exponent(10, 2)
|
|
139
|
+
deca = Exponent(10, 1)
|
|
140
|
+
one = Exponent(10, 0)
|
|
141
|
+
deci = Exponent(10,-1)
|
|
142
|
+
centi = Exponent(10,-2)
|
|
143
|
+
milli = Exponent(10,-3)
|
|
144
|
+
micro = Exponent(10,-6)
|
|
145
|
+
_kibi = Exponent(2,-10)
|
|
146
|
+
_mebi = Exponent(2,-20)
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def all():
|
|
150
|
+
return dict(map(lambda x: ((x.value.base, x.value.power), x.name), Scale))
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def by_value():
|
|
154
|
+
return dict(map(lambda x: (x.value.evaluated, x.name), Scale))
|
|
155
|
+
|
|
156
|
+
def __truediv__(self, another_scale):
|
|
157
|
+
power_diff = self.value.power - another_scale.value.power
|
|
158
|
+
if self.value == another_scale.value:
|
|
159
|
+
return Scale.one
|
|
160
|
+
if self.value.base == another_scale.value.base:
|
|
161
|
+
return Scale[Scale.all()[Exponent(self.value.base, power_diff).parts()]]
|
|
162
|
+
|
|
163
|
+
base_quotient = self.value.base / another_scale.value.base
|
|
164
|
+
exp_quotient = round((base_quotient ** another_scale.value.power) * (self.value.base ** power_diff), 15)
|
|
165
|
+
|
|
166
|
+
if Scale.one in [self, another_scale]:
|
|
167
|
+
power = Exponent.bases[2](exp_quotient)
|
|
168
|
+
return Scale[Scale.all()[Exponent(2, int(power)).parts()]]
|
|
169
|
+
else:
|
|
170
|
+
scale_exp_values = [Scale[Scale.all()[pair]].value.evaluated for pair in Scale.all().keys()]
|
|
171
|
+
closest_val = min(scale_exp_values, key=lambda val: abs(val - exp_quotient))
|
|
172
|
+
return Scale[Scale.by_value()[closest_val]]
|
|
173
|
+
|
|
174
|
+
def __lt__(self, another_scale):
|
|
175
|
+
return self.value < another_scale.value
|
|
176
|
+
|
|
177
|
+
def __gt__(self, another_scale):
|
|
178
|
+
return self.value > another_scale.value
|
|
179
|
+
|
|
180
|
+
def __eq__(self, another_scale):
|
|
181
|
+
return self.value == another_scale.value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# TODO -- consider using a dataclass
|
|
185
|
+
class Number:
|
|
186
|
+
"""
|
|
187
|
+
Represents a **numeric quantity** with an associated :class:`Unit` and :class:`Scale`.
|
|
188
|
+
|
|
189
|
+
Combines magnitude, unit, and scale into a single, composable object that
|
|
190
|
+
supports dimensional arithmetic and conversion:
|
|
191
|
+
|
|
192
|
+
>>> from ucon import core, units
|
|
193
|
+
>>> length = core.Number(unit=units.meter, quantity=5)
|
|
194
|
+
>>> time = core.Number(unit=units.second, quantity=2)
|
|
195
|
+
>>> speed = length / time
|
|
196
|
+
>>> speed
|
|
197
|
+
<2.5 (m/s)>
|
|
198
|
+
"""
|
|
199
|
+
def __init__(self, unit: Unit = units.none, scale: Scale = Scale.one, quantity = 1):
|
|
200
|
+
self.unit = unit
|
|
201
|
+
self.scale = scale
|
|
202
|
+
self.quantity = quantity
|
|
203
|
+
self.value = round(self.quantity * self.scale.value.evaluated, 15)
|
|
204
|
+
|
|
205
|
+
def simplify(self):
|
|
206
|
+
return Number(unit=self.unit, quantity=self.value)
|
|
207
|
+
|
|
208
|
+
def to(self, new_scale: Scale):
|
|
209
|
+
new_quantity = self.quantity / new_scale.value.evaluated
|
|
210
|
+
return Number(unit=self.unit, scale=new_scale, quantity=new_quantity)
|
|
211
|
+
|
|
212
|
+
def as_ratio(self):
|
|
213
|
+
return Ratio(self)
|
|
214
|
+
|
|
215
|
+
def __mul__(self, another_number: 'Number') -> 'Number':
|
|
216
|
+
return Number(
|
|
217
|
+
unit=self.unit * another_number.unit,
|
|
218
|
+
scale=self.scale,
|
|
219
|
+
quantity=self.quantity * another_number.quantity,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def __truediv__(self, another_number: 'Number') -> 'Number':
|
|
223
|
+
unit = self.unit / another_number.unit
|
|
224
|
+
scale = self.scale / another_number.scale
|
|
225
|
+
quantity = self.quantity / another_number.quantity
|
|
226
|
+
return Number(unit, scale, quantity)
|
|
227
|
+
|
|
228
|
+
def __eq__(self, another_number):
|
|
229
|
+
if isinstance(another_number, Number):
|
|
230
|
+
return (self.unit == another_number.unit) and \
|
|
231
|
+
(self.quantity == another_number.quantity) and \
|
|
232
|
+
(self.value == another_number.value)
|
|
233
|
+
elif isinstance(another_number, Ratio):
|
|
234
|
+
return self == another_number.evaluate()
|
|
235
|
+
else:
|
|
236
|
+
raise ValueError(f'"{another_number}" is not a Number or Ratio. Comparison not possible.')
|
|
237
|
+
|
|
238
|
+
def __repr__(self):
|
|
239
|
+
return f'<{self.quantity} {"" if self.scale.name == "one" else self.scale.name}{self.unit.name}>'
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# TODO -- consider using a dataclass
|
|
243
|
+
class Ratio:
|
|
244
|
+
"""
|
|
245
|
+
Represents a **ratio of two Numbers**, preserving their unit semantics.
|
|
246
|
+
|
|
247
|
+
Useful for expressing physical relationships like efficiency, density,
|
|
248
|
+
or dimensionless comparisons:
|
|
249
|
+
|
|
250
|
+
>>> ratio = Ratio(length, time)
|
|
251
|
+
>>> ratio.evaluate()
|
|
252
|
+
<2.5 (m/s)>
|
|
253
|
+
"""
|
|
254
|
+
def __init__(self, numerator: Number = Number(), denominator: Number = Number()):
|
|
255
|
+
self.numerator = numerator
|
|
256
|
+
self.denominator = denominator
|
|
257
|
+
|
|
258
|
+
def reciprocal(self) -> 'Ratio':
|
|
259
|
+
return Ratio(numerator=self.denominator, denominator=self.numerator)
|
|
260
|
+
|
|
261
|
+
def evaluate(self) -> Number:
|
|
262
|
+
return self.numerator / self.denominator
|
|
263
|
+
|
|
264
|
+
def __mul__(self, another_ratio: 'Ratio') -> 'Ratio':
|
|
265
|
+
if self.numerator.unit == another_ratio.denominator.unit:
|
|
266
|
+
factor = self.numerator / another_ratio.denominator
|
|
267
|
+
numerator, denominator = factor * another_ratio.numerator, self.denominator
|
|
268
|
+
elif self.denominator.unit == another_ratio.numerator.unit:
|
|
269
|
+
factor = another_ratio.numerator / self.denominator
|
|
270
|
+
numerator, denominator = factor * self.numerator, another_ratio.denominator
|
|
271
|
+
else:
|
|
272
|
+
factor = Number()
|
|
273
|
+
another_number = another_ratio.evaluate()
|
|
274
|
+
numerator, denominator = self.numerator * another_number, self.denominator
|
|
275
|
+
return Ratio(numerator=numerator, denominator=denominator)
|
|
276
|
+
|
|
277
|
+
def __truediv__(self, another_ratio: 'Ratio') -> 'Ratio':
|
|
278
|
+
return Ratio(
|
|
279
|
+
numerator=self.numerator * another_ratio.denominator,
|
|
280
|
+
denominator=self.denominator * another_ratio.numerator,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def __eq__(self, another_ratio: 'Ratio') -> bool:
|
|
284
|
+
if isinstance(another_ratio, Ratio):
|
|
285
|
+
return self.evaluate() == another_ratio.evaluate()
|
|
286
|
+
elif isinstance(another_ratio, Number):
|
|
287
|
+
return self.evaluate() == another_ratio
|
|
288
|
+
else:
|
|
289
|
+
raise ValueError(f'"{another_ratio}" is not a Ratio or Number. Comparison not possible.')
|
|
290
|
+
|
|
291
|
+
def __repr__(self):
|
|
292
|
+
# TODO -- resolve int/float inconsistency
|
|
293
|
+
return f'{self.evaluate()}' if self.numerator == self.denominator else f'{self.numerator} / {self.denominator}'
|
ucon/dimension.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ucon.dimension
|
|
3
|
+
===============
|
|
4
|
+
|
|
5
|
+
Defines the algebra of **physical dimensions**--the foundation of all unit
|
|
6
|
+
relationships and dimensional analysis in *ucon*.
|
|
7
|
+
|
|
8
|
+
Each :class:`Dimension` represents a physical quantity (time, mass, length, etc.)
|
|
9
|
+
expressed as a 7-element exponent vector following the SI base system:
|
|
10
|
+
|
|
11
|
+
(T, L, M, I, Θ, J, N) :: (s * m * kg * A * K * cd * mol)
|
|
12
|
+
time, length, mass, current, temperature, luminous intensity, substance
|
|
13
|
+
|
|
14
|
+
Derived dimensions are expressed as algebraic sums or differences of these base
|
|
15
|
+
vectors (e.g., `velocity = length / time`, `force = mass * acceleration`).
|
|
16
|
+
|
|
17
|
+
Classes
|
|
18
|
+
-------
|
|
19
|
+
- :class:`Vector` — Represents the exponent vector of a physical quantity.
|
|
20
|
+
- :class:`Dimension` — Enum of known physical quantities, each with a `Vector`
|
|
21
|
+
value and operator overloads for dimensional algebra.
|
|
22
|
+
"""
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from functools import partial, reduce
|
|
26
|
+
from operator import __sub__ as subtraction
|
|
27
|
+
from typing import Callable, Iterable, Iterator
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
diff: Callable[[Iterable], int] = partial(reduce, subtraction)
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Vector:
|
|
34
|
+
"""
|
|
35
|
+
Represents the **exponent vector** of a physical quantity.
|
|
36
|
+
|
|
37
|
+
Each component corresponds to the power of a base dimension in the SI system:
|
|
38
|
+
time (T), length (L), mass (M), current (I), temperature (Θ),
|
|
39
|
+
luminous intensity (J), and amount of substance (N).
|
|
40
|
+
|
|
41
|
+
Arithmetic operations correspond to dimensional composition:
|
|
42
|
+
- Addition (`+`) → multiplication of quantities
|
|
43
|
+
- Subtraction (`-`) → division of quantities
|
|
44
|
+
|
|
45
|
+
e.g.
|
|
46
|
+
Vector(T=1, L=0, M=0, I=0, Θ=0, J=0, N=0) => "time"
|
|
47
|
+
Vector(T=0, L=2, M=0, I=0, Θ=0, J=0, N=0) => "area"
|
|
48
|
+
Vector(T=-2, L=1, M=1, I=0, Θ=0, J=0, N=0) => "force"
|
|
49
|
+
"""
|
|
50
|
+
T: int = 0 # time
|
|
51
|
+
L: int = 0 # length
|
|
52
|
+
M: int = 0 # mass
|
|
53
|
+
I: int = 0 # current
|
|
54
|
+
Θ: int = 0 # temperature
|
|
55
|
+
J: int = 0 # luminous intensity
|
|
56
|
+
N: int = 0 # amount of substance
|
|
57
|
+
|
|
58
|
+
def __iter__(self) -> Iterator[int]:
|
|
59
|
+
yield self.T
|
|
60
|
+
yield self.L
|
|
61
|
+
yield self.M
|
|
62
|
+
yield self.I
|
|
63
|
+
yield self.Θ
|
|
64
|
+
yield self.J
|
|
65
|
+
yield self.N
|
|
66
|
+
|
|
67
|
+
def __len__(self) -> int:
|
|
68
|
+
return sum(tuple(1 for x in self))
|
|
69
|
+
|
|
70
|
+
def __add__(self, vector: 'Vector') -> 'Vector':
|
|
71
|
+
"""
|
|
72
|
+
Addition, here, comes from the multiplication of base quantities
|
|
73
|
+
|
|
74
|
+
e.g. F = m * a
|
|
75
|
+
F =
|
|
76
|
+
(s^-2 * m^1 * kg * A * K * cd * mol) +
|
|
77
|
+
(s * m * kg^1 * A * K * cd * mol)
|
|
78
|
+
"""
|
|
79
|
+
values = tuple(sum(pair) for pair in zip(tuple(self), tuple(vector)))
|
|
80
|
+
return Vector(*values)
|
|
81
|
+
|
|
82
|
+
def __sub__(self, vector: 'Vector') -> 'Vector':
|
|
83
|
+
"""
|
|
84
|
+
Subtraction, here, comes from the division of base quantities
|
|
85
|
+
"""
|
|
86
|
+
values = tuple(diff(pair) for pair in zip(tuple(self), tuple(vector)))
|
|
87
|
+
return Vector(*values)
|
|
88
|
+
|
|
89
|
+
def __eq__(self, vector: 'Vector') -> bool:
|
|
90
|
+
assert isinstance(vector, Vector), "Can only compare Vector to another Vector"
|
|
91
|
+
return tuple(self) == tuple(vector)
|
|
92
|
+
|
|
93
|
+
def __hash__(self) -> int:
|
|
94
|
+
# Hash based on the string because tuples have been shown to collide
|
|
95
|
+
# Not the most performant, but effective
|
|
96
|
+
return hash(str(tuple(self)))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Dimension(Enum):
|
|
100
|
+
"""
|
|
101
|
+
Represents a **physical dimension** defined by a :class:`Vector`.
|
|
102
|
+
|
|
103
|
+
Each dimension corresponds to a distinct combination of base exponents.
|
|
104
|
+
Dimensions are algebraically composable via multiplication and division:
|
|
105
|
+
|
|
106
|
+
>>> Dimension.length / Dimension.time
|
|
107
|
+
<Dimension.velocity: Vector(T=-1, L=1, M=0, I=0, Θ=0, J=0, N=0)>
|
|
108
|
+
|
|
109
|
+
This algebra forms the foundation for unit compatibility and conversion.
|
|
110
|
+
"""
|
|
111
|
+
none = Vector()
|
|
112
|
+
|
|
113
|
+
# -- BASIS ---------------------------------------
|
|
114
|
+
time = Vector(1, 0, 0, 0, 0, 0, 0)
|
|
115
|
+
length = Vector(0, 1, 0, 0, 0, 0, 0)
|
|
116
|
+
mass = Vector(0, 0, 1, 0, 0, 0, 0)
|
|
117
|
+
current = Vector(0, 0, 0, 1, 0, 0, 0)
|
|
118
|
+
temperature = Vector(0, 0, 0, 0, 1, 0, 0)
|
|
119
|
+
luminous_intensity = Vector(0, 0, 0, 0, 0, 1, 0)
|
|
120
|
+
amount_of_substance = Vector(0, 0, 0, 0, 0, 0, 1)
|
|
121
|
+
# ------------------------------------------------
|
|
122
|
+
|
|
123
|
+
acceleration = Vector(-2, 1, 0, 0, 0, 0, 0)
|
|
124
|
+
angular_momentum = Vector(-1, 2, 1, 0, 0, 0, 0)
|
|
125
|
+
area = Vector(0, 2, 0, 0, 0, 0, 0)
|
|
126
|
+
capacitance = Vector(4, -2, -1, 2, 0, 0, 0)
|
|
127
|
+
charge = Vector(1, 0, 0, 1, 0, 0, 0)
|
|
128
|
+
conductance = Vector(3, -2, -1, 2, 0, 0, 0)
|
|
129
|
+
conductivity = Vector(3, -3, -1, 2, 0, 0, 0)
|
|
130
|
+
density = Vector(0, -3, 1, 0, 0, 0, 0)
|
|
131
|
+
electric_field_strength = Vector(-3, 1, 1, -1, 0, 0, 0)
|
|
132
|
+
energy = Vector(-2, 2, 1, 0, 0, 0, 0)
|
|
133
|
+
entropy = Vector(-2, 2, 1, 0, -1, 0, 0)
|
|
134
|
+
force = Vector(-2, 1, 1, 0, 0, 0, 0)
|
|
135
|
+
frequency = Vector(-1, 0, 0, 0, 0, 0, 0)
|
|
136
|
+
gravitation = Vector(-2, 3, -1, 0, 0, 0, 0)
|
|
137
|
+
illuminance = Vector(0, -2, 0, 0, 0, 1, 0)
|
|
138
|
+
inductance = Vector(-2, 2, 1, -2, 0, 0, 0)
|
|
139
|
+
magnetic_flux = Vector(-2, 2, 1, -1, 0, 0, 0)
|
|
140
|
+
magnetic_flux_density = Vector(-2, 0, 1, -1, 0, 0, 0)
|
|
141
|
+
magnetic_permeability = Vector(-2, 1, 1, -2, 0, 0, 0)
|
|
142
|
+
molar_mass = Vector(0, 0, 1, 0, 0, 0, -1)
|
|
143
|
+
molar_volume = Vector(0, 3, 0, 0, 0, 0, -1)
|
|
144
|
+
momentum = Vector(-1, 1, 1, 0, 0, 0, 0)
|
|
145
|
+
permittivity = Vector(4, -3, -1, 2, 0, 0, 0)
|
|
146
|
+
power = Vector(-3, 2, 1, 0, 0, 0, 0)
|
|
147
|
+
pressure = Vector(-2, -1, 1, 0, 0, 0, 0)
|
|
148
|
+
resistance = Vector(-3, 2, 1, -2, 0, 0, 0)
|
|
149
|
+
resistivity = Vector(-3, 3, 1, -2, 0, 0, 0)
|
|
150
|
+
specific_heat_capacity = Vector(-2, 2, 0, 0, -1, 0, 0)
|
|
151
|
+
thermal_conductivity = Vector(-3, 1, 1, 0, -1, 0, 0)
|
|
152
|
+
velocity = Vector(-1, 1, 0, 0, 0, 0, 0)
|
|
153
|
+
voltage = Vector(-3, 2, 1, -1, 0, 0, 0)
|
|
154
|
+
volume = Vector(0, 3, 0, 0, 0, 0, 0)
|
|
155
|
+
|
|
156
|
+
def __truediv__(self, dimension: 'Dimension') -> 'Dimension':
|
|
157
|
+
if not isinstance(dimension, Dimension):
|
|
158
|
+
raise TypeError(f"Cannot divide Dimension by non-Dimension type: {type(dimension)}")
|
|
159
|
+
return Dimension(self.value - dimension.value)
|
|
160
|
+
|
|
161
|
+
def __mul__(self, dimension: 'Dimension') -> 'Dimension':
|
|
162
|
+
if not isinstance(dimension, Dimension):
|
|
163
|
+
raise TypeError(f"Cannot multiply Dimension by non-Dimension type: {type(dimension)}")
|
|
164
|
+
return Dimension(self.value + dimension.value)
|
|
165
|
+
|
|
166
|
+
def __eq__(self, dimension) -> bool:
|
|
167
|
+
if not isinstance(dimension, Dimension):
|
|
168
|
+
raise TypeError(f"Cannot compare Dimension with non-Dimension type: {type(dimension)}")
|
|
169
|
+
return self.value == dimension.value
|
|
170
|
+
|
|
171
|
+
def __hash__(self) -> int:
|
|
172
|
+
return hash(self.value)
|