saiunit 0.3.2__tar.gz → 0.4.0__tar.gz
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.
- {saiunit-0.3.2 → saiunit-0.4.0}/PKG-INFO +1 -1
- {saiunit-0.3.2 → saiunit-0.4.0}/pyproject.toml +2 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/__init__.py +2 -3
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_decorators.py +42 -8
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_decorators_test.py +71 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_dimension.py +44 -8
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_dimension_test.py +82 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_getters.py +16 -4
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_quantity.py +316 -54
- saiunit-0.4.0/saiunit/_base_quantity_edge_test.py +357 -0
- saiunit-0.4.0/saiunit/_base_quantity_format_test.py +197 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_quantity_test.py +7 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_unit.py +205 -134
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_unit_test.py +168 -11
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_scatter.py +13 -4
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_scatter_test.py +21 -4
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_sparse_base.py +27 -2
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_unit_common.py +0 -2
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_unit_constants.py +4 -3
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_unit_constants_test.py +24 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_version.py +1 -1
- saiunit-0.4.0/saiunit/autograd/_autograd_edge_test.py +221 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_jacobian.py +92 -18
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_misc.py +13 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_value_and_grad.py +19 -5
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_vector_grad.py +33 -7
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/constants.py +18 -4
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/constants_test.py +49 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/custom_array.py +28 -6
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/custom_array_test.py +56 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/fft/_fft_change_unit.py +15 -11
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/fft/_fft_change_unit_test.py +84 -5
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_accept_unitless.py +37 -15
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_accept_unitless_test.py +49 -2
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_array_creation.py +4 -8
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_array_creation_test.py +18 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_change_unit.py +20 -20
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_change_unit_test.py +74 -6
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_keep_unit.py +35 -12
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_keep_unit_test.py +87 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_linalg.py +92 -68
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_linalg_test.py +202 -6
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_misc.py +31 -11
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_misc_test.py +40 -2
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_change_unit.py +33 -12
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_change_unit_test.py +55 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_keep_unit.py +151 -11
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_keep_unit_test.py +111 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_activation.py +9 -1
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_activation_test.py +46 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_einops.py +19 -3
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_einops_test.py +39 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_exprel.py +74 -12
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_exprel_test.py +143 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_accept_unitless.py +115 -23
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_accept_unitless_test.py +175 -9
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_array_creation.py +89 -25
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_array_creation_test.py +137 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_change_unit.py +170 -36
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_change_unit_test.py +185 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_keep_unit.py +140 -31
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_keep_unit_test.py +285 -1
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_remove_unit.py +24 -18
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_remove_unit_test.py +61 -3
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_misc.py +46 -21
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_misc_test.py +98 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/sparse/_coo.py +20 -5
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/sparse/_coo_test.py +91 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/sparse/_csr.py +49 -9
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/sparse/_csr_test.py +148 -2
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/typing.py +5 -1
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/typing_test.py +16 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit.egg-info/PKG-INFO +1 -1
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit.egg-info/SOURCES.txt +3 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/LICENSE +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/README.md +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/examples/matplotlib_plain_basics.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/examples/matplotlib_quantity_basics.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/examples/matplotlib_quantity_interactive.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/examples/matplotlib_quantity_vs_plain.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_backend.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_backend_parametrize_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_backend_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_getters_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_celsius.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_celsius_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_compatible_import.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_compatible_import_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_dask_laziness_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_display_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_exceptions.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_exceptions_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_jax_compat.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_jax_compat_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_jax_guard.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_jax_guard_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_matplotlib_compat.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_matplotlib_compat_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_misc.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_misc_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_ndonnx_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_no_jax_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_sparse_base_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_typing.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_typing_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_unit_shortcuts.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_unit_shortcuts_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/__init__.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_hessian.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_hessian_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_jacobian_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_misc_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_value_and_grad_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_vector_grad_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/fft/__init__.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/fft/_fft_backend_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/fft/_fft_keep_unit.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/fft/_fft_keep_unit_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/__init__.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_remove_unit.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_remove_unit_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/__init__.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_backend_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_docstring_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_remove_unit.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_remove_unit_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/__init__.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_alias.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_alias_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_einops_parsing.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_einops_parsing_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_multi_backend_test.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/fft.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/linalg.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/py.typed +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/sparse/__init__.py +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit.egg-info/dependency_links.txt +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit.egg-info/requires.txt +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/saiunit.egg-info/top_level.txt +0 -0
- {saiunit-0.3.2 → saiunit-0.4.0}/setup.cfg +0 -0
|
@@ -131,13 +131,12 @@ __all__ = [
|
|
|
131
131
|
'__version__',
|
|
132
132
|
'__version_info__',
|
|
133
133
|
|
|
134
|
-
# submodules
|
|
134
|
+
# submodules ('autograd', 'lax' and 'sparse' are appended below
|
|
135
|
+
# together with the lazy-loading machinery)
|
|
135
136
|
'math',
|
|
136
137
|
'linalg',
|
|
137
|
-
'autograd',
|
|
138
138
|
'fft',
|
|
139
139
|
'constants',
|
|
140
|
-
'sparse',
|
|
141
140
|
'typing',
|
|
142
141
|
|
|
143
142
|
# misc
|
|
@@ -177,6 +177,17 @@ def check_dims(**au):
|
|
|
177
177
|
)
|
|
178
178
|
newkeyset[n] = v
|
|
179
179
|
|
|
180
|
+
# validate default values of parameters the caller did not
|
|
181
|
+
# supply, so a default that violates the specification does not
|
|
182
|
+
# silently bypass the check
|
|
183
|
+
defaults = f.__defaults__ or ()
|
|
184
|
+
for n, v in zip(arg_names[len(arg_names) - len(defaults):], defaults):
|
|
185
|
+
if n not in newkeyset and n in au:
|
|
186
|
+
newkeyset[n] = v
|
|
187
|
+
for n, v in (f.__kwdefaults__ or {}).items():
|
|
188
|
+
if n not in newkeyset and n in au:
|
|
189
|
+
newkeyset[n] = v
|
|
190
|
+
|
|
180
191
|
for k in newkeyset:
|
|
181
192
|
# string variables are allowed to pass, the presumption is they
|
|
182
193
|
# name another variable. None is also allowed, useful for
|
|
@@ -307,7 +318,13 @@ def _check_dim(f, val, dim):
|
|
|
307
318
|
@set_module_as('saiunit')
|
|
308
319
|
def check_units(**au):
|
|
309
320
|
"""
|
|
310
|
-
Decorator to check units of arguments passed to a function
|
|
321
|
+
Decorator to check units of arguments passed to a function.
|
|
322
|
+
|
|
323
|
+
The check is **exact**: the argument must carry the very unit given in
|
|
324
|
+
the specification, including its scale. ``1 * mV`` does *not* pass a
|
|
325
|
+
``volt`` specification even though both are voltages. Use
|
|
326
|
+
:func:`check_dims` if you only want to constrain the physical dimension
|
|
327
|
+
and accept any scaled unit of that dimension.
|
|
311
328
|
|
|
312
329
|
Examples
|
|
313
330
|
--------
|
|
@@ -319,12 +336,12 @@ def check_units(**au):
|
|
|
319
336
|
You don't have to check the units of every variable in the function, and
|
|
320
337
|
you can define what the units should be for variables that aren't
|
|
321
338
|
explicitly named in the definition of the function. For example, the code
|
|
322
|
-
above checks that the variable wibble should be
|
|
339
|
+
above checks that the variable wibble should be in metre, so writing
|
|
323
340
|
|
|
324
341
|
>>> getvoltage(1*amp, 1*ohm, wibble=1) # doctest: +IGNORE_EXCEPTION_DETAIL
|
|
325
342
|
Traceback (most recent call last):
|
|
326
343
|
...
|
|
327
|
-
|
|
344
|
+
UnitMismatchError: Function 'getvoltage' expected a array with unit Unit("m") for argument 'wibble' but got '1'
|
|
328
345
|
|
|
329
346
|
fails, but
|
|
330
347
|
|
|
@@ -360,7 +377,7 @@ def check_units(**au):
|
|
|
360
377
|
a function that takes the units of all the arguments as its inputs (in the
|
|
361
378
|
order specified in the function header):
|
|
362
379
|
|
|
363
|
-
>>> @check_units(result=lambda
|
|
380
|
+
>>> @check_units(result=lambda u: u**2)
|
|
364
381
|
... def square(value):
|
|
365
382
|
... return value**2
|
|
366
383
|
|
|
@@ -379,14 +396,17 @@ def check_units(**au):
|
|
|
379
396
|
>>> multiply_sum(3*nA, 4*mV, 5*nA) # doctest: +IGNORE_EXCEPTION_DETAIL
|
|
380
397
|
Traceback (most recent call last):
|
|
381
398
|
...
|
|
382
|
-
|
|
399
|
+
UnitMismatchError: Function 'multiply_sum' expected the argument 'summand_2' to have the same units as argument 'summand_1', but argument 'summand_2' has unit nA, while argument 'summand_1' has unit mV.
|
|
400
|
+
|
|
401
|
+
Default values of parameters that the caller does not supply are
|
|
402
|
+
validated as well.
|
|
383
403
|
|
|
384
404
|
Raises
|
|
385
405
|
------
|
|
386
406
|
|
|
387
|
-
|
|
407
|
+
UnitMismatchError
|
|
388
408
|
In case the input arguments or the return value do not have the
|
|
389
|
-
expected
|
|
409
|
+
expected units.
|
|
390
410
|
TypeError
|
|
391
411
|
If an input argument or return value was expected to be a boolean but
|
|
392
412
|
is not.
|
|
@@ -432,6 +452,17 @@ def check_units(**au):
|
|
|
432
452
|
)
|
|
433
453
|
newkeyset[n] = v
|
|
434
454
|
|
|
455
|
+
# validate default values of parameters the caller did not
|
|
456
|
+
# supply, so a default that violates the specification does not
|
|
457
|
+
# silently bypass the check
|
|
458
|
+
defaults = f.__defaults__ or ()
|
|
459
|
+
for n, v in zip(arg_names[len(arg_names) - len(defaults):], defaults):
|
|
460
|
+
if n not in newkeyset and n in au:
|
|
461
|
+
newkeyset[n] = v
|
|
462
|
+
for n, v in (f.__kwdefaults__ or {}).items():
|
|
463
|
+
if n not in newkeyset and n in au:
|
|
464
|
+
newkeyset[n] = v
|
|
465
|
+
|
|
435
466
|
for k in newkeyset:
|
|
436
467
|
# string variables are allowed to pass, the presumption is they
|
|
437
468
|
# name another variable. None is also allowed, useful for
|
|
@@ -488,7 +519,10 @@ def check_units(**au):
|
|
|
488
519
|
result = f(*args, **kwds)
|
|
489
520
|
if "result" in au:
|
|
490
521
|
if isinstance(au["result"], Callable) and au["result"] != bool:
|
|
491
|
-
|
|
522
|
+
# Pass the argument *units* (not dimensions): the result is
|
|
523
|
+
# validated by unit with ``_check_unit`` below, so a Dimension
|
|
524
|
+
# here would always mismatch the returned Quantity's unit.
|
|
525
|
+
expected_result = au["result"](*[get_unit(a) for a in args])
|
|
492
526
|
else:
|
|
493
527
|
expected_result = au["result"]
|
|
494
528
|
|
|
@@ -266,6 +266,27 @@ class TestCheckUnits:
|
|
|
266
266
|
with pytest.raises(UnitMismatchError):
|
|
267
267
|
f()
|
|
268
268
|
|
|
269
|
+
def test_result_callable(self):
|
|
270
|
+
# Documented usage: the result unit is derived from the input unit.
|
|
271
|
+
# The callable must receive the argument *unit* (not its dimension),
|
|
272
|
+
# otherwise the unit comparison spuriously fails.
|
|
273
|
+
@check_units(result=lambda un: un ** 2)
|
|
274
|
+
def square(x):
|
|
275
|
+
return x ** 2
|
|
276
|
+
|
|
277
|
+
q = Quantity(3.0, unit=_metre)
|
|
278
|
+
result = square(q)
|
|
279
|
+
assert result.unit == _metre ** 2
|
|
280
|
+
assert jnp.allclose(result.mantissa, 9.0)
|
|
281
|
+
|
|
282
|
+
def test_result_callable_mismatch_raises(self):
|
|
283
|
+
@check_units(result=lambda un: un ** 2)
|
|
284
|
+
def bad(x):
|
|
285
|
+
return x # wrong: returns metre, not metre**2
|
|
286
|
+
|
|
287
|
+
with pytest.raises(UnitMismatchError):
|
|
288
|
+
bad(Quantity(3.0, unit=_metre))
|
|
289
|
+
|
|
269
290
|
def test_stores_metadata(self):
|
|
270
291
|
@check_units(x=_metre, result=_metre)
|
|
271
292
|
def f(x):
|
|
@@ -912,3 +933,53 @@ class TestDocstringExampleAssignUnits:
|
|
|
912
933
|
assert v.unit == u.volt
|
|
913
934
|
assert jnp.allclose(t.mantissa, 5.0)
|
|
914
935
|
assert jnp.allclose(v.mantissa, 3.0)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
class TestDefaultValueValidation:
|
|
939
|
+
"""Default values must be validated like explicitly passed arguments."""
|
|
940
|
+
|
|
941
|
+
def test_check_units_bad_default_rejected(self):
|
|
942
|
+
@check_units(v=_volt)
|
|
943
|
+
def f(v=1 * _msecond):
|
|
944
|
+
return v
|
|
945
|
+
|
|
946
|
+
with pytest.raises(UnitMismatchError):
|
|
947
|
+
f()
|
|
948
|
+
|
|
949
|
+
def test_check_units_good_default_passes(self):
|
|
950
|
+
@check_units(v=_volt)
|
|
951
|
+
def f(v=1 * _volt):
|
|
952
|
+
return v
|
|
953
|
+
|
|
954
|
+
assert f() == 1 * _volt
|
|
955
|
+
|
|
956
|
+
def test_check_units_none_default_passes(self):
|
|
957
|
+
@check_units(v=_volt)
|
|
958
|
+
def f(v=None):
|
|
959
|
+
return v
|
|
960
|
+
|
|
961
|
+
assert f() is None
|
|
962
|
+
|
|
963
|
+
def test_check_units_kwonly_default_rejected(self):
|
|
964
|
+
@check_units(w=_volt)
|
|
965
|
+
def f(*, w=2 * _second):
|
|
966
|
+
return w
|
|
967
|
+
|
|
968
|
+
with pytest.raises(UnitMismatchError):
|
|
969
|
+
f()
|
|
970
|
+
assert f(w=1 * _volt) == 1 * _volt
|
|
971
|
+
|
|
972
|
+
def test_check_dims_bad_default_rejected(self):
|
|
973
|
+
@check_dims(v=_voltage_dim)
|
|
974
|
+
def f(v=1 * _second):
|
|
975
|
+
return v
|
|
976
|
+
|
|
977
|
+
with pytest.raises(DimensionMismatchError):
|
|
978
|
+
f()
|
|
979
|
+
|
|
980
|
+
def test_check_dims_scaled_default_passes(self):
|
|
981
|
+
@check_dims(v=_time_dim)
|
|
982
|
+
def f(v=1 * _msecond):
|
|
983
|
+
return v
|
|
984
|
+
|
|
985
|
+
assert f() == 1 * _msecond
|
|
@@ -162,7 +162,7 @@ class Dimension:
|
|
|
162
162
|
|
|
163
163
|
This property memoizes the hash value for efficiency. Once calculated,
|
|
164
164
|
the hash value is stored in the `_hash` attribute for future access.
|
|
165
|
-
The hash is based on the
|
|
165
|
+
The hash is based on the values of the dimension exponents.
|
|
166
166
|
|
|
167
167
|
Returns
|
|
168
168
|
-------
|
|
@@ -176,7 +176,12 @@ class Dimension:
|
|
|
176
176
|
their use as dictionary keys and in sets.
|
|
177
177
|
"""
|
|
178
178
|
if self._hash is None:
|
|
179
|
-
|
|
179
|
+
# Hash by *value*, not by byte representation: ``tobytes()``
|
|
180
|
+
# differs between dtypes (int vs float dims) and between 0.0
|
|
181
|
+
# and -0.0, while ``__eq__`` (np.array_equal) treats those as
|
|
182
|
+
# equal. Python scalar hashing guarantees hash(1) == hash(1.0)
|
|
183
|
+
# and hash(0.0) == hash(-0.0), keeping the hash/eq contract.
|
|
184
|
+
self._hash = hash(tuple(self._dims.tolist()))
|
|
180
185
|
return self._hash
|
|
181
186
|
|
|
182
187
|
@hash.setter
|
|
@@ -463,6 +468,14 @@ class Dimension:
|
|
|
463
468
|
value = np.array(value)
|
|
464
469
|
if value.size > 1:
|
|
465
470
|
raise TypeError("Too many exponents")
|
|
471
|
+
if value.size == 0:
|
|
472
|
+
raise TypeError("Expected a scalar exponent, got an empty array")
|
|
473
|
+
if not np.all(np.isfinite(value)):
|
|
474
|
+
# A NaN/inf exponent would create NaN dimension entries; NaN
|
|
475
|
+
# compares unequal to itself, so every such Dimension would
|
|
476
|
+
# miss the singleton cache, growing it unboundedly and
|
|
477
|
+
# breaking the ``is``-identity the library relies on.
|
|
478
|
+
raise ValueError(f"Dimension exponent must be finite, got {value!r}")
|
|
466
479
|
return get_or_create_dimension(self._dims * value)
|
|
467
480
|
|
|
468
481
|
def __imul__(self, value): # type: ignore[misc]
|
|
@@ -563,17 +576,19 @@ class Dimension:
|
|
|
563
576
|
-------
|
|
564
577
|
bool
|
|
565
578
|
True if the dimensions are equal (have the same exponents),
|
|
566
|
-
False otherwise
|
|
579
|
+
False otherwise.
|
|
567
580
|
|
|
568
581
|
Notes
|
|
569
582
|
-----
|
|
570
583
|
The comparison uses ``numpy.array_equal``. Equal Dimensions are
|
|
571
584
|
produced by ``get_or_create_dimension`` from the same exponent tuple,
|
|
572
585
|
so callers that round their exponents identically observe equal
|
|
573
|
-
instances. If value is not a Dimension object,
|
|
586
|
+
instances. If value is not a Dimension object, ``NotImplemented``
|
|
587
|
+
is returned so that the other operand's reflected comparison is
|
|
588
|
+
attempted before Python falls back to ``False``.
|
|
574
589
|
"""
|
|
575
590
|
if not isinstance(value, Dimension):
|
|
576
|
-
return
|
|
591
|
+
return NotImplemented
|
|
577
592
|
try:
|
|
578
593
|
return np.array_equal(self._dims, value._dims)
|
|
579
594
|
except (AttributeError, _TracerArrayConversionError):
|
|
@@ -597,7 +612,10 @@ class Dimension:
|
|
|
597
612
|
bool
|
|
598
613
|
True if the dimensions are not equal, False otherwise.
|
|
599
614
|
"""
|
|
600
|
-
|
|
615
|
+
result = self.__eq__(value)
|
|
616
|
+
if result is NotImplemented:
|
|
617
|
+
return result
|
|
618
|
+
return not result
|
|
601
619
|
|
|
602
620
|
# MAKE DIMENSION PICKABLE #
|
|
603
621
|
def __getstate__(self):
|
|
@@ -750,6 +768,11 @@ def get_or_create_dimension(*args, **kwds) -> Dimension:
|
|
|
750
768
|
if len(args):
|
|
751
769
|
if len(args) != 1:
|
|
752
770
|
raise TypeError(f"get_or_create_dimension() takes at most 1 positional argument, got {len(args)}")
|
|
771
|
+
if kwds:
|
|
772
|
+
raise TypeError(
|
|
773
|
+
"get_or_create_dimension() accepts either a positional sequence "
|
|
774
|
+
"or keyword arguments, not both"
|
|
775
|
+
)
|
|
753
776
|
# initialisation by list
|
|
754
777
|
dims = args[0]
|
|
755
778
|
try:
|
|
@@ -759,12 +782,25 @@ def get_or_create_dimension(*args, **kwds) -> Dimension:
|
|
|
759
782
|
raise TypeError("Need a sequence of exactly 7 items")
|
|
760
783
|
else:
|
|
761
784
|
# initialisation by keywords
|
|
762
|
-
|
|
785
|
+
# Use a plain list (not an int array) so fractional exponents such
|
|
786
|
+
# as metre=0.5 are preserved; np.asarray below picks the dtype.
|
|
787
|
+
dims = [0, 0, 0, 0, 0, 0, 0]
|
|
763
788
|
for k in kwds:
|
|
764
789
|
# _dim2index stores the index of the dimension with name 'k'
|
|
765
|
-
|
|
790
|
+
try:
|
|
791
|
+
index = _dim2index[k]
|
|
792
|
+
except KeyError:
|
|
793
|
+
raise TypeError(
|
|
794
|
+
f"Unknown dimension name {k!r}. Valid names are: "
|
|
795
|
+
f"{sorted(_dim2index)}"
|
|
796
|
+
) from None
|
|
797
|
+
dims[index] = kwds[k]
|
|
766
798
|
|
|
767
799
|
dims = np.asarray(dims)
|
|
800
|
+
if not np.issubdtype(dims.dtype, np.number):
|
|
801
|
+
raise TypeError(
|
|
802
|
+
f"Dimension exponents must be numeric, got dtype {dims.dtype!r}"
|
|
803
|
+
)
|
|
768
804
|
key = tuple(dims)
|
|
769
805
|
cached = _dimension_cache.get(key)
|
|
770
806
|
if cached is not None:
|
|
@@ -280,6 +280,14 @@ class TestGetOrCreateDimension:
|
|
|
280
280
|
assert d.get_dimension("kg") == 1
|
|
281
281
|
assert d.get_dimension("s") == -2
|
|
282
282
|
|
|
283
|
+
def test_fractional_exponent_by_keyword(self):
|
|
284
|
+
# Fractional exponents passed by keyword must be preserved, not
|
|
285
|
+
# truncated to int. The list-form and ** paths already do this.
|
|
286
|
+
d = get_or_create_dimension(metre=0.5)
|
|
287
|
+
assert not d.is_dimensionless
|
|
288
|
+
assert d.get_dimension("m") == 0.5
|
|
289
|
+
assert d == get_or_create_dimension([0.5, 0, 0, 0, 0, 0, 0])
|
|
290
|
+
|
|
283
291
|
def test_singleton_by_list(self):
|
|
284
292
|
d1 = get_or_create_dimension([1, 0, 0, 0, 0, 0, 0])
|
|
285
293
|
d2 = get_or_create_dimension([1, 0, 0, 0, 0, 0, 0])
|
|
@@ -496,3 +504,77 @@ def test_docstring_example_unit_mismatch_error():
|
|
|
496
504
|
import saiunit as u
|
|
497
505
|
e = u.UnitMismatchError("Addition", u.mvolt, u.volt)
|
|
498
506
|
assert 'Addition' in str(e)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# =========================================================================
|
|
510
|
+
# Regression tests: audited Dimension API fixes (2026-06)
|
|
511
|
+
# =========================================================================
|
|
512
|
+
|
|
513
|
+
class TestDimensionHashEqContract:
|
|
514
|
+
def test_int_vs_float_dims_hash_consistent(self):
|
|
515
|
+
# Direct construction is public API; equal Dimensions must hash
|
|
516
|
+
# equal regardless of the dtype of the exponent array.
|
|
517
|
+
d_int = Dimension(np.array([1, 0, 0, 0, 0, 0, 0]))
|
|
518
|
+
d_flt = Dimension(np.array([1., 0., 0., 0., 0., 0., 0.]))
|
|
519
|
+
assert d_int == d_flt
|
|
520
|
+
assert hash(d_int) == hash(d_flt)
|
|
521
|
+
|
|
522
|
+
def test_negative_zero_hash_consistent(self):
|
|
523
|
+
d_pos = Dimension(np.array([1., 0., 0., 0., 0., 0., 0.]))
|
|
524
|
+
d_neg = Dimension(np.array([1., -0., 0., 0., 0., 0., 0.]))
|
|
525
|
+
assert d_pos == d_neg
|
|
526
|
+
assert hash(d_pos) == hash(d_neg)
|
|
527
|
+
|
|
528
|
+
def test_eq_non_dimension_returns_notimplemented(self):
|
|
529
|
+
d = get_or_create_dimension([1, 0, 0, 0, 0, 0, 0])
|
|
530
|
+
assert d.__eq__(5) is NotImplemented
|
|
531
|
+
assert (d == 5) is False
|
|
532
|
+
assert (d != 5) is True
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
class TestDimensionPowGuards:
|
|
536
|
+
def test_pow_nan_raises(self):
|
|
537
|
+
d = get_or_create_dimension([1, 0, 0, 0, 0, 0, 0])
|
|
538
|
+
with pytest.raises(ValueError, match="finite"):
|
|
539
|
+
d ** float('nan')
|
|
540
|
+
|
|
541
|
+
def test_pow_inf_raises(self):
|
|
542
|
+
d = get_or_create_dimension([1, 0, 0, 0, 0, 0, 0])
|
|
543
|
+
with pytest.raises(ValueError, match="finite"):
|
|
544
|
+
d ** float('inf')
|
|
545
|
+
|
|
546
|
+
def test_pow_nan_does_not_grow_cache(self):
|
|
547
|
+
# A NaN exponent must not leak NaN-keyed entries into the
|
|
548
|
+
# singleton cache (NaN != NaN would make every lookup miss).
|
|
549
|
+
from saiunit._base_dimension import _dimension_cache
|
|
550
|
+
d = get_or_create_dimension([1, 0, 0, 0, 0, 0, 0])
|
|
551
|
+
n0 = len(_dimension_cache)
|
|
552
|
+
for _ in range(3):
|
|
553
|
+
with pytest.raises(ValueError):
|
|
554
|
+
d ** float('nan')
|
|
555
|
+
assert len(_dimension_cache) == n0
|
|
556
|
+
|
|
557
|
+
def test_pow_empty_array_raises(self):
|
|
558
|
+
d = get_or_create_dimension([1, 0, 0, 0, 0, 0, 0])
|
|
559
|
+
with pytest.raises(TypeError, match="empty"):
|
|
560
|
+
d ** np.array([])
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
class TestFactoryValidation:
|
|
564
|
+
def test_positional_and_kwargs_raises(self):
|
|
565
|
+
with pytest.raises(TypeError, match="not both"):
|
|
566
|
+
get_or_create_dimension([1, 0, 0, 0, 0, 0, 0], length=5)
|
|
567
|
+
|
|
568
|
+
def test_unknown_kwarg_raises_typeerror(self):
|
|
569
|
+
with pytest.raises(TypeError, match="Unknown dimension name"):
|
|
570
|
+
get_or_create_dimension(bad_kw=1)
|
|
571
|
+
|
|
572
|
+
def test_non_numeric_string_raises(self):
|
|
573
|
+
# A 7-character string passes the len() check but is not a
|
|
574
|
+
# sequence of exponents.
|
|
575
|
+
with pytest.raises(TypeError, match="numeric"):
|
|
576
|
+
get_or_create_dimension("abcdefg")
|
|
577
|
+
|
|
578
|
+
def test_non_numeric_sequence_raises(self):
|
|
579
|
+
with pytest.raises(TypeError, match="numeric"):
|
|
580
|
+
get_or_create_dimension(['a'] * 7)
|
|
@@ -162,6 +162,10 @@ def get_dim(obj) -> Dimension:
|
|
|
162
162
|
return obj
|
|
163
163
|
if isinstance(obj, Quantity):
|
|
164
164
|
return obj.dim
|
|
165
|
+
if isinstance(obj, (str, bytes)):
|
|
166
|
+
# Quantity() rejects str/bytes mantissas, but get_dim keeps its
|
|
167
|
+
# duck-typing contract: anything without units is dimensionless.
|
|
168
|
+
return DIMENSIONLESS
|
|
165
169
|
try:
|
|
166
170
|
return Quantity(obj).dim
|
|
167
171
|
except TypeError:
|
|
@@ -207,6 +211,10 @@ def get_unit(obj) -> 'Unit':
|
|
|
207
211
|
return obj
|
|
208
212
|
if isinstance(obj, Quantity):
|
|
209
213
|
return obj.unit
|
|
214
|
+
if isinstance(obj, (str, bytes)):
|
|
215
|
+
# Quantity() rejects str/bytes mantissas, but get_unit keeps its
|
|
216
|
+
# duck-typing contract: anything without units is unitless.
|
|
217
|
+
return UNITLESS
|
|
210
218
|
try:
|
|
211
219
|
return Quantity(obj).unit
|
|
212
220
|
except TypeError:
|
|
@@ -731,12 +739,16 @@ def unit_scale_align_to_first(*args) -> 'list[Quantity]':
|
|
|
731
739
|
if not isinstance(items[0], Quantity):
|
|
732
740
|
items[0] = Quantity(items[0])
|
|
733
741
|
for i in range(1, len(items)):
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
742
|
+
# ``in_unit`` both checks the dimension and rescales
|
|
743
|
+
# scaled-dimensionless quantities (e.g. mV/volt) onto the
|
|
744
|
+
# unitless first item, instead of only checking dimensions.
|
|
745
|
+
items[i] = _to_quantity(items[i]).in_unit(first_unit)
|
|
737
746
|
else:
|
|
738
747
|
for i in range(1, len(items)):
|
|
739
|
-
|
|
748
|
+
# Route through _to_quantity so raw values (plain numbers, bare
|
|
749
|
+
# arrays, Unit objects) raise a proper UnitMismatchError instead
|
|
750
|
+
# of an AttributeError on ``.in_unit``.
|
|
751
|
+
items[i] = _to_quantity(items[i]).in_unit(first_unit)
|
|
740
752
|
return items
|
|
741
753
|
|
|
742
754
|
|