ucon 0.3.3rc2__py3-none-any.whl → 0.3.4__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/quantity.py ADDED
@@ -0,0 +1,249 @@
1
+ """
2
+ ucon.quantity
3
+ ==========
4
+
5
+ Implements the **quantitative core** of the *ucon* system — the machinery that
6
+ defines how numeric values are coupled with units and scales to represent
7
+ physical quantities.
8
+
9
+ Classes
10
+ -------
11
+ - :class:`Number` — Couples a numeric value with a unit and scale.
12
+ - :class:`Ratio` — Represents a ratio between two :class:`Number` objects.
13
+
14
+ Together, these classes allow full arithmetic, conversion, and introspection
15
+ of physical quantities with explicit dimensional semantics.
16
+ """
17
+ from dataclasses import dataclass
18
+ from typing import Union
19
+
20
+ from ucon import units
21
+ from ucon.core import CompositeUnit, Scale, Unit
22
+
23
+
24
+ Quantifiable = Union['Number', 'Ratio']
25
+
26
+ @dataclass
27
+ class Number:
28
+ """
29
+ Represents a **numeric quantity** with an associated :class:`Unit` and :class:`Scale`.
30
+
31
+ Combines magnitude, unit, and scale into a single, composable object that
32
+ supports dimensional arithmetic and conversion:
33
+
34
+ >>> from ucon import core, units
35
+ >>> length = core.Number(unit=units.meter, quantity=5)
36
+ >>> time = core.Number(unit=units.second, quantity=2)
37
+ >>> speed = length / time
38
+ >>> speed
39
+ <2.5 (m/s)>
40
+ """
41
+ quantity: Union[float, int] = 1.0
42
+ unit: Unit = units.none
43
+
44
+ @property
45
+ def value(self) -> float:
46
+ """Return numeric magnitude as quantity × scale factor."""
47
+ return round(self.quantity * self.unit.scale.value.evaluated, 15)
48
+
49
+ def simplify(self):
50
+ """Return a new Number expressed in base scale (Scale.one)."""
51
+ raise NotImplementedError("Unit simplification requires ConversionGraph; coming soon.")
52
+
53
+ def to(self, new_scale: Scale):
54
+ raise NotImplementedError("Unit conversion requires ConversionGraph; coming soon.")
55
+
56
+ def as_ratio(self):
57
+ return Ratio(self)
58
+
59
+ def _inherit_symbolic_identity(self, new_unit: Unit, lhs: Unit, rhs: Unit) -> Unit:
60
+ """
61
+ If new_unit has no name/aliases but is dimension-compatible
62
+ with either lhs or rhs, inherit symbolic identity.
63
+ """
64
+ if isinstance(new_unit, CompositeUnit):
65
+ return new_unit # composite units have their own structure
66
+
67
+ if new_unit.aliases or new_unit.name:
68
+ return new_unit # already has a symbol
69
+
70
+ if new_unit.scale is not Scale.one:
71
+ return new_unit # keep scaled units intact
72
+
73
+ # inheritance priority: lhs → rhs
74
+ if lhs.dimension == new_unit.dimension:
75
+ return Unit(
76
+ *lhs.aliases,
77
+ name=lhs.name,
78
+ dimension=new_unit.dimension,
79
+ scale=Scale.one,
80
+ )
81
+ if rhs.dimension == new_unit.dimension:
82
+ return Unit(
83
+ *rhs.aliases,
84
+ name=rhs.name,
85
+ dimension=new_unit.dimension,
86
+ scale=Scale.one,
87
+ )
88
+
89
+ return new_unit
90
+
91
+ def __mul__(self, other: Quantifiable) -> 'Number':
92
+ if isinstance(other, Ratio):
93
+ other = other.evaluate()
94
+
95
+ if not isinstance(other, Number):
96
+ return NotImplemented
97
+
98
+ return Number(
99
+ quantity=self.quantity * other.quantity,
100
+ unit=self.unit * other.unit,
101
+ )
102
+
103
+ def __truediv__(self, other: Quantifiable) -> "Number":
104
+ # Allow dividing by a Ratio (interpret as its evaluated Number)
105
+ if isinstance(other, Ratio):
106
+ other = other.evaluate()
107
+
108
+ if not isinstance(other, Number):
109
+ raise TypeError("Cannot divide Number by non-Number/Ratio type: {type(other)}")
110
+
111
+ # Symbolic quotient in the unit algebra
112
+ unit_quot = self.unit / other.unit
113
+
114
+ # --- Case 1: Dimensionless result ----------------------------------
115
+ # If the net dimension is none, we want a pure scalar:
116
+ # fold *all* scale factors into the numeric magnitude.
117
+ if not unit_quot.dimension:
118
+ num = self.value # quantity × scale
119
+ den = other.value
120
+ return Number(quantity=num / den, unit=units.none)
121
+
122
+ # --- Case 2: Dimensionful result -----------------------------------
123
+ # For "real" physical results like g/mL, m/s², etc., preserve the
124
+ # user's chosen unit scales symbolically. Only divide the raw quantities.
125
+ new_quantity = self.quantity / other.quantity
126
+ return Number(quantity=new_quantity, unit=unit_quot)
127
+
128
+ def __eq__(self, other: Quantifiable) -> bool:
129
+ if not isinstance(other, (Number, Ratio)):
130
+ raise TypeError(
131
+ f"Cannot compare Number to non-Number/Ratio type: {type(other)}"
132
+ )
133
+
134
+ # If comparing with a Ratio, evaluate it to a Number
135
+ if isinstance(other, Ratio):
136
+ other = other.evaluate()
137
+
138
+ # Dimensions must match
139
+ if self.unit.dimension != other.unit.dimension:
140
+ return False
141
+
142
+ # Compare magnitudes, scale-adjusted
143
+ if abs(self.value - other.value) >= 1e-12:
144
+ return False
145
+
146
+ return True
147
+
148
+ def __repr__(self):
149
+ if not self.unit.dimension:
150
+ return f"<{self.quantity}>"
151
+ return f"<{self.quantity} {self.unit.shorthand}>"
152
+
153
+
154
+ # TODO -- consider using a dataclass
155
+ class Ratio:
156
+ """
157
+ Represents a **ratio of two Numbers**, preserving their unit semantics.
158
+
159
+ Useful for expressing physical relationships like efficiency, density,
160
+ or dimensionless comparisons:
161
+
162
+ >>> ratio = Ratio(length, time)
163
+ >>> ratio.evaluate()
164
+ <2.5 (m/s)>
165
+ """
166
+ def __init__(self, numerator: Number = Number(), denominator: Number = Number()):
167
+ self.numerator = numerator
168
+ self.denominator = denominator
169
+
170
+ def reciprocal(self) -> 'Ratio':
171
+ return Ratio(numerator=self.denominator, denominator=self.numerator)
172
+
173
+ def evaluate(self) -> "Number":
174
+ # Pure arithmetic, no scale normalization.
175
+ numeric = self.numerator.quantity / self.denominator.quantity
176
+
177
+ # Pure unit division, with FactoredUnit preservation.
178
+ unit = self.numerator.unit / self.denominator.unit
179
+
180
+ # DO NOT normalize, DO NOT fold scale.
181
+ return Number(quantity=numeric, unit=unit)
182
+
183
+ def _fold_scales(self, unit: Union[Unit, CompositeUnit]):
184
+ """
185
+ Extracts numeric scaling from unit prefixes while preserving exponent structure.
186
+ Returns: (numeric_factor: float, stripped_unit: Unit|CompositeUnit)
187
+ """
188
+ # --- UNIT CASE ----------------------------------------------------
189
+ if isinstance(unit, Unit) and not isinstance(unit, CompositeUnit):
190
+ return self._fold_scales_from_unit(unit)
191
+
192
+ # --- COMPOSITE CASE -----------------------------------------------
193
+ total = 1.0
194
+ normalized: dict[Unit, float] = {}
195
+
196
+ for u, exp in unit.components.items():
197
+ factor, base_unit = self._fold_scales_from_unit(u, exp)
198
+ total *= factor
199
+ if abs(exp) >= 1e-12: # drop zero powers
200
+ normalized[base_unit] = normalized.get(base_unit, 0) + exp
201
+
202
+ return total, CompositeUnit(normalized) if normalized else units.none
203
+
204
+ def _fold_scales_from_unit(self, u: Unit, power: float = 1):
205
+ """Extract numeric scale^power and return (factor, scale-free Unit)."""
206
+ if u.scale is Scale.one:
207
+ return 1.0, Unit(*u.aliases, name=u.name, dimension=u.dimension, scale=Scale.one)
208
+
209
+ factor = u.scale.value.evaluated ** power
210
+ return factor, Unit(*u.aliases, name=u.name, dimension=u.dimension, scale=Scale.one)
211
+
212
+ def normalize(self, number: Number) -> Number:
213
+ scale_factor, base_unit = self._fold_scales(number.unit)
214
+
215
+ return Number(
216
+ quantity = number.quantity * scale_factor,
217
+ unit = base_unit
218
+ )
219
+
220
+ def __mul__(self, another_ratio: 'Ratio') -> 'Ratio':
221
+ if self.numerator.unit == another_ratio.denominator.unit:
222
+ factor = self.numerator / another_ratio.denominator
223
+ numerator, denominator = factor * another_ratio.numerator, self.denominator
224
+ elif self.denominator.unit == another_ratio.numerator.unit:
225
+ factor = another_ratio.numerator / self.denominator
226
+ numerator, denominator = factor * self.numerator, another_ratio.denominator
227
+ else:
228
+ factor = Number()
229
+ another_number = another_ratio.evaluate()
230
+ numerator, denominator = self.numerator * another_number, self.denominator
231
+ return Ratio(numerator=numerator, denominator=denominator)
232
+
233
+ def __truediv__(self, another_ratio: 'Ratio') -> 'Ratio':
234
+ return Ratio(
235
+ numerator=self.numerator * another_ratio.denominator,
236
+ denominator=self.denominator * another_ratio.numerator,
237
+ )
238
+
239
+ def __eq__(self, another_ratio: 'Ratio') -> bool:
240
+ if isinstance(another_ratio, Ratio):
241
+ return self.evaluate() == another_ratio.evaluate()
242
+ elif isinstance(another_ratio, Number):
243
+ return self.evaluate() == another_ratio
244
+ else:
245
+ raise ValueError(f'"{another_ratio}" is not a Ratio or Number. Comparison not possible.')
246
+
247
+ def __repr__(self):
248
+ # TODO -- resolve int/float inconsistency
249
+ return f'{self.evaluate()}' if self.numerator == self.denominator else f'{self.numerator} / {self.denominator}'
ucon/units.py CHANGED
@@ -24,8 +24,7 @@ Notes
24
24
  The design allows for future extensibility: users can register their own units,
25
25
  systems, or aliases dynamically, without modifying the core definitions.
26
26
  """
27
- from ucon.dimension import Dimension
28
- from ucon.unit import Unit
27
+ from ucon.core import Dimension, Unit
29
28
 
30
29
 
31
30
  none = Unit()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucon
3
- Version: 0.3.3rc2
3
+ Version: 0.3.4
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
@@ -82,7 +82,7 @@ To best answer this question, we turn to an age-old technique ([dimensional anal
82
82
 
83
83
  `ucon` models unit math through a hierarchy where each layer builds on the last:
84
84
 
85
- <img src=https://gist.githubusercontent.com/withtwoemms/429d2ca1f979865aa80a2658bf9efa32/raw/f3518d37445301950026fc9ffd1bd062768005fe/ucon.data-model.png align="center" alt="ucon Data Model" width=600/>
85
+ <img src=https://gist.githubusercontent.com/withtwoemms/429d2ca1f979865aa80a2658bf9efa32/raw/0c704737a52b9e4a87cda5c839e9aa40f7e5bb48/ucon.data-model_v035.png align="center" alt="ucon Data Model" width=600/>
86
86
 
87
87
  ## Why `ucon`?
88
88
 
@@ -138,12 +138,13 @@ becomes straightforward when you define a measurement:
138
138
  from ucon import Number, Scale, Units, Ratio
139
139
 
140
140
  # Two milliliters of bromine
141
- two_mL_bromine = Number(unit=Units.liter, scale=Scale.milli, quantity=2)
141
+ mL = Scale.milli * units.liter
142
+ two_mL_bromine = Number(quantity=2, unit=mL)
142
143
 
143
144
  # Density of bromine: 3.119 g/mL
144
145
  bromine_density = Ratio(
145
- numerator=Number(unit=Units.gram, quantity=3.119),
146
- denominator=Number(unit=Units.liter, scale=Scale.milli),
146
+ numerator=Number(unit=units.gram, quantity=3.119),
147
+ denominator=Number(unit=mL),
147
148
  )
148
149
 
149
150
  # Multiply to find mass
@@ -0,0 +1,15 @@
1
+ tests/ucon/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ tests/ucon/test_algebra.py,sha256=seQ5qfvtqc7uojzldQIk4jRr5O9FCNqpagox1Q53lMU,8212
3
+ tests/ucon/test_core.py,sha256=CMbToQiFJ_2MW26qV2RGKFWo-tLwz_3XN7O7dRDaoBg,26605
4
+ tests/ucon/test_quantity.py,sha256=XacQHxinlwoABR6tHPrw27ct5b_4le36c0e7d2h0JIw,14486
5
+ tests/ucon/test_units.py,sha256=NUEbcKgvj5nn9xIe3D-5NoaOpQry5Dkg_OmIAxY7QpU,777
6
+ ucon/__init__.py,sha256=B_yFxd47zl4toP4v5M6ODfJNnssoSzQz3VTM2md1rfg,1745
7
+ ucon/algebra.py,sha256=qe7Hfvo_P4YiBjSahBQu6rcH0ZfjBuO1cGtqG-ip_x8,7142
8
+ ucon/core.py,sha256=54zJpnutgK-RwrqajS81p8uiR_26ADSSzfI6co7fOFo,28797
9
+ ucon/quantity.py,sha256=wbgzc48Qs1ncgtU0Dz0F8x6q88eKe8jK2Ii5lVJKJ4E,9344
10
+ ucon/units.py,sha256=HqpATy3QPISLRfenWFaNnc-w6QhF1_Mm6_XIb93imOk,3663
11
+ ucon-0.3.4.dist-info/licenses/LICENSE,sha256=-Djjiq2wM8Cc6fzTsdMbr_T2_uaX6Yorxcemr3GGkqc,1072
12
+ ucon-0.3.4.dist-info/METADATA,sha256=qXIkp1XhZmjpDdKoY_mqk8LrA8b0h2av0WezAuCQZwE,10583
13
+ ucon-0.3.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ ucon-0.3.4.dist-info/top_level.txt,sha256=zZYRJiQrVUtN32ziJD2YEq7ClSvDmVYHYy5ArRAZGxI,11
15
+ ucon-0.3.4.dist-info/RECORD,,
@@ -1,206 +0,0 @@
1
- import unittest
2
- from ucon.dimension import Vector, Dimension
3
-
4
-
5
- class TestVector(unittest.TestCase):
6
-
7
- def test_vector_iteration_and_length(self):
8
- v = Vector(1, 0, 0, 0, 0, 0, 0)
9
- self.assertEqual(tuple(v), (1, 0, 0, 0, 0, 0, 0))
10
- self.assertEqual(len(v), 7) # always 7 components
11
-
12
- def test_vector_addition(self):
13
- v1 = Vector(1, 0, 0, 0, 0, 0, 0)
14
- v2 = Vector(0, 2, 0, 0, 0, 0, 0)
15
- result = v1 + v2
16
- self.assertEqual(result, Vector(1, 2, 0, 0, 0, 0, 0))
17
-
18
- def test_vector_subtraction(self):
19
- v1 = Vector(2, 1, 0, 0, 0, 0, 0)
20
- v2 = Vector(1, 1, 0, 0, 0, 0, 0)
21
- self.assertEqual(v1 - v2, Vector(1, 0, 0, 0, 0, 0, 0))
22
-
23
- def test_vector_equality_and_hash(self):
24
- v1 = Vector(1, 0, 0, 0, 0, 0, 0)
25
- v2 = Vector(1, 0, 0, 0, 0, 0, 0)
26
- v3 = Vector(0, 1, 0, 0, 0, 0, 0)
27
- self.assertTrue(v1 == v2)
28
- self.assertFalse(v1 == v3)
29
- self.assertEqual(hash(v1), hash(v2))
30
- self.assertNotEqual(hash(v1), hash(v3))
31
-
32
-
33
- class TestDimension(unittest.TestCase):
34
-
35
- def test_basic_dimensions_are_unique(self):
36
- seen = set()
37
- for dim in Dimension:
38
- self.assertNotIn(dim.value, seen, f'Duplicate vector found for {dim.name}')
39
- seen.add(dim.value)
40
-
41
- def test_multiplication_adds_exponents(self):
42
- self.assertEqual(
43
- Dimension.mass * Dimension.acceleration,
44
- Dimension.force,
45
- )
46
- self.assertEqual(
47
- Dimension.length * Dimension.length,
48
- Dimension.area,
49
- )
50
- self.assertEqual(
51
- Dimension.length * Dimension.length * Dimension.length,
52
- Dimension.volume,
53
- )
54
-
55
- def test_division_subtracts_exponents(self):
56
- self.assertEqual(
57
- Dimension.length / Dimension.time,
58
- Dimension.velocity,
59
- )
60
- self.assertEqual(
61
- Dimension.force / Dimension.area,
62
- Dimension.pressure,
63
- )
64
-
65
- def test_none_dimension_behaves_neutrally(self):
66
- base = Dimension.mass
67
- self.assertEqual(base * Dimension.none, base)
68
- self.assertEqual(base / Dimension.none, base)
69
- self.assertEqual(Dimension.none * base, base)
70
- with self.assertRaises(ValueError) as exc:
71
- Dimension.none / base
72
- assert type(exc.exception) == ValueError
73
- assert str(exc.exception).endswith('is not a valid Dimension')
74
-
75
- def test_hash_and_equality_consistency(self):
76
- d1 = Dimension.mass
77
- d2 = Dimension.mass
78
- d3 = Dimension.length
79
- self.assertEqual(d1, d2)
80
- self.assertNotEqual(d1, d3)
81
- self.assertEqual(hash(d1), hash(d2))
82
- self.assertNotEqual(hash(d1), hash(d3))
83
-
84
- def test_composite_quantities_examples(self):
85
- # Energy = Force * Length
86
- self.assertEqual(
87
- Dimension.force * Dimension.length,
88
- Dimension.energy,
89
- )
90
- # Power = Energy / Time
91
- self.assertEqual(
92
- Dimension.energy / Dimension.time,
93
- Dimension.power,
94
- )
95
- # Pressure = Force / Area
96
- self.assertEqual(
97
- Dimension.force / Dimension.area,
98
- Dimension.pressure,
99
- )
100
- # Charge = Current * Time
101
- self.assertEqual(
102
- Dimension.current * Dimension.time,
103
- Dimension.charge,
104
- )
105
-
106
- def test_vector_equality_reflects_dimension_equality(self):
107
- self.assertEqual(Dimension.mass.value, Dimension.mass.value)
108
- self.assertNotEqual(Dimension.mass.value, Dimension.time.value)
109
- self.assertEqual(Dimension.mass, Dimension.mass)
110
- self.assertNotEqual(Dimension.mass, Dimension.time)
111
-
112
-
113
- class TestVectorEdgeCases(unittest.TestCase):
114
-
115
- def test_zero_vector_equality_and_additivity(self):
116
- zero = Vector()
117
- self.assertEqual(zero, Vector(0, 0, 0, 0, 0, 0, 0))
118
- # Adding or subtracting zero should yield same vector
119
- v = Vector(1, 2, 3, 4, 5, 6, 7)
120
- self.assertEqual(v + zero, v)
121
- self.assertEqual(v - zero, v)
122
-
123
- def test_vector_with_negative_exponents(self):
124
- v1 = Vector(1, -2, 3, 0, 0, 0, 0)
125
- v2 = Vector(-1, 2, -3, 0, 0, 0, 0)
126
- result = v1 + v2
127
- self.assertEqual(result, Vector(0, 0, 0, 0, 0, 0, 0))
128
- self.assertEqual(v1 - v1, Vector()) # perfect cancellation
129
-
130
- def test_vector_equality_with_non_vector(self):
131
- v = Vector()
132
- with self.assertRaises(AssertionError):
133
- v == "not a vector"
134
- with self.assertRaises(AssertionError):
135
- v == None
136
-
137
- def test_hash_consistency_for_equal_vectors(self):
138
- v1 = Vector(1, 0, 0, 0, 0, 0, 0)
139
- v2 = Vector(1, 0, 0, 0, 0, 0, 0)
140
- self.assertEqual(hash(v1), hash(v2))
141
- self.assertEqual(len({v1, v2}), 1)
142
-
143
- def test_iter_length_order_consistency(self):
144
- v = Vector(1, 2, 3, 4, 5, 6, 7)
145
- components = list(v)
146
- self.assertEqual(len(components), len(v))
147
- # Ensure order of iteration is fixed (T→L→M→I→Θ→J→N)
148
- self.assertEqual(components, [1, 2, 3, 4, 5, 6, 7])
149
-
150
- def test_vector_arithmetic_does_not_mutate_operands(self):
151
- v1 = Vector(1, 0, 0, 0, 0, 0, 0)
152
- v2 = Vector(0, 1, 0, 0, 0, 0, 0)
153
- _ = v1 + v2
154
- self.assertEqual(v1, Vector(1, 0, 0, 0, 0, 0, 0))
155
- self.assertEqual(v2, Vector(0, 1, 0, 0, 0, 0, 0))
156
-
157
- def test_invalid_addition_type_raises(self):
158
- v = Vector(1, 0, 0, 0, 0, 0, 0)
159
- with self.assertRaises(TypeError):
160
- _ = v + "length"
161
- with self.assertRaises(TypeError):
162
- _ = v - 5
163
-
164
-
165
- class TestDimensionEdgeCases(unittest.TestCase):
166
-
167
- def test_invalid_multiplication_type(self):
168
- with self.assertRaises(TypeError):
169
- Dimension.length * 5
170
- with self.assertRaises(TypeError):
171
- "mass" * Dimension.time
172
-
173
- def test_invalid_division_type(self):
174
- with self.assertRaises(TypeError):
175
- Dimension.time / "length"
176
- with self.assertRaises(TypeError):
177
- 5 / Dimension.mass
178
-
179
- def test_equality_with_non_dimension(self):
180
- with self.assertRaises(TypeError):
181
- Dimension.mass == "mass"
182
-
183
- def test_enum_uniqueness_and_hash(self):
184
- # Hashes should be unique per distinct dimension
185
- hashes = {hash(d) for d in Dimension}
186
- self.assertEqual(len(hashes), len(Dimension))
187
- # All Dimension.value entries must be distinct Vectors
188
- values = [d.value for d in Dimension]
189
- self.assertEqual(len(values), len(set(values)))
190
-
191
- def test_combined_chained_operations(self):
192
- # (mass * acceleration) / area = pressure
193
- result = (Dimension.mass * Dimension.acceleration) / Dimension.area
194
- self.assertEqual(result, Dimension.pressure)
195
-
196
- def test_dimension_round_trip_equality(self):
197
- # Multiplying and dividing by the same dimension returns self
198
- d = Dimension.energy
199
- self.assertEqual((d * Dimension.none) / Dimension.none, d)
200
- self.assertEqual(d / Dimension.none, d)
201
- self.assertEqual(Dimension.none * d, d)
202
-
203
- def test_enum_is_hashable_and_iterable(self):
204
- seen = {d for d in Dimension}
205
- self.assertIn(Dimension.mass, seen)
206
- self.assertEqual(len(seen), len(Dimension))
tests/ucon/test_unit.py DELETED
@@ -1,143 +0,0 @@
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))