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.
Files changed (140) hide show
  1. {saiunit-0.3.2 → saiunit-0.4.0}/PKG-INFO +1 -1
  2. {saiunit-0.3.2 → saiunit-0.4.0}/pyproject.toml +2 -0
  3. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/__init__.py +2 -3
  4. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_decorators.py +42 -8
  5. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_decorators_test.py +71 -0
  6. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_dimension.py +44 -8
  7. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_dimension_test.py +82 -0
  8. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_getters.py +16 -4
  9. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_quantity.py +316 -54
  10. saiunit-0.4.0/saiunit/_base_quantity_edge_test.py +357 -0
  11. saiunit-0.4.0/saiunit/_base_quantity_format_test.py +197 -0
  12. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_quantity_test.py +7 -0
  13. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_unit.py +205 -134
  14. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_unit_test.py +168 -11
  15. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_scatter.py +13 -4
  16. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_scatter_test.py +21 -4
  17. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_sparse_base.py +27 -2
  18. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_unit_common.py +0 -2
  19. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_unit_constants.py +4 -3
  20. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_unit_constants_test.py +24 -0
  21. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_version.py +1 -1
  22. saiunit-0.4.0/saiunit/autograd/_autograd_edge_test.py +221 -0
  23. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_jacobian.py +92 -18
  24. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_misc.py +13 -0
  25. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_value_and_grad.py +19 -5
  26. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_vector_grad.py +33 -7
  27. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/constants.py +18 -4
  28. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/constants_test.py +49 -0
  29. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/custom_array.py +28 -6
  30. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/custom_array_test.py +56 -0
  31. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/fft/_fft_change_unit.py +15 -11
  32. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/fft/_fft_change_unit_test.py +84 -5
  33. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_accept_unitless.py +37 -15
  34. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_accept_unitless_test.py +49 -2
  35. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_array_creation.py +4 -8
  36. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_array_creation_test.py +18 -0
  37. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_change_unit.py +20 -20
  38. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_change_unit_test.py +74 -6
  39. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_keep_unit.py +35 -12
  40. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_keep_unit_test.py +87 -0
  41. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_linalg.py +92 -68
  42. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_linalg_test.py +202 -6
  43. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_misc.py +31 -11
  44. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_misc_test.py +40 -2
  45. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_change_unit.py +33 -12
  46. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_change_unit_test.py +55 -0
  47. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_keep_unit.py +151 -11
  48. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_keep_unit_test.py +111 -0
  49. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_activation.py +9 -1
  50. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_activation_test.py +46 -0
  51. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_einops.py +19 -3
  52. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_einops_test.py +39 -0
  53. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_exprel.py +74 -12
  54. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_exprel_test.py +143 -0
  55. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_accept_unitless.py +115 -23
  56. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_accept_unitless_test.py +175 -9
  57. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_array_creation.py +89 -25
  58. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_array_creation_test.py +137 -0
  59. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_change_unit.py +170 -36
  60. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_change_unit_test.py +185 -0
  61. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_keep_unit.py +140 -31
  62. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_keep_unit_test.py +285 -1
  63. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_remove_unit.py +24 -18
  64. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_fun_remove_unit_test.py +61 -3
  65. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_misc.py +46 -21
  66. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_misc_test.py +98 -0
  67. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/sparse/_coo.py +20 -5
  68. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/sparse/_coo_test.py +91 -0
  69. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/sparse/_csr.py +49 -9
  70. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/sparse/_csr_test.py +148 -2
  71. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/typing.py +5 -1
  72. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/typing_test.py +16 -0
  73. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit.egg-info/PKG-INFO +1 -1
  74. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit.egg-info/SOURCES.txt +3 -0
  75. {saiunit-0.3.2 → saiunit-0.4.0}/LICENSE +0 -0
  76. {saiunit-0.3.2 → saiunit-0.4.0}/README.md +0 -0
  77. {saiunit-0.3.2 → saiunit-0.4.0}/examples/matplotlib_plain_basics.py +0 -0
  78. {saiunit-0.3.2 → saiunit-0.4.0}/examples/matplotlib_quantity_basics.py +0 -0
  79. {saiunit-0.3.2 → saiunit-0.4.0}/examples/matplotlib_quantity_interactive.py +0 -0
  80. {saiunit-0.3.2 → saiunit-0.4.0}/examples/matplotlib_quantity_vs_plain.py +0 -0
  81. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_backend.py +0 -0
  82. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_backend_parametrize_test.py +0 -0
  83. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_backend_test.py +0 -0
  84. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_base_getters_test.py +0 -0
  85. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_celsius.py +0 -0
  86. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_celsius_test.py +0 -0
  87. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_compatible_import.py +0 -0
  88. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_compatible_import_test.py +0 -0
  89. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_dask_laziness_test.py +0 -0
  90. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_display_test.py +0 -0
  91. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_exceptions.py +0 -0
  92. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_exceptions_test.py +0 -0
  93. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_jax_compat.py +0 -0
  94. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_jax_compat_test.py +0 -0
  95. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_jax_guard.py +0 -0
  96. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_jax_guard_test.py +0 -0
  97. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_matplotlib_compat.py +0 -0
  98. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_matplotlib_compat_test.py +0 -0
  99. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_misc.py +0 -0
  100. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_misc_test.py +0 -0
  101. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_ndonnx_test.py +0 -0
  102. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_no_jax_test.py +0 -0
  103. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_sparse_base_test.py +0 -0
  104. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_typing.py +0 -0
  105. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_typing_test.py +0 -0
  106. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_unit_shortcuts.py +0 -0
  107. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/_unit_shortcuts_test.py +0 -0
  108. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/__init__.py +0 -0
  109. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_hessian.py +0 -0
  110. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_hessian_test.py +0 -0
  111. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_jacobian_test.py +0 -0
  112. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_misc_test.py +0 -0
  113. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_value_and_grad_test.py +0 -0
  114. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/autograd/_vector_grad_test.py +0 -0
  115. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/fft/__init__.py +0 -0
  116. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/fft/_fft_backend_test.py +0 -0
  117. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/fft/_fft_keep_unit.py +0 -0
  118. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/fft/_fft_keep_unit_test.py +0 -0
  119. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/__init__.py +0 -0
  120. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_remove_unit.py +0 -0
  121. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/lax/_lax_remove_unit_test.py +0 -0
  122. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/__init__.py +0 -0
  123. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_backend_test.py +0 -0
  124. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_docstring_test.py +0 -0
  125. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_remove_unit.py +0 -0
  126. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/linalg/_linalg_remove_unit_test.py +0 -0
  127. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/__init__.py +0 -0
  128. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_alias.py +0 -0
  129. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_alias_test.py +0 -0
  130. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_einops_parsing.py +0 -0
  131. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_einops_parsing_test.py +0 -0
  132. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/_multi_backend_test.py +0 -0
  133. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/fft.py +0 -0
  134. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/math/linalg.py +0 -0
  135. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/py.typed +0 -0
  136. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit/sparse/__init__.py +0 -0
  137. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit.egg-info/dependency_links.txt +0 -0
  138. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit.egg-info/requires.txt +0 -0
  139. {saiunit-0.3.2 → saiunit-0.4.0}/saiunit.egg-info/top_level.txt +0 -0
  140. {saiunit-0.3.2 → saiunit-0.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: saiunit
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Enabling Unit-aware Computations for AI-driven Scientific Computing.
5
5
  Author-email: SAIUnit Developers <chao.brain@qq.com>
6
6
  License-Expression: Apache-2.0
@@ -91,6 +91,8 @@ all = ["saiunit[jax,cupy,torch,dask,ndonnx]"]
91
91
  # core requirements (numpy + jax) installed.
92
92
  [[tool.mypy.overrides]]
93
93
  module = [
94
+ "array_api_compat",
95
+ "array_api_compat.*",
94
96
  "brainstate",
95
97
  "brainstate.*",
96
98
  "cupy",
@@ -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 a length, so writing
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
- DimensionMismatchError: Function "getvoltage" variable "wibble" has wrong dimensions, dimensions were (1) (m)
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 d: d**2)
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
- DimensionMismatchError: Function 'multiply_sum' expected the same arguments for arguments 'summand_1', 'summand_2', but argument 'summand_1' has unit V, while argument 'summand_2' has unit A.
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
- DimensionMismatchError
407
+ UnitMismatchError
388
408
  In case the input arguments or the return value do not have the
389
- expected dimensions.
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
- expected_result = au["result"](*[get_dim(a) for a in args])
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 binary representation of the dimensions array.
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
- self._hash = hash(self._dims.tobytes())
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 or if the provided value is not a Dimension object.
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, returns False.
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 False
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
- return not self.__eq__(value)
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
- dims = np.asarray([0, 0, 0, 0, 0, 0, 0])
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
- dims[_dim2index[k]] = kwds[k]
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
- fail_for_unit_mismatch(items[i], items[0], 'Non-matching unit for function "unit_scale_align_to_first"')
735
- if not isinstance(items[i], Quantity):
736
- items[i] = Quantity(items[i])
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
- items[i] = items[i].in_unit(first_unit)
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