ucon 0.3.5rc1__py3-none-any.whl → 0.4.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.
- tests/ucon/conversion/__init__.py +0 -0
- tests/ucon/conversion/test_graph.py +175 -0
- tests/ucon/conversion/test_map.py +163 -0
- tests/ucon/test_algebra.py +34 -34
- tests/ucon/test_core.py +20 -20
- tests/ucon/test_quantity.py +205 -14
- ucon/__init__.py +6 -2
- ucon/algebra.py +9 -5
- ucon/core.py +388 -68
- ucon/graph.py +415 -0
- ucon/maps.py +161 -0
- ucon/quantity.py +7 -186
- ucon/units.py +74 -31
- {ucon-0.3.5rc1.dist-info → ucon-0.4.0.dist-info}/METADATA +58 -30
- ucon-0.4.0.dist-info/RECORD +21 -0
- ucon-0.3.5rc1.dist-info/RECORD +0 -16
- {ucon-0.3.5rc1.dist-info → ucon-0.4.0.dist-info}/WHEEL +0 -0
- {ucon-0.3.5rc1.dist-info → ucon-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {ucon-0.3.5rc1.dist-info → ucon-0.4.0.dist-info}/licenses/NOTICE +0 -0
- {ucon-0.3.5rc1.dist-info → ucon-0.4.0.dist-info}/top_level.txt +0 -0
ucon/quantity.py
CHANGED
|
@@ -4,193 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
"""
|
|
6
6
|
ucon.quantity
|
|
7
|
-
|
|
7
|
+
=============
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
physical quantities.
|
|
9
|
+
Re-exports :class:`Number` and :class:`Ratio` from :mod:`ucon.core`
|
|
10
|
+
for backward compatibility.
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- :class:`Number` — Couples a numeric value with a unit and scale.
|
|
16
|
-
- :class:`Ratio` — Represents a ratio between two :class:`Number` objects.
|
|
17
|
-
|
|
18
|
-
Together, these classes allow full arithmetic, conversion, and introspection
|
|
19
|
-
of physical quantities with explicit dimensional semantics.
|
|
12
|
+
These classes now live in :mod:`ucon.core` to enable the callable
|
|
13
|
+
unit syntax: ``meter(5)`` returns a ``Number``.
|
|
20
14
|
"""
|
|
21
|
-
from
|
|
22
|
-
from typing import Union
|
|
23
|
-
|
|
24
|
-
from ucon import units
|
|
25
|
-
from ucon.core import Unit, UnitProduct, Scale
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
Quantifiable = Union['Number', 'Ratio']
|
|
29
|
-
|
|
30
|
-
@dataclass
|
|
31
|
-
class Number:
|
|
32
|
-
"""
|
|
33
|
-
Represents a **numeric quantity** with an associated :class:`Unit` and :class:`Scale`.
|
|
34
|
-
|
|
35
|
-
Combines magnitude, unit, and scale into a single, composable object that
|
|
36
|
-
supports dimensional arithmetic and conversion:
|
|
37
|
-
|
|
38
|
-
>>> from ucon import core, units
|
|
39
|
-
>>> length = core.Number(unit=units.meter, quantity=5)
|
|
40
|
-
>>> time = core.Number(unit=units.second, quantity=2)
|
|
41
|
-
>>> speed = length / time
|
|
42
|
-
>>> speed
|
|
43
|
-
<2.5 (m/s)>
|
|
44
|
-
"""
|
|
45
|
-
quantity: Union[float, int] = 1.0
|
|
46
|
-
unit: Unit = units.none
|
|
47
|
-
|
|
48
|
-
@property
|
|
49
|
-
def value(self) -> float:
|
|
50
|
-
"""Return the numeric magnitude as-expressed (no scale folding).
|
|
51
|
-
|
|
52
|
-
Scale lives in the unit expression (e.g. kJ, mL) and is NOT
|
|
53
|
-
folded into the returned value. Use ``unit.fold_scale()`` on a
|
|
54
|
-
UnitProduct when you need the base-unit-equivalent magnitude.
|
|
55
|
-
"""
|
|
56
|
-
return round(self.quantity, 15)
|
|
57
|
-
|
|
58
|
-
@property
|
|
59
|
-
def _canonical_magnitude(self) -> float:
|
|
60
|
-
"""Quantity folded to base-unit scale (internal use for eq/div)."""
|
|
61
|
-
if isinstance(self.unit, UnitProduct):
|
|
62
|
-
return self.quantity * self.unit.fold_scale()
|
|
63
|
-
return self.quantity
|
|
64
|
-
|
|
65
|
-
def simplify(self):
|
|
66
|
-
"""Return a new Number expressed in base scale (Scale.one)."""
|
|
67
|
-
raise NotImplementedError("Unit simplification requires ConversionGraph; coming soon.")
|
|
68
|
-
|
|
69
|
-
def to(self, new_scale: Scale):
|
|
70
|
-
raise NotImplementedError("Unit conversion requires ConversionGraph; coming soon.")
|
|
71
|
-
|
|
72
|
-
def as_ratio(self):
|
|
73
|
-
return Ratio(self)
|
|
74
|
-
|
|
75
|
-
def __mul__(self, other: Quantifiable) -> 'Number':
|
|
76
|
-
if isinstance(other, Ratio):
|
|
77
|
-
other = other.evaluate()
|
|
78
|
-
|
|
79
|
-
if not isinstance(other, Number):
|
|
80
|
-
return NotImplemented
|
|
81
|
-
|
|
82
|
-
return Number(
|
|
83
|
-
quantity=self.quantity * other.quantity,
|
|
84
|
-
unit=self.unit * other.unit,
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
def __truediv__(self, other: Quantifiable) -> "Number":
|
|
88
|
-
# Allow dividing by a Ratio (interpret as its evaluated Number)
|
|
89
|
-
if isinstance(other, Ratio):
|
|
90
|
-
other = other.evaluate()
|
|
91
|
-
|
|
92
|
-
if not isinstance(other, Number):
|
|
93
|
-
raise TypeError("Cannot divide Number by non-Number/Ratio type: {type(other)}")
|
|
94
|
-
|
|
95
|
-
# Symbolic quotient in the unit algebra
|
|
96
|
-
unit_quot = self.unit / other.unit
|
|
97
|
-
|
|
98
|
-
# --- Case 1: Dimensionless result ----------------------------------
|
|
99
|
-
# If the net dimension is none, we want a pure scalar:
|
|
100
|
-
# fold *all* scale factors into the numeric magnitude.
|
|
101
|
-
if not unit_quot.dimension:
|
|
102
|
-
num = self._canonical_magnitude # quantity × scale
|
|
103
|
-
den = other._canonical_magnitude
|
|
104
|
-
return Number(quantity=num / den, unit=units.none)
|
|
105
|
-
|
|
106
|
-
# --- Case 2: Dimensionful result -----------------------------------
|
|
107
|
-
# For "real" physical results like g/mL, m/s², etc., preserve the
|
|
108
|
-
# user's chosen unit scales symbolically. Only divide the raw quantities.
|
|
109
|
-
new_quantity = self.quantity / other.quantity
|
|
110
|
-
return Number(quantity=new_quantity, unit=unit_quot)
|
|
111
|
-
|
|
112
|
-
def __eq__(self, other: Quantifiable) -> bool:
|
|
113
|
-
if not isinstance(other, (Number, Ratio)):
|
|
114
|
-
raise TypeError(
|
|
115
|
-
f"Cannot compare Number to non-Number/Ratio type: {type(other)}"
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
# If comparing with a Ratio, evaluate it to a Number
|
|
119
|
-
if isinstance(other, Ratio):
|
|
120
|
-
other = other.evaluate()
|
|
121
|
-
|
|
122
|
-
# Dimensions must match
|
|
123
|
-
if self.unit.dimension != other.unit.dimension:
|
|
124
|
-
return False
|
|
125
|
-
|
|
126
|
-
# Compare magnitudes, scale-adjusted
|
|
127
|
-
if abs(self._canonical_magnitude - other._canonical_magnitude) >= 1e-12:
|
|
128
|
-
return False
|
|
129
|
-
|
|
130
|
-
return True
|
|
131
|
-
|
|
132
|
-
def __repr__(self):
|
|
133
|
-
if not self.unit.dimension:
|
|
134
|
-
return f"<{self.quantity}>"
|
|
135
|
-
return f"<{self.quantity} {self.unit.shorthand}>"
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
# TODO -- consider using a dataclass
|
|
139
|
-
class Ratio:
|
|
140
|
-
"""
|
|
141
|
-
Represents a **ratio of two Numbers**, preserving their unit semantics.
|
|
142
|
-
|
|
143
|
-
Useful for expressing physical relationships like efficiency, density,
|
|
144
|
-
or dimensionless comparisons:
|
|
145
|
-
|
|
146
|
-
>>> ratio = Ratio(length, time)
|
|
147
|
-
>>> ratio.evaluate()
|
|
148
|
-
<2.5 (m/s)>
|
|
149
|
-
"""
|
|
150
|
-
def __init__(self, numerator: Number = Number(), denominator: Number = Number()):
|
|
151
|
-
self.numerator = numerator
|
|
152
|
-
self.denominator = denominator
|
|
153
|
-
|
|
154
|
-
def reciprocal(self) -> 'Ratio':
|
|
155
|
-
return Ratio(numerator=self.denominator, denominator=self.numerator)
|
|
156
|
-
|
|
157
|
-
def evaluate(self) -> "Number":
|
|
158
|
-
# Pure arithmetic, no scale normalization.
|
|
159
|
-
numeric = self.numerator.quantity / self.denominator.quantity
|
|
160
|
-
|
|
161
|
-
# Pure unit division, with UnitFactor preservation.
|
|
162
|
-
unit = self.numerator.unit / self.denominator.unit
|
|
163
|
-
|
|
164
|
-
# DO NOT normalize, DO NOT fold scale.
|
|
165
|
-
return Number(quantity=numeric, unit=unit)
|
|
166
|
-
|
|
167
|
-
def __mul__(self, another_ratio: 'Ratio') -> 'Ratio':
|
|
168
|
-
if self.numerator.unit == another_ratio.denominator.unit:
|
|
169
|
-
factor = self.numerator / another_ratio.denominator
|
|
170
|
-
numerator, denominator = factor * another_ratio.numerator, self.denominator
|
|
171
|
-
elif self.denominator.unit == another_ratio.numerator.unit:
|
|
172
|
-
factor = another_ratio.numerator / self.denominator
|
|
173
|
-
numerator, denominator = factor * self.numerator, another_ratio.denominator
|
|
174
|
-
else:
|
|
175
|
-
factor = Number()
|
|
176
|
-
another_number = another_ratio.evaluate()
|
|
177
|
-
numerator, denominator = self.numerator * another_number, self.denominator
|
|
178
|
-
return Ratio(numerator=numerator, denominator=denominator)
|
|
179
|
-
|
|
180
|
-
def __truediv__(self, another_ratio: 'Ratio') -> 'Ratio':
|
|
181
|
-
return Ratio(
|
|
182
|
-
numerator=self.numerator * another_ratio.denominator,
|
|
183
|
-
denominator=self.denominator * another_ratio.numerator,
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
def __eq__(self, another_ratio: 'Ratio') -> bool:
|
|
187
|
-
if isinstance(another_ratio, Ratio):
|
|
188
|
-
return self.evaluate() == another_ratio.evaluate()
|
|
189
|
-
elif isinstance(another_ratio, Number):
|
|
190
|
-
return self.evaluate() == another_ratio
|
|
191
|
-
else:
|
|
192
|
-
raise ValueError(f'"{another_ratio}" is not a Ratio or Number. Comparison not possible.')
|
|
15
|
+
from ucon.core import Number, Ratio, Quantifiable
|
|
193
16
|
|
|
194
|
-
|
|
195
|
-
# TODO -- resolve int/float inconsistency
|
|
196
|
-
return f'{self.evaluate()}' if self.numerator == self.denominator else f'{self.numerator} / {self.denominator}'
|
|
17
|
+
__all__ = ['Number', 'Ratio', 'Quantifiable']
|
ucon/units.py
CHANGED
|
@@ -35,40 +35,83 @@ none = Unit()
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
# -- International System of Units (SI) --------------------------------
|
|
38
|
-
ampere = Unit(
|
|
39
|
-
becquerel = Unit(
|
|
40
|
-
celsius = Unit(
|
|
41
|
-
coulomb = Unit(
|
|
42
|
-
farad = Unit(
|
|
43
|
-
gram = Unit(
|
|
44
|
-
gray = Unit(
|
|
45
|
-
henry = Unit(
|
|
46
|
-
hertz = Unit(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
liter = Unit(
|
|
52
|
-
lumen = Unit(
|
|
53
|
-
lux = Unit(
|
|
54
|
-
meter = Unit(
|
|
55
|
-
mole = Unit(
|
|
56
|
-
newton = Unit(
|
|
57
|
-
ohm = Unit(
|
|
58
|
-
pascal = Unit(
|
|
59
|
-
radian = Unit(
|
|
60
|
-
|
|
61
|
-
sievert = Unit(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
webers_per_meter = Unit('Wb/m', name='webers_per_meter', dimension=Dimension.magnetic_permeability)
|
|
38
|
+
ampere = Unit(name='ampere', dimension=Dimension.current, aliases=('I', 'amp'))
|
|
39
|
+
becquerel = Unit(name='becquerel', dimension=Dimension.frequency, aliases=('Bq',))
|
|
40
|
+
celsius = Unit(name='celsius', dimension=Dimension.temperature, aliases=('°C', 'degC'))
|
|
41
|
+
coulomb = Unit(name='coulomb', dimension=Dimension.charge, aliases=('C',))
|
|
42
|
+
farad = Unit(name='farad', dimension=Dimension.capacitance, aliases=('F',))
|
|
43
|
+
gram = Unit(name='gram', dimension=Dimension.mass, aliases=('g',))
|
|
44
|
+
gray = Unit(name='gray', dimension=Dimension.energy, aliases=('Gy',))
|
|
45
|
+
henry = Unit(name='henry', dimension=Dimension.inductance, aliases=('H',))
|
|
46
|
+
hertz = Unit(name='hertz', dimension=Dimension.frequency, aliases=('Hz',))
|
|
47
|
+
joule = Unit(name='joule', dimension=Dimension.energy, aliases=('J',))
|
|
48
|
+
joule_per_kelvin = Unit(name='joule_per_kelvin', dimension=Dimension.entropy, aliases=('J/K',))
|
|
49
|
+
kelvin = Unit(name='kelvin', dimension=Dimension.temperature, aliases=('K',))
|
|
50
|
+
kilogram = Unit(name='kilogram', dimension=Dimension.mass, aliases=('kg',))
|
|
51
|
+
liter = Unit(name='liter', dimension=Dimension.volume, aliases=('L', 'l'))
|
|
52
|
+
lumen = Unit(name='lumen', dimension=Dimension.luminous_intensity, aliases=('lm',))
|
|
53
|
+
lux = Unit(name='lux', dimension=Dimension.illuminance, aliases=('lx',))
|
|
54
|
+
meter = Unit(name='meter', dimension=Dimension.length, aliases=('m',))
|
|
55
|
+
mole = Unit(name='mole', dimension=Dimension.amount_of_substance, aliases=('mol', 'n'))
|
|
56
|
+
newton = Unit(name='newton', dimension=Dimension.force, aliases=('N',))
|
|
57
|
+
ohm = Unit(name='ohm', dimension=Dimension.resistance, aliases=('Ω',))
|
|
58
|
+
pascal = Unit(name='pascal', dimension=Dimension.pressure, aliases=('Pa',))
|
|
59
|
+
radian = Unit(name='radian', dimension=Dimension.none, aliases=('rad',))
|
|
60
|
+
siemens = Unit(name='siemens', dimension=Dimension.conductance, aliases=('S',))
|
|
61
|
+
sievert = Unit(name='sievert', dimension=Dimension.energy, aliases=('Sv',))
|
|
62
|
+
steradian = Unit(name='steradian', dimension=Dimension.none, aliases=('sr',))
|
|
63
|
+
tesla = Unit(name='tesla', dimension=Dimension.magnetic_flux_density, aliases=('T',))
|
|
64
|
+
volt = Unit(name='volt', dimension=Dimension.voltage, aliases=('V',))
|
|
65
|
+
watt = Unit(name='watt', dimension=Dimension.power, aliases=('W',))
|
|
66
|
+
weber = Unit(name='weber', dimension=Dimension.magnetic_flux, aliases=('Wb',))
|
|
67
|
+
webers_per_meter = Unit(name='webers_per_meter', dimension=Dimension.magnetic_permeability, aliases=('Wb/m',))
|
|
69
68
|
# ----------------------------------------------------------------------
|
|
70
69
|
|
|
71
70
|
|
|
71
|
+
# -- Time Units --------------------------------------------------------
|
|
72
|
+
second = Unit(name='second', dimension=Dimension.time, aliases=('s', 'sec'))
|
|
73
|
+
minute = Unit(name='minute', dimension=Dimension.time, aliases=('min',))
|
|
74
|
+
hour = Unit(name='hour', dimension=Dimension.time, aliases=('h', 'hr'))
|
|
75
|
+
day = Unit(name='day', dimension=Dimension.time, aliases=('d',))
|
|
76
|
+
# ----------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# -- Imperial / US Customary Units -------------------------------------
|
|
80
|
+
# Length
|
|
81
|
+
foot = Unit(name='foot', dimension=Dimension.length, aliases=('ft',))
|
|
82
|
+
inch = Unit(name='inch', dimension=Dimension.length, aliases=('in',))
|
|
83
|
+
yard = Unit(name='yard', dimension=Dimension.length, aliases=('yd',))
|
|
84
|
+
mile = Unit(name='mile', dimension=Dimension.length, aliases=('mi',))
|
|
85
|
+
|
|
86
|
+
# Mass
|
|
87
|
+
pound = Unit(name='pound', dimension=Dimension.mass, aliases=('lb', 'lbs'))
|
|
88
|
+
ounce = Unit(name='ounce', dimension=Dimension.mass, aliases=('oz',))
|
|
89
|
+
|
|
90
|
+
# Temperature
|
|
91
|
+
fahrenheit = Unit(name='fahrenheit', dimension=Dimension.temperature, aliases=('°F', 'degF'))
|
|
92
|
+
|
|
93
|
+
# Volume
|
|
94
|
+
gallon = Unit(name='gallon', dimension=Dimension.volume, aliases=('gal',))
|
|
95
|
+
|
|
96
|
+
# Energy
|
|
97
|
+
calorie = Unit(name='calorie', dimension=Dimension.energy, aliases=('cal',))
|
|
98
|
+
btu = Unit(name='btu', dimension=Dimension.energy, aliases=('BTU',))
|
|
99
|
+
|
|
100
|
+
# Power
|
|
101
|
+
horsepower = Unit(name='horsepower', dimension=Dimension.power, aliases=('hp',))
|
|
102
|
+
# ----------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# -- Information Units -------------------------------------------------
|
|
106
|
+
bit = Unit(name='bit', dimension=Dimension.information, aliases=('b',))
|
|
107
|
+
byte = Unit(name='byte', dimension=Dimension.information, aliases=('B',))
|
|
108
|
+
# ----------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Backward compatibility alias
|
|
112
|
+
webers = weber
|
|
113
|
+
|
|
114
|
+
|
|
72
115
|
def have(name: str) -> bool:
|
|
73
116
|
assert name, "Must provide a unit name to check"
|
|
74
117
|
assert isinstance(name, str), "Unit name must be a string"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ucon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: a tool for dimensional analysis: a "Unit CONverter"
|
|
5
5
|
Home-page: https://github.com/withtwoemms/ucon
|
|
6
6
|
Author: Emmanuel I. Obi
|
|
@@ -55,8 +55,8 @@ Dynamic: summary
|
|
|
55
55
|
It combines **units**, **scales**, and **dimensions** into a composable algebra that supports:
|
|
56
56
|
|
|
57
57
|
- Dimensional analysis through `Number` and `Ratio`
|
|
58
|
-
- Scale-aware arithmetic and
|
|
59
|
-
- Metric and binary prefixes (`kilo`, `kibi`, `micro`, `mebi`,
|
|
58
|
+
- Scale-aware arithmetic via `UnitFactor` and `UnitProduct`
|
|
59
|
+
- Metric and binary prefixes (`kilo`, `kibi`, `micro`, `mebi`, etc.)
|
|
60
60
|
- A clean foundation for physics, chemistry, data modeling, and beyond
|
|
61
61
|
|
|
62
62
|
Think of it as **`decimal.Decimal` for the physical world** — precise, predictable, and type-safe.
|
|
@@ -70,20 +70,24 @@ The crux of this tiny library is to provide abstractions that simplify the answe
|
|
|
70
70
|
To best answer this question, we turn to an age-old technique ([dimensional analysis](https://en.wikipedia.org/wiki/Dimensional_analysis)) which essentially allows for the solution to be written as a product of ratios. `ucon` comes equipped with some useful primitives:
|
|
71
71
|
| Type | Defined In | Purpose | Typical Use Cases |
|
|
72
72
|
| ----------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
73
|
-
| **`Vector`** | `ucon.
|
|
74
|
-
| **`
|
|
75
|
-
| **`
|
|
73
|
+
| **`Vector`** | `ucon.algebra` | Represents the 8-component exponent tuple of a physical quantity's base dimensions (T, L, M, I, Θ, J, N, B). | Internal representation of dimensional algebra; building derived quantities (e.g., area, velocity, force). |
|
|
74
|
+
| **`Exponent`** | `ucon.algebra` | Represents base-power pairs (e.g., 10³, 2¹⁰) used by `Scale`. | Performing arithmetic on powers and bases; normalizing scales across conversions. |
|
|
75
|
+
| **`Dimension`** | `ucon.core` | Encapsulates physical dimensions (e.g., length, time, mass) as algebraic combinations of vectors. | Enforcing dimensional consistency; defining relationships between quantities (e.g., length / time = velocity). |
|
|
76
76
|
| **`Scale`** | `ucon.core` | Encodes powers of base magnitudes (binary or decimal prefixes like kilo-, milli-, mebi-). | Adjusting numeric scale without changing dimension (e.g., kilometer ↔ meter, byte ↔ kibibyte). |
|
|
77
|
-
| **`
|
|
78
|
-
| **`
|
|
77
|
+
| **`Unit`** | `ucon.core` | An atomic, scale-free measurement symbol (e.g., meter, second, joule) with a `Dimension`. | Defining base units; serving as graph nodes for future conversions. |
|
|
78
|
+
| **`UnitFactor`** | `ucon.core` | Pairs a `Unit` with a `Scale` (e.g., kilo + gram = kg). Used as keys inside `UnitProduct`. | Preserving user-provided scale prefixes through algebraic operations. |
|
|
79
|
+
| **`UnitProduct`** | `ucon.core` | A product/quotient of `UnitFactor`s with exponent tracking and simplification. | Representing composite units like m/s, kg·m/s², kJ·h. |
|
|
80
|
+
| **`Number`** | `ucon.core` | Combines a numeric quantity with a unit; the primary measurable type. | Performing arithmetic with units; representing physical quantities like 5 m/s. |
|
|
79
81
|
| **`Ratio`** | `ucon.core` | Represents the division of two `Number` objects; captures relationships between quantities. | Expressing rates, densities, efficiencies (e.g., energy / time = power, length / time = velocity). |
|
|
80
|
-
| **`
|
|
82
|
+
| **`Map`** hierarchy | `ucon.maps` | Composable conversion morphisms: `LinearMap`, `AffineMap`, `ComposedMap`. | Defining conversion functions between units (e.g., meter→foot, celsius→kelvin). |
|
|
83
|
+
| **`ConversionGraph`** | `ucon.graph` | Registry of unit conversion edges with BFS path composition. | Converting between units via `Number.to(target)`; managing default and custom graphs. |
|
|
84
|
+
| **`units` module** | `ucon.units` | Defines canonical unit instances (SI, imperial, information, and derived units). | Quick access to standard physical units (`units.meter`, `units.foot`, `units.byte`, etc.). |
|
|
81
85
|
|
|
82
86
|
### Under the Hood
|
|
83
87
|
|
|
84
88
|
`ucon` models unit math through a hierarchy where each layer builds on the last:
|
|
85
89
|
|
|
86
|
-
<img src=https://gist.githubusercontent.com/withtwoemms/429d2ca1f979865aa80a2658bf9efa32/raw/
|
|
90
|
+
<img src=https://gist.githubusercontent.com/withtwoemms/429d2ca1f979865aa80a2658bf9efa32/raw/5df6a7fb2a6426ee6804096c092c10bed1b30b6f/ucon.data-model_v040.png align="center" alt="ucon Data Model" width=600/>
|
|
87
91
|
|
|
88
92
|
## Why `ucon`?
|
|
89
93
|
|
|
@@ -91,24 +95,22 @@ Python already has mature libraries for handling units and physical quantities
|
|
|
91
95
|
|
|
92
96
|
| Library | Focus | Limitation |
|
|
93
97
|
| --------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
94
|
-
| **Pint** | Runtime unit conversion and compatibility checking | Treats quantities as decorated numbers — conversions work, but the algebra behind them isn
|
|
98
|
+
| **Pint** | Runtime unit conversion and compatibility checking | Treats quantities as decorated numbers — conversions work, but the algebra behind them isn't inspectable or type-safe. |
|
|
95
99
|
| **SymPy** | Symbolic algebra and simplification of unit expressions | Excellent for symbolic reasoning, but not designed for runtime validation, conversion, or serialization. |
|
|
96
100
|
| **Unum** | Unit-aware arithmetic and unit propagation | Tracks units through arithmetic but lacks explicit dimensional algebra, conversion taxonomy, or runtime introspection. |
|
|
97
101
|
|
|
98
102
|
Together, these tools can _use_ units, but none can explicitly represent and verify the relationships between units and dimensions.
|
|
99
103
|
|
|
100
|
-
That
|
|
104
|
+
That's the gap `ucon` fills.
|
|
101
105
|
|
|
102
106
|
It treats units, dimensions, and scales as first-class objects and builds a composable algebra around them.
|
|
103
107
|
This allows you to:
|
|
104
108
|
- Represent dimensional meaning explicitly (`Dimension`, `Vector`);
|
|
105
109
|
- Compose and compute with type-safe, introspectable quantities (`Unit`, `Number`);
|
|
106
|
-
- Perform reversible, declarative conversions (standard, linear, affine, nonlinear);
|
|
107
|
-
- Serialize and validate measurements with Pydantic integration;
|
|
108
110
|
- Extend the system with custom unit registries and conversion families.
|
|
109
111
|
|
|
110
112
|
Where Pint, Unum, and SymPy focus on _how_ to compute with units,
|
|
111
|
-
`ucon` focuses on why those computations make sense. Every operation checks the dimensional structure, _not just the unit labels_. This means ucon doesn
|
|
113
|
+
`ucon` focuses on why those computations make sense. Every operation checks the dimensional structure, _not just the unit labels_. This means ucon doesn't just track names: it enforces physics:
|
|
112
114
|
```python
|
|
113
115
|
from ucon import Number, units
|
|
114
116
|
|
|
@@ -136,7 +138,8 @@ This sort of dimensional analysis:
|
|
|
136
138
|
```
|
|
137
139
|
becomes straightforward when you define a measurement:
|
|
138
140
|
```python
|
|
139
|
-
from ucon import Number, Scale,
|
|
141
|
+
from ucon import Number, Scale, units
|
|
142
|
+
from ucon.quantity import Ratio
|
|
140
143
|
|
|
141
144
|
# Two milliliters of bromine
|
|
142
145
|
mL = Scale.milli * units.liter
|
|
@@ -149,27 +152,52 @@ bromine_density = Ratio(
|
|
|
149
152
|
)
|
|
150
153
|
|
|
151
154
|
# Multiply to find mass
|
|
152
|
-
grams_bromine =
|
|
153
|
-
print(grams_bromine) # <6.238
|
|
155
|
+
grams_bromine = bromine_density.evaluate() * two_mL_bromine
|
|
156
|
+
print(grams_bromine) # <6.238 g>
|
|
154
157
|
```
|
|
155
158
|
|
|
156
|
-
Scale
|
|
159
|
+
Scale prefixes compose naturally:
|
|
160
|
+
```python
|
|
161
|
+
km = Scale.kilo * units.meter # UnitProduct with kilo-scaled meter
|
|
162
|
+
mg = Scale.milli * units.gram # UnitProduct with milli-scaled gram
|
|
163
|
+
|
|
164
|
+
print(km.shorthand) # 'km'
|
|
165
|
+
print(mg.shorthand) # 'mg'
|
|
166
|
+
|
|
167
|
+
# Scale arithmetic
|
|
168
|
+
print(km.fold_scale()) # 1000.0
|
|
169
|
+
print(mg.fold_scale()) # 0.001
|
|
170
|
+
```
|
|
157
171
|
|
|
172
|
+
Units are callable for ergonomic quantity construction:
|
|
158
173
|
```python
|
|
159
|
-
|
|
160
|
-
|
|
174
|
+
from ucon import units, Scale
|
|
175
|
+
|
|
176
|
+
# Callable syntax: unit(quantity) → Number
|
|
177
|
+
height = units.meter(1.8)
|
|
178
|
+
speed = (units.mile / units.hour)(60)
|
|
179
|
+
|
|
180
|
+
# Convert between units
|
|
181
|
+
height_ft = height.to(units.foot)
|
|
182
|
+
print(height_ft) # <5.905... ft>
|
|
183
|
+
|
|
184
|
+
# Scaled units work too
|
|
185
|
+
km = Scale.kilo * units.meter
|
|
186
|
+
distance = km(5)
|
|
187
|
+
distance_mi = distance.to(units.mile)
|
|
188
|
+
print(distance_mi) # <3.107... mi>
|
|
161
189
|
```
|
|
162
190
|
|
|
163
191
|
---
|
|
164
192
|
|
|
165
193
|
## Roadmap Highlights
|
|
166
194
|
|
|
167
|
-
| Version | Theme | Focus |
|
|
168
|
-
|
|
169
|
-
|
|
|
170
|
-
| [**0.4.x**](https://github.com/withtwoemms/ucon/milestone/2) | Conversion System |
|
|
171
|
-
| [**0.6.x**](https://github.com/withtwoemms/ucon/milestone/4) | Nonlinear / Specialized Units | Decibel, Percent, pH |
|
|
172
|
-
| [**0.8.x**](https://github.com/withtwoemms/ucon/milestone/6) | Pydantic Integration | Type-safe quantity validation |
|
|
195
|
+
| Version | Theme | Focus | Status |
|
|
196
|
+
|----------|-------|--------|--------|
|
|
197
|
+
| **0.3.5** | Dimensional Algebra | Unit/Scale separation, `UnitFactor`, `UnitProduct` | ✅ Complete |
|
|
198
|
+
| [**0.4.x**](https://github.com/withtwoemms/ucon/milestone/2) | Conversion System | `ConversionGraph`, `Number.to()`, callable units | 🚧 In Progress |
|
|
199
|
+
| [**0.6.x**](https://github.com/withtwoemms/ucon/milestone/4) | Nonlinear / Specialized Units | Decibel, Percent, pH | ⏳ Planned |
|
|
200
|
+
| [**0.8.x**](https://github.com/withtwoemms/ucon/milestone/6) | Pydantic Integration | Type-safe quantity validation | ⏳ Planned |
|
|
173
201
|
|
|
174
202
|
See full roadmap: [ROADMAP.md](./ROADMAP.md)
|
|
175
203
|
|
|
@@ -182,13 +210,13 @@ Ensure `nox` is installed.
|
|
|
182
210
|
```
|
|
183
211
|
pip install -r requirements.txt
|
|
184
212
|
```
|
|
185
|
-
Then run the full test suite (
|
|
213
|
+
Then run the full test suite (against all supported python versions) before committing:
|
|
186
214
|
|
|
187
215
|
```bash
|
|
188
216
|
nox -s test
|
|
189
217
|
```
|
|
190
218
|
---
|
|
191
219
|
|
|
192
|
-
>
|
|
220
|
+
> "If it can be measured, it can be represented.
|
|
193
221
|
If it can be represented, it can be validated.
|
|
194
|
-
If it can be validated, it can be trusted
|
|
222
|
+
If it can be validated, it can be trusted."
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
tests/ucon/__init__.py,sha256=9BAHYTs27Ed3VgqiMUH4XtVttAmOPgK0Zvj-dUNo7D8,119
|
|
2
|
+
tests/ucon/test_algebra.py,sha256=Esm38M1QBNsV7vdfoFgqRUluyvWX7yccB0RwZXk4DpA,8433
|
|
3
|
+
tests/ucon/test_core.py,sha256=NqmyKMkrF3T4ekucsFzVsxbkErdB6assbkhBAYH2fug,32664
|
|
4
|
+
tests/ucon/test_quantity.py,sha256=qJ-dXOPgIp0SKelASu45IC6KquTjlcRwHVkSKicsrXw,22754
|
|
5
|
+
tests/ucon/test_units.py,sha256=SILymDtDNDyxEhkYQubrfkakKCMexwEwjyHfhrkDrMI,869
|
|
6
|
+
tests/ucon/conversion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
tests/ucon/conversion/test_graph.py,sha256=R3u-0FCpgffoUXvJmT7aBJCUDUpzmBYROh5I0WLHA_Y,7256
|
|
8
|
+
tests/ucon/conversion/test_map.py,sha256=2_bxXCuga-lTGxU5rGferiziJmjzrltksA-6JJseEQ0,5359
|
|
9
|
+
ucon/__init__.py,sha256=jaFcNFZC5gxHKzM8OkS1pLxltp6ToLRVpuuhJQY9FKQ,2000
|
|
10
|
+
ucon/algebra.py,sha256=wGl4jJVMd8SXQ4sYDBOxV00ymAzRWfDhea1o4t2kVp4,7482
|
|
11
|
+
ucon/core.py,sha256=o3Q4posUOYoIhQVHl6bANCIcGKgGOpNZsnqGZw9ujYk,41523
|
|
12
|
+
ucon/graph.py,sha256=lgueyVdBI73pgqsydwRtOY9sxt02G-gy8-Lf7993RNw,14224
|
|
13
|
+
ucon/maps.py,sha256=yyZ7RqnohO2joTUvvKh40in7E6SKMQIQ8jkECO0-_NA,4753
|
|
14
|
+
ucon/quantity.py,sha256=GBxZ_96nocx-8F-usNWGbPvWHRhRgdZzqfH9Sx69iC4,465
|
|
15
|
+
ucon/units.py,sha256=PJFAqUoEq_0--Zo7JDHezZBPHPbAlS_5eArIVCLemxA,5852
|
|
16
|
+
ucon-0.4.0.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
|
|
17
|
+
ucon-0.4.0.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
|
|
18
|
+
ucon-0.4.0.dist-info/METADATA,sha256=_hZdjrgY1lvorBac-JPQQVzNVuNet-QY2zPP0G8-hik,12349
|
|
19
|
+
ucon-0.4.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
20
|
+
ucon-0.4.0.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
|
|
21
|
+
ucon-0.4.0.dist-info/RECORD,,
|
ucon-0.3.5rc1.dist-info/RECORD
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
tests/ucon/__init__.py,sha256=9BAHYTs27Ed3VgqiMUH4XtVttAmOPgK0Zvj-dUNo7D8,119
|
|
2
|
-
tests/ucon/test_algebra.py,sha256=0mxkiXibZfnzYtbscgVXPDcX1JelrVpcqNBcQe3cn3g,8330
|
|
3
|
-
tests/ucon/test_core.py,sha256=x5JLJAKuaTBkxQzYqTFnDaStVcIlnCviJNiN2OJ7KGQ,32435
|
|
4
|
-
tests/ucon/test_quantity.py,sha256=YLV78_t4AkZcJeEGu-lBvIXNLhTV_MLkTbIKV67rs4Y,14955
|
|
5
|
-
tests/ucon/test_units.py,sha256=SILymDtDNDyxEhkYQubrfkakKCMexwEwjyHfhrkDrMI,869
|
|
6
|
-
ucon/__init__.py,sha256=vJ6A2BU6s2_3vW4fExhn3VEjbn8yrvgF2hYBfGrKPrY,1865
|
|
7
|
-
ucon/algebra.py,sha256=ZVF8B2kUOeSN20R-lTHJNJDxe_Zv7s8kJ70F2JOSYxk,7262
|
|
8
|
-
ucon/core.py,sha256=cjq0hc5L7iA3ObWUT9JetUnWN6-WWqoQJ7c9YWbS35Y,29597
|
|
9
|
-
ucon/quantity.py,sha256=rjJLx1bktnfddfgn80m3eyVEPq0LU9RmUVR0aOeopqM,7290
|
|
10
|
-
ucon/units.py,sha256=-CShNMLr9t7f3pyYsfmZv3wMCZU4lEnoe8r_9YQWjxA,3783
|
|
11
|
-
ucon-0.3.5rc1.dist-info/licenses/LICENSE,sha256=LtimSYBSw1L_X6n1-VEdZRdwuROzPumrMUNX21asFuI,11356
|
|
12
|
-
ucon-0.3.5rc1.dist-info/licenses/NOTICE,sha256=bh4fBOItio3kM4hSNYhqfFpcaAvOoixjD7Du8im-sYA,1079
|
|
13
|
-
ucon-0.3.5rc1.dist-info/METADATA,sha256=EqRVt-FxtnB-C73U_-JZqzouAKJl_y3ofiBAKxxBVtM,10626
|
|
14
|
-
ucon-0.3.5rc1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
15
|
-
ucon-0.3.5rc1.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
|
|
16
|
-
ucon-0.3.5rc1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|