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
|
@@ -1,409 +0,0 @@
|
|
|
1
|
-
# © 2025 The Radiativity Company
|
|
2
|
-
# Licensed under the Apache License, Version 2.0
|
|
3
|
-
# See the LICENSE file for details.
|
|
4
|
-
|
|
5
|
-
import unittest
|
|
6
|
-
|
|
7
|
-
from ucon.maps import AffineMap, ComposedMap, LinearMap, Map
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class TestLinearMap(unittest.TestCase):
|
|
11
|
-
|
|
12
|
-
def test_apply(self):
|
|
13
|
-
m = LinearMap(39.37)
|
|
14
|
-
self.assertAlmostEqual(m(1.0), 39.37)
|
|
15
|
-
self.assertAlmostEqual(m(0.0), 0.0)
|
|
16
|
-
self.assertAlmostEqual(m(2.5), 98.425)
|
|
17
|
-
|
|
18
|
-
def test_inverse(self):
|
|
19
|
-
m = LinearMap(39.37)
|
|
20
|
-
inv = m.inverse()
|
|
21
|
-
self.assertIsInstance(inv, LinearMap)
|
|
22
|
-
self.assertAlmostEqual(inv.a, 1.0 / 39.37)
|
|
23
|
-
|
|
24
|
-
def test_inverse_zero_raises(self):
|
|
25
|
-
m = LinearMap(0)
|
|
26
|
-
with self.assertRaises(ZeroDivisionError):
|
|
27
|
-
m.inverse()
|
|
28
|
-
|
|
29
|
-
def test_round_trip(self):
|
|
30
|
-
m = LinearMap(39.37)
|
|
31
|
-
for x in [0.0, 1.0, -5.5, 1000.0]:
|
|
32
|
-
self.assertAlmostEqual(m.inverse()(m(x)), x, places=10)
|
|
33
|
-
|
|
34
|
-
def test_compose_closed(self):
|
|
35
|
-
f = LinearMap(39.37)
|
|
36
|
-
g = LinearMap(1.0 / 12.0)
|
|
37
|
-
composed = f @ g
|
|
38
|
-
self.assertIsInstance(composed, LinearMap)
|
|
39
|
-
self.assertAlmostEqual(composed.a, 39.37 / 12.0)
|
|
40
|
-
|
|
41
|
-
def test_compose_apply(self):
|
|
42
|
-
f = LinearMap(2.0)
|
|
43
|
-
g = LinearMap(3.0)
|
|
44
|
-
# (f @ g)(x) = f(g(x)) = 2 * (3 * x) = 6x
|
|
45
|
-
self.assertAlmostEqual((f @ g)(5.0), 30.0)
|
|
46
|
-
|
|
47
|
-
def test_identity(self):
|
|
48
|
-
ident = LinearMap.identity()
|
|
49
|
-
self.assertAlmostEqual(ident(42.0), 42.0)
|
|
50
|
-
m = LinearMap(7.0)
|
|
51
|
-
self.assertEqual(m @ ident, m)
|
|
52
|
-
self.assertEqual(ident @ m, m)
|
|
53
|
-
|
|
54
|
-
def test_invertible(self):
|
|
55
|
-
self.assertTrue(LinearMap(5.0).invertible)
|
|
56
|
-
self.assertFalse(LinearMap(0).invertible)
|
|
57
|
-
|
|
58
|
-
def test_eq(self):
|
|
59
|
-
self.assertEqual(LinearMap(3.0), LinearMap(3.0))
|
|
60
|
-
self.assertNotEqual(LinearMap(3.0), LinearMap(4.0))
|
|
61
|
-
|
|
62
|
-
def test_repr(self):
|
|
63
|
-
self.assertIn("39.37", repr(LinearMap(39.37)))
|
|
64
|
-
|
|
65
|
-
def test_matmul_non_map_returns_not_implemented(self):
|
|
66
|
-
m = LinearMap(2.0)
|
|
67
|
-
result = m.__matmul__(42)
|
|
68
|
-
self.assertIs(result, NotImplemented)
|
|
69
|
-
|
|
70
|
-
def test_matmul_with_affine(self):
|
|
71
|
-
lin = LinearMap(2.0)
|
|
72
|
-
aff = AffineMap(3.0, 5.0)
|
|
73
|
-
composed = lin @ aff
|
|
74
|
-
# lin(aff(x)) = 2 * (3x + 5) = 6x + 10
|
|
75
|
-
self.assertIsInstance(composed, AffineMap)
|
|
76
|
-
self.assertAlmostEqual(composed.a, 6.0)
|
|
77
|
-
self.assertAlmostEqual(composed.b, 10.0)
|
|
78
|
-
self.assertAlmostEqual(composed(1.0), 16.0)
|
|
79
|
-
|
|
80
|
-
def test_pow(self):
|
|
81
|
-
m = LinearMap(3.0)
|
|
82
|
-
squared = m ** 2
|
|
83
|
-
self.assertIsInstance(squared, LinearMap)
|
|
84
|
-
self.assertAlmostEqual(squared.a, 9.0)
|
|
85
|
-
|
|
86
|
-
def test_pow_negative(self):
|
|
87
|
-
m = LinearMap(4.0)
|
|
88
|
-
inv = m ** -1
|
|
89
|
-
self.assertIsInstance(inv, LinearMap)
|
|
90
|
-
self.assertAlmostEqual(inv.a, 0.25)
|
|
91
|
-
|
|
92
|
-
def test_pow_fractional(self):
|
|
93
|
-
m = LinearMap(4.0)
|
|
94
|
-
sqrt = m ** 0.5
|
|
95
|
-
self.assertIsInstance(sqrt, LinearMap)
|
|
96
|
-
self.assertAlmostEqual(sqrt.a, 2.0)
|
|
97
|
-
|
|
98
|
-
def test_is_identity_true(self):
|
|
99
|
-
m = LinearMap(1.0)
|
|
100
|
-
self.assertTrue(m.is_identity())
|
|
101
|
-
|
|
102
|
-
def test_is_identity_false(self):
|
|
103
|
-
m = LinearMap(2.0)
|
|
104
|
-
self.assertFalse(m.is_identity())
|
|
105
|
-
|
|
106
|
-
def test_is_identity_near_one(self):
|
|
107
|
-
m = LinearMap(1.0 + 1e-10)
|
|
108
|
-
self.assertTrue(m.is_identity())
|
|
109
|
-
|
|
110
|
-
def test_hash(self):
|
|
111
|
-
m1 = LinearMap(3.0)
|
|
112
|
-
m2 = LinearMap(3.0)
|
|
113
|
-
self.assertEqual(hash(m1), hash(m2))
|
|
114
|
-
self.assertEqual(len({m1, m2}), 1)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
class TestAffineMap(unittest.TestCase):
|
|
118
|
-
|
|
119
|
-
def test_apply(self):
|
|
120
|
-
# Celsius to Fahrenheit: F = 1.8 * C + 32
|
|
121
|
-
c_to_f = AffineMap(1.8, 32.0)
|
|
122
|
-
self.assertAlmostEqual(c_to_f(0.0), 32.0)
|
|
123
|
-
self.assertAlmostEqual(c_to_f(100.0), 212.0)
|
|
124
|
-
self.assertAlmostEqual(c_to_f(-40.0), -40.0)
|
|
125
|
-
|
|
126
|
-
def test_inverse(self):
|
|
127
|
-
c_to_f = AffineMap(1.8, 32.0)
|
|
128
|
-
f_to_c = c_to_f.inverse()
|
|
129
|
-
self.assertIsInstance(f_to_c, AffineMap)
|
|
130
|
-
self.assertAlmostEqual(f_to_c(32.0), 0.0)
|
|
131
|
-
self.assertAlmostEqual(f_to_c(212.0), 100.0)
|
|
132
|
-
|
|
133
|
-
def test_inverse_zero_raises(self):
|
|
134
|
-
m = AffineMap(0, 5.0)
|
|
135
|
-
with self.assertRaises(ZeroDivisionError):
|
|
136
|
-
m.inverse()
|
|
137
|
-
|
|
138
|
-
def test_round_trip(self):
|
|
139
|
-
m = AffineMap(1.8, 32.0)
|
|
140
|
-
for x in [0.0, 100.0, -40.0, 37.5]:
|
|
141
|
-
self.assertAlmostEqual(m.inverse()(m(x)), x, places=10)
|
|
142
|
-
|
|
143
|
-
def test_compose_closed(self):
|
|
144
|
-
f = AffineMap(2.0, 3.0)
|
|
145
|
-
g = AffineMap(4.0, 5.0)
|
|
146
|
-
composed = f @ g
|
|
147
|
-
self.assertIsInstance(composed, AffineMap)
|
|
148
|
-
# f(g(x)) = 2*(4x+5)+3 = 8x+13
|
|
149
|
-
self.assertAlmostEqual(composed.a, 8.0)
|
|
150
|
-
self.assertAlmostEqual(composed.b, 13.0)
|
|
151
|
-
|
|
152
|
-
def test_compose_apply(self):
|
|
153
|
-
f = AffineMap(2.0, 3.0)
|
|
154
|
-
g = AffineMap(4.0, 5.0)
|
|
155
|
-
for x in [0.0, 1.0, -2.0]:
|
|
156
|
-
self.assertAlmostEqual((f @ g)(x), f(g(x)), places=10)
|
|
157
|
-
|
|
158
|
-
def test_invertible(self):
|
|
159
|
-
self.assertTrue(AffineMap(1.8, 32.0).invertible)
|
|
160
|
-
self.assertFalse(AffineMap(0, 32.0).invertible)
|
|
161
|
-
|
|
162
|
-
def test_eq(self):
|
|
163
|
-
self.assertEqual(AffineMap(1.8, 32.0), AffineMap(1.8, 32.0))
|
|
164
|
-
self.assertNotEqual(AffineMap(1.8, 32.0), AffineMap(1.8, 0.0))
|
|
165
|
-
|
|
166
|
-
def test_repr(self):
|
|
167
|
-
r = repr(AffineMap(1.8, 32.0))
|
|
168
|
-
self.assertIn("1.8", r)
|
|
169
|
-
self.assertIn("32.0", r)
|
|
170
|
-
|
|
171
|
-
def test_matmul_with_linear(self):
|
|
172
|
-
aff = AffineMap(2.0, 3.0)
|
|
173
|
-
lin = LinearMap(4.0)
|
|
174
|
-
composed = aff @ lin
|
|
175
|
-
# aff(lin(x)) = 2 * (4x) + 3 = 8x + 3
|
|
176
|
-
self.assertIsInstance(composed, AffineMap)
|
|
177
|
-
self.assertAlmostEqual(composed.a, 8.0)
|
|
178
|
-
self.assertAlmostEqual(composed.b, 3.0)
|
|
179
|
-
self.assertAlmostEqual(composed(1.0), 11.0)
|
|
180
|
-
|
|
181
|
-
def test_matmul_non_map_returns_not_implemented(self):
|
|
182
|
-
m = AffineMap(1.8, 32.0)
|
|
183
|
-
result = m.__matmul__("not a map")
|
|
184
|
-
self.assertIs(result, NotImplemented)
|
|
185
|
-
|
|
186
|
-
def test_pow_one(self):
|
|
187
|
-
m = AffineMap(1.8, 32.0)
|
|
188
|
-
result = m ** 1
|
|
189
|
-
self.assertIs(result, m)
|
|
190
|
-
|
|
191
|
-
def test_pow_negative_one(self):
|
|
192
|
-
m = AffineMap(1.8, 32.0)
|
|
193
|
-
result = m ** -1
|
|
194
|
-
inv = m.inverse()
|
|
195
|
-
self.assertEqual(result.a, inv.a)
|
|
196
|
-
self.assertEqual(result.b, inv.b)
|
|
197
|
-
|
|
198
|
-
def test_pow_invalid_raises(self):
|
|
199
|
-
m = AffineMap(1.8, 32.0)
|
|
200
|
-
with self.assertRaises(ValueError) as ctx:
|
|
201
|
-
m ** 2
|
|
202
|
-
self.assertIn("only supports exp=1 or exp=-1", str(ctx.exception))
|
|
203
|
-
|
|
204
|
-
def test_is_identity_true(self):
|
|
205
|
-
m = AffineMap(1.0, 0.0)
|
|
206
|
-
self.assertTrue(m.is_identity())
|
|
207
|
-
|
|
208
|
-
def test_is_identity_false_due_to_offset(self):
|
|
209
|
-
m = AffineMap(1.0, 5.0)
|
|
210
|
-
self.assertFalse(m.is_identity())
|
|
211
|
-
|
|
212
|
-
def test_is_identity_false_due_to_scale(self):
|
|
213
|
-
m = AffineMap(2.0, 0.0)
|
|
214
|
-
self.assertFalse(m.is_identity())
|
|
215
|
-
|
|
216
|
-
def test_hash(self):
|
|
217
|
-
m1 = AffineMap(1.8, 32.0)
|
|
218
|
-
m2 = AffineMap(1.8, 32.0)
|
|
219
|
-
self.assertEqual(hash(m1), hash(m2))
|
|
220
|
-
self.assertEqual(len({m1, m2}), 1)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
class TestComposedMap(unittest.TestCase):
|
|
224
|
-
|
|
225
|
-
def test_heterogeneous_composition(self):
|
|
226
|
-
# LinearMap @ AffineMap now returns AffineMap (closed composition)
|
|
227
|
-
# Use ComposedMap directly to test the fallback behavior
|
|
228
|
-
lin = LinearMap(2.0)
|
|
229
|
-
aff = AffineMap(3.0, 1.0)
|
|
230
|
-
composed = ComposedMap(lin, aff)
|
|
231
|
-
# lin(aff(x)) = 2 * (3x + 1) = 6x + 2
|
|
232
|
-
self.assertAlmostEqual(composed(0.0), 2.0)
|
|
233
|
-
self.assertAlmostEqual(composed(1.0), 8.0)
|
|
234
|
-
|
|
235
|
-
def test_inverse(self):
|
|
236
|
-
composed = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
|
|
237
|
-
for x in [0.0, 1.0, -3.0, 10.0]:
|
|
238
|
-
self.assertAlmostEqual(composed.inverse()(composed(x)), x, places=10)
|
|
239
|
-
|
|
240
|
-
def test_invertible(self):
|
|
241
|
-
self.assertTrue(ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0)).invertible)
|
|
242
|
-
self.assertFalse(ComposedMap(LinearMap(0), AffineMap(3.0, 1.0)).invertible)
|
|
243
|
-
|
|
244
|
-
def test_non_invertible_raises(self):
|
|
245
|
-
composed = ComposedMap(LinearMap(0), AffineMap(3.0, 1.0))
|
|
246
|
-
with self.assertRaises(ValueError):
|
|
247
|
-
composed.inverse()
|
|
248
|
-
|
|
249
|
-
def test_repr(self):
|
|
250
|
-
composed = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
|
|
251
|
-
r = repr(composed)
|
|
252
|
-
self.assertIn("LinearMap", r)
|
|
253
|
-
self.assertIn("AffineMap", r)
|
|
254
|
-
|
|
255
|
-
def test_matmul(self):
|
|
256
|
-
c1 = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
|
|
257
|
-
c2 = LinearMap(5.0)
|
|
258
|
-
composed = c1 @ c2
|
|
259
|
-
self.assertIsInstance(composed, ComposedMap)
|
|
260
|
-
# c1(c2(x)) = c1(5x) = 2*(3*5x + 1) = 30x + 2
|
|
261
|
-
self.assertAlmostEqual(composed(1.0), 32.0)
|
|
262
|
-
self.assertAlmostEqual(composed(0.0), 2.0)
|
|
263
|
-
|
|
264
|
-
def test_matmul_non_map_returns_not_implemented(self):
|
|
265
|
-
composed = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
|
|
266
|
-
result = composed.__matmul__(42)
|
|
267
|
-
self.assertIs(result, NotImplemented)
|
|
268
|
-
|
|
269
|
-
def test_pow_one(self):
|
|
270
|
-
composed = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
|
|
271
|
-
result = composed ** 1
|
|
272
|
-
self.assertIs(result, composed)
|
|
273
|
-
|
|
274
|
-
def test_pow_negative_one(self):
|
|
275
|
-
composed = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
|
|
276
|
-
result = composed ** -1
|
|
277
|
-
# Round-trip should be identity
|
|
278
|
-
for x in [0.0, 1.0, 5.0]:
|
|
279
|
-
self.assertAlmostEqual(result(composed(x)), x, places=10)
|
|
280
|
-
|
|
281
|
-
def test_pow_invalid_raises(self):
|
|
282
|
-
composed = ComposedMap(LinearMap(2.0), AffineMap(3.0, 1.0))
|
|
283
|
-
with self.assertRaises(ValueError) as ctx:
|
|
284
|
-
composed ** 2
|
|
285
|
-
self.assertIn("only supports exp=1 or exp=-1", str(ctx.exception))
|
|
286
|
-
|
|
287
|
-
def test_invertible_both_non_invertible(self):
|
|
288
|
-
composed = ComposedMap(LinearMap(0), LinearMap(0))
|
|
289
|
-
self.assertFalse(composed.invertible)
|
|
290
|
-
|
|
291
|
-
def test_invertible_inner_non_invertible(self):
|
|
292
|
-
composed = ComposedMap(LinearMap(2.0), LinearMap(0))
|
|
293
|
-
self.assertFalse(composed.invertible)
|
|
294
|
-
|
|
295
|
-
def test_is_identity(self):
|
|
296
|
-
composed = ComposedMap(LinearMap(1.0), LinearMap(1.0))
|
|
297
|
-
self.assertTrue(composed.is_identity())
|
|
298
|
-
|
|
299
|
-
def test_is_identity_false(self):
|
|
300
|
-
composed = ComposedMap(LinearMap(2.0), LinearMap(0.5))
|
|
301
|
-
self.assertTrue(composed.is_identity()) # 2 * 0.5 = 1
|
|
302
|
-
|
|
303
|
-
def test_is_identity_with_offset(self):
|
|
304
|
-
composed = ComposedMap(LinearMap(1.0), AffineMap(1.0, 5.0))
|
|
305
|
-
self.assertFalse(composed.is_identity())
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
class TestMapABC(unittest.TestCase):
|
|
309
|
-
|
|
310
|
-
def test_cannot_instantiate(self):
|
|
311
|
-
with self.assertRaises(TypeError):
|
|
312
|
-
Map()
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
class TestCrossTypeComposition(unittest.TestCase):
|
|
316
|
-
"""Tests for composition between different Map types."""
|
|
317
|
-
|
|
318
|
-
def test_linear_at_affine_at_linear(self):
|
|
319
|
-
"""Chain: LinearMap @ AffineMap @ LinearMap"""
|
|
320
|
-
l1 = LinearMap(2.0)
|
|
321
|
-
a = AffineMap(3.0, 1.0)
|
|
322
|
-
l2 = LinearMap(4.0)
|
|
323
|
-
# l1 @ a = AffineMap(6, 2)
|
|
324
|
-
# (l1 @ a) @ l2 = AffineMap(24, 2)
|
|
325
|
-
composed = l1 @ a @ l2
|
|
326
|
-
self.assertIsInstance(composed, AffineMap)
|
|
327
|
-
self.assertAlmostEqual(composed(1.0), 26.0)
|
|
328
|
-
|
|
329
|
-
def test_affine_at_linear_at_affine(self):
|
|
330
|
-
"""Chain: AffineMap @ LinearMap @ AffineMap"""
|
|
331
|
-
a1 = AffineMap(2.0, 1.0)
|
|
332
|
-
l = LinearMap(3.0)
|
|
333
|
-
a2 = AffineMap(4.0, 5.0)
|
|
334
|
-
# l @ a2 = AffineMap(12, 15)
|
|
335
|
-
# a1 @ (l @ a2) = AffineMap(24, 31)
|
|
336
|
-
composed = a1 @ l @ a2
|
|
337
|
-
self.assertIsInstance(composed, AffineMap)
|
|
338
|
-
self.assertAlmostEqual(composed(1.0), 55.0)
|
|
339
|
-
|
|
340
|
-
def test_composed_preserves_semantics(self):
|
|
341
|
-
"""Verify f @ g computes f(g(x)) correctly for all type combinations."""
|
|
342
|
-
maps = [
|
|
343
|
-
LinearMap(2.0),
|
|
344
|
-
LinearMap(0.5),
|
|
345
|
-
AffineMap(3.0, 1.0),
|
|
346
|
-
AffineMap(1.0, -5.0),
|
|
347
|
-
]
|
|
348
|
-
for f in maps:
|
|
349
|
-
for g in maps:
|
|
350
|
-
composed = f @ g
|
|
351
|
-
for x in [0.0, 1.0, -2.0, 10.0]:
|
|
352
|
-
expected = f(g(x))
|
|
353
|
-
actual = composed(x)
|
|
354
|
-
self.assertAlmostEqual(actual, expected, places=10,
|
|
355
|
-
msg=f"Failed for {type(f).__name__} @ {type(g).__name__} at x={x}")
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
class TestMapEdgeCases(unittest.TestCase):
|
|
359
|
-
"""Edge case tests for Map hierarchy."""
|
|
360
|
-
|
|
361
|
-
def test_linear_map_with_negative_scale(self):
|
|
362
|
-
m = LinearMap(-3.0)
|
|
363
|
-
self.assertAlmostEqual(m(5.0), -15.0)
|
|
364
|
-
self.assertAlmostEqual(m.inverse()(m(5.0)), 5.0)
|
|
365
|
-
|
|
366
|
-
def test_affine_map_with_negative_scale(self):
|
|
367
|
-
m = AffineMap(-2.0, 10.0)
|
|
368
|
-
self.assertAlmostEqual(m(5.0), 0.0)
|
|
369
|
-
self.assertAlmostEqual(m.inverse()(m(5.0)), 5.0)
|
|
370
|
-
|
|
371
|
-
def test_linear_map_very_small_scale(self):
|
|
372
|
-
m = LinearMap(1e-10)
|
|
373
|
-
self.assertAlmostEqual(m(1e10), 1.0)
|
|
374
|
-
self.assertTrue(m.invertible)
|
|
375
|
-
|
|
376
|
-
def test_linear_map_very_large_scale(self):
|
|
377
|
-
m = LinearMap(1e10)
|
|
378
|
-
self.assertAlmostEqual(m(1e-10), 1.0)
|
|
379
|
-
self.assertTrue(m.invertible)
|
|
380
|
-
|
|
381
|
-
def test_affine_identity(self):
|
|
382
|
-
"""AffineMap(1, 0) should be identity."""
|
|
383
|
-
m = AffineMap(1.0, 0.0)
|
|
384
|
-
for x in [0.0, 1.0, -100.0, 1e6]:
|
|
385
|
-
self.assertAlmostEqual(m(x), x)
|
|
386
|
-
|
|
387
|
-
def test_linear_identity(self):
|
|
388
|
-
"""LinearMap(1) should be identity."""
|
|
389
|
-
m = LinearMap(1.0)
|
|
390
|
-
for x in [0.0, 1.0, -100.0, 1e6]:
|
|
391
|
-
self.assertAlmostEqual(m(x), x)
|
|
392
|
-
|
|
393
|
-
def test_composed_map_deep_nesting(self):
|
|
394
|
-
"""Test deeply nested ComposedMap."""
|
|
395
|
-
m = LinearMap(2.0)
|
|
396
|
-
for _ in range(5):
|
|
397
|
-
m = ComposedMap(m, LinearMap(1.5))
|
|
398
|
-
# 2 * 1.5^5 = 2 * 7.59375 = 15.1875
|
|
399
|
-
self.assertAlmostEqual(m(1.0), 2.0 * (1.5 ** 5))
|
|
400
|
-
|
|
401
|
-
def test_inverse_of_inverse(self):
|
|
402
|
-
"""(m.inverse()).inverse() == m"""
|
|
403
|
-
m = LinearMap(7.0)
|
|
404
|
-
self.assertEqual(m.inverse().inverse(), m)
|
|
405
|
-
|
|
406
|
-
m2 = AffineMap(3.0, 5.0)
|
|
407
|
-
double_inv = m2.inverse().inverse()
|
|
408
|
-
self.assertAlmostEqual(double_inv.a, m2.a)
|
|
409
|
-
self.assertAlmostEqual(double_inv.b, m2.b)
|
tests/ucon/test_algebra.py
DELETED
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
# © 2025 The Radiativity Company
|
|
2
|
-
# Licensed under the Apache License, Version 2.0
|
|
3
|
-
# See the LICENSE file for details.
|
|
4
|
-
|
|
5
|
-
import math
|
|
6
|
-
from unittest import TestCase
|
|
7
|
-
|
|
8
|
-
from ucon.algebra import Exponent, Vector
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class TestVector(TestCase):
|
|
12
|
-
|
|
13
|
-
def test_vector_iteration_and_length(self):
|
|
14
|
-
v = Vector(1, 0, 0, 0, 0, 0, 0, 0)
|
|
15
|
-
self.assertEqual(tuple(v), (1, 0, 0, 0, 0, 0, 0, 0))
|
|
16
|
-
self.assertEqual(len(v), 8) # 7 SI + 1 information
|
|
17
|
-
|
|
18
|
-
def test_vector_addition(self):
|
|
19
|
-
v1 = Vector(1, 0, 0, 0, 0, 0, 0, 0)
|
|
20
|
-
v2 = Vector(0, 2, 0, 0, 0, 0, 0, 0)
|
|
21
|
-
result = v1 + v2
|
|
22
|
-
self.assertEqual(result, Vector(1, 2, 0, 0, 0, 0, 0, 0))
|
|
23
|
-
|
|
24
|
-
def test_vector_subtraction(self):
|
|
25
|
-
v1 = Vector(2, 1, 0, 0, 0, 0, 0, 0)
|
|
26
|
-
v2 = Vector(1, 1, 0, 0, 0, 0, 0, 0)
|
|
27
|
-
self.assertEqual(v1 - v2, Vector(1, 0, 0, 0, 0, 0, 0, 0))
|
|
28
|
-
|
|
29
|
-
def test_vector_scalar_multiplication_by_integer(self):
|
|
30
|
-
v = Vector(1, -2, 0, 0, 0, 0, 3, 0)
|
|
31
|
-
scaled = v * 2
|
|
32
|
-
self.assertEqual(scaled, Vector(2, -4, 0, 0, 0, 0, 6, 0))
|
|
33
|
-
self.assertEqual(v, Vector(1, -2, 0, 0, 0, 0, 3, 0)) # original unchanged
|
|
34
|
-
|
|
35
|
-
def test_vector_scalar_multiplication_by_float(self):
|
|
36
|
-
v = Vector(0, 1, 0, 0, 0, 0, 0, 0)
|
|
37
|
-
scaled = v * 0.5
|
|
38
|
-
self.assertEqual(scaled, Vector(0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0))
|
|
39
|
-
|
|
40
|
-
def test_vector_scalar_multiplication_by_zero(self):
|
|
41
|
-
v = Vector(1, 2, 3, 4, 5, 6, 7, 8)
|
|
42
|
-
zeroed = v * 0
|
|
43
|
-
self.assertEqual(zeroed, Vector(0, 0, 0, 0, 0, 0, 0, 0))
|
|
44
|
-
|
|
45
|
-
def test_vector_equality_and_hash(self):
|
|
46
|
-
v1 = Vector(1, 0, 0, 0, 0, 0, 0, 0)
|
|
47
|
-
v2 = Vector(1, 0, 0, 0, 0, 0, 0, 0)
|
|
48
|
-
v3 = Vector(0, 1, 0, 0, 0, 0, 0, 0)
|
|
49
|
-
self.assertTrue(v1 == v2)
|
|
50
|
-
self.assertFalse(v1 == v3)
|
|
51
|
-
self.assertEqual(hash(v1), hash(v2))
|
|
52
|
-
self.assertNotEqual(hash(v1), hash(v3))
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class TestVectorEdgeCases(TestCase):
|
|
56
|
-
|
|
57
|
-
def test_zero_vector_equality_and_additivity(self):
|
|
58
|
-
zero = Vector()
|
|
59
|
-
self.assertEqual(zero, Vector(0, 0, 0, 0, 0, 0, 0, 0))
|
|
60
|
-
# Adding or subtracting zero should yield same vector
|
|
61
|
-
v = Vector(1, 2, 3, 4, 5, 6, 7, 8)
|
|
62
|
-
self.assertEqual(v + zero, v)
|
|
63
|
-
self.assertEqual(v - zero, v)
|
|
64
|
-
|
|
65
|
-
def test_vector_with_negative_exponents(self):
|
|
66
|
-
v1 = Vector(1, -2, 3, 0, 0, 0, 0, 0)
|
|
67
|
-
v2 = Vector(-1, 2, -3, 0, 0, 0, 0, 0)
|
|
68
|
-
result = v1 + v2
|
|
69
|
-
self.assertEqual(result, Vector(0, 0, 0, 0, 0, 0, 0, 0))
|
|
70
|
-
self.assertEqual(v1 - v1, Vector()) # perfect cancellation
|
|
71
|
-
|
|
72
|
-
def test_vector_equality_with_non_vector(self):
|
|
73
|
-
v = Vector()
|
|
74
|
-
# Non-Vector comparisons return NotImplemented, which Python
|
|
75
|
-
# resolves to False (not equal) rather than raising an error
|
|
76
|
-
self.assertFalse(v == "not a vector")
|
|
77
|
-
self.assertFalse(v == None)
|
|
78
|
-
|
|
79
|
-
def test_hash_consistency_for_equal_vectors(self):
|
|
80
|
-
v1 = Vector(1, 0, 0, 0, 0, 0, 0, 0)
|
|
81
|
-
v2 = Vector(1, 0, 0, 0, 0, 0, 0, 0)
|
|
82
|
-
self.assertEqual(hash(v1), hash(v2))
|
|
83
|
-
self.assertEqual(len({v1, v2}), 1)
|
|
84
|
-
|
|
85
|
-
def test_iter_length_order_consistency(self):
|
|
86
|
-
v = Vector(1, 2, 3, 4, 5, 6, 7, 8)
|
|
87
|
-
components = list(v)
|
|
88
|
-
self.assertEqual(len(components), len(v))
|
|
89
|
-
# Ensure order of iteration is fixed (T→L→M→I→Θ→J→N→B)
|
|
90
|
-
self.assertEqual(components, [1, 2, 3, 4, 5, 6, 7, 8])
|
|
91
|
-
|
|
92
|
-
def test_vector_arithmetic_does_not_mutate_operands(self):
|
|
93
|
-
v1 = Vector(1, 0, 0, 0, 0, 0, 0, 0)
|
|
94
|
-
v2 = Vector(0, 1, 0, 0, 0, 0, 0, 0)
|
|
95
|
-
_ = v1 + v2
|
|
96
|
-
self.assertEqual(v1, Vector(1, 0, 0, 0, 0, 0, 0, 0))
|
|
97
|
-
self.assertEqual(v2, Vector(0, 1, 0, 0, 0, 0, 0, 0))
|
|
98
|
-
|
|
99
|
-
def test_invalid_addition_type_raises(self):
|
|
100
|
-
v = Vector(1, 0, 0, 0, 0, 0, 0, 0)
|
|
101
|
-
with self.assertRaises(TypeError):
|
|
102
|
-
_ = v + "length"
|
|
103
|
-
with self.assertRaises(TypeError):
|
|
104
|
-
_ = v - 5
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
class TestExponent(TestCase):
|
|
108
|
-
|
|
109
|
-
thousand = Exponent(10, 3)
|
|
110
|
-
thousandth = Exponent(10, -3)
|
|
111
|
-
kibibyte = Exponent(2, 10)
|
|
112
|
-
mebibyte = Exponent(2, 20)
|
|
113
|
-
|
|
114
|
-
def test___init__(self):
|
|
115
|
-
with self.assertRaises(ValueError):
|
|
116
|
-
Exponent(5, 3) # no support for base 5 logarithms
|
|
117
|
-
|
|
118
|
-
def test_parts(self):
|
|
119
|
-
self.assertEqual((10, 3), self.thousand.parts())
|
|
120
|
-
self.assertEqual((10, -3), self.thousandth.parts())
|
|
121
|
-
|
|
122
|
-
def test_evaluated_property(self):
|
|
123
|
-
self.assertEqual(1000, self.thousand.evaluated)
|
|
124
|
-
self.assertAlmostEqual(0.001, self.thousandth.evaluated)
|
|
125
|
-
self.assertEqual(1024, self.kibibyte.evaluated)
|
|
126
|
-
self.assertEqual(1048576, self.mebibyte.evaluated)
|
|
127
|
-
|
|
128
|
-
def test___truediv__(self):
|
|
129
|
-
# same base returns a new Exponent
|
|
130
|
-
ratio = self.thousand / self.thousandth
|
|
131
|
-
self.assertIsInstance(ratio, Exponent)
|
|
132
|
-
self.assertEqual(ratio.base, 10)
|
|
133
|
-
self.assertEqual(ratio.power, 6)
|
|
134
|
-
self.assertEqual(ratio.evaluated, 1_000_000)
|
|
135
|
-
|
|
136
|
-
# different base returns numeric float
|
|
137
|
-
val = self.thousand / self.kibibyte
|
|
138
|
-
self.assertIsInstance(val, float)
|
|
139
|
-
self.assertAlmostEqual(1000 / 1024, val)
|
|
140
|
-
|
|
141
|
-
def test___mul__(self):
|
|
142
|
-
product = self.kibibyte * self.mebibyte
|
|
143
|
-
self.assertIsInstance(product, Exponent)
|
|
144
|
-
self.assertEqual(product.base, 2)
|
|
145
|
-
self.assertEqual(product.power, 30)
|
|
146
|
-
self.assertEqual(product.evaluated, 2**30)
|
|
147
|
-
|
|
148
|
-
# cross-base multiplication returns numeric
|
|
149
|
-
val = self.kibibyte * self.thousand
|
|
150
|
-
self.assertIsInstance(val, float)
|
|
151
|
-
self.assertAlmostEqual(1024 * 1000, val)
|
|
152
|
-
|
|
153
|
-
def test___hash__(self):
|
|
154
|
-
a = Exponent(10, 3)
|
|
155
|
-
b = Exponent(10, 3)
|
|
156
|
-
self.assertEqual(hash(a), hash(b))
|
|
157
|
-
self.assertEqual(len({a, b}), 1) # both should hash to same value
|
|
158
|
-
|
|
159
|
-
def test___float__(self):
|
|
160
|
-
self.assertEqual(float(self.thousand), 1000.0)
|
|
161
|
-
|
|
162
|
-
def test___int__(self):
|
|
163
|
-
self.assertEqual(int(self.thousand), 1000)
|
|
164
|
-
|
|
165
|
-
def test_comparisons(self):
|
|
166
|
-
self.assertTrue(self.thousand > self.thousandth)
|
|
167
|
-
self.assertTrue(self.thousandth < self.thousand)
|
|
168
|
-
self.assertTrue(self.kibibyte < self.mebibyte)
|
|
169
|
-
self.assertTrue(self.kibibyte == Exponent(2, 10))
|
|
170
|
-
|
|
171
|
-
with self.assertRaises(TypeError):
|
|
172
|
-
_ = self.thousand == 1000 # comparison to non-Exponent
|
|
173
|
-
|
|
174
|
-
def test___repr__(self):
|
|
175
|
-
self.assertIn("Exponent", repr(Exponent(10, -3)))
|
|
176
|
-
|
|
177
|
-
def test___str__(self):
|
|
178
|
-
self.assertEqual(str(self.thousand), '10^3')
|
|
179
|
-
self.assertEqual(str(self.thousandth), '10^-3')
|
|
180
|
-
|
|
181
|
-
def test_to_base(self):
|
|
182
|
-
e = Exponent(2, 10)
|
|
183
|
-
converted = e.to_base(10)
|
|
184
|
-
self.assertIsInstance(converted, Exponent)
|
|
185
|
-
self.assertEqual(converted.base, 10)
|
|
186
|
-
self.assertAlmostEqual(converted.power, math.log10(1024), places=10)
|
|
187
|
-
|
|
188
|
-
with self.assertRaises(ValueError):
|
|
189
|
-
e.to_base(5)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
class TestExponentEdgeCases(TestCase):
|
|
193
|
-
|
|
194
|
-
def test_extreme_powers(self):
|
|
195
|
-
e = Exponent(10, 308)
|
|
196
|
-
self.assertTrue(math.isfinite(e.evaluated))
|
|
197
|
-
e_small = Exponent(10, -308)
|
|
198
|
-
self.assertGreater(e.evaluated, e_small.evaluated)
|
|
199
|
-
|
|
200
|
-
def test_precision_rounding_in_hash(self):
|
|
201
|
-
a = Exponent(10, 6)
|
|
202
|
-
b = Exponent(10, 6 + 1e-16)
|
|
203
|
-
# rounding in hash avoids floating drift
|
|
204
|
-
self.assertEqual(hash(a), hash(b))
|
|
205
|
-
|
|
206
|
-
def test_negative_and_zero_power(self):
|
|
207
|
-
e0 = Exponent(10, 0)
|
|
208
|
-
e_neg = Exponent(10, -1)
|
|
209
|
-
self.assertEqual(e0.evaluated, 1.0)
|
|
210
|
-
self.assertEqual(e_neg.evaluated, 0.1)
|
|
211
|
-
self.assertLess(e_neg, e0)
|
|
212
|
-
|
|
213
|
-
def test_valid_exponent_evaluates_correctly(self):
|
|
214
|
-
base, power = 10, 3
|
|
215
|
-
e = Exponent(base, power)
|
|
216
|
-
self.assertEqual(e.evaluated, 1000)
|
|
217
|
-
self.assertEqual(e.parts(), (base, power))
|
|
218
|
-
self.assertEqual(f'{base}^{power}', str(e))
|
|
219
|
-
self.assertEqual(f'Exponent(base={base}, power={power})', repr(e))
|
|
220
|
-
|
|
221
|
-
def test_invalid_base_raises_value_error(self):
|
|
222
|
-
with self.assertRaises(ValueError):
|
|
223
|
-
Exponent(5, 2)
|
|
224
|
-
|
|
225
|
-
def test_exponent_comparisons(self):
|
|
226
|
-
e1 = Exponent(10, 2)
|
|
227
|
-
e2 = Exponent(10, 3)
|
|
228
|
-
self.assertTrue(e1 < e2)
|
|
229
|
-
self.assertTrue(e2 > e1)
|
|
230
|
-
self.assertFalse(e1 == e2)
|
|
231
|
-
|
|
232
|
-
def test_division_returns_exponent(self):
|
|
233
|
-
e1 = Exponent(10, 3)
|
|
234
|
-
e2 = Exponent(10, 2)
|
|
235
|
-
self.assertEqual(e1 / e2, Exponent(10, 1))
|
|
236
|
-
|
|
237
|
-
def test_equality_with_different_type(self):
|
|
238
|
-
with self.assertRaises(TypeError):
|
|
239
|
-
Exponent(10, 2) == "10^2"
|