unitx 0.16.2__tar.gz → 0.16.3__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 (178) hide show
  1. {unitx-0.16.2 → unitx-0.16.3}/.github/consumers/arrays.py +21 -1
  2. {unitx-0.16.2 → unitx-0.16.3}/CHANGELOG.md +8 -0
  3. {unitx-0.16.2 → unitx-0.16.3}/PKG-INFO +1 -1
  4. {unitx-0.16.2 → unitx-0.16.3}/docs/numpy.md +34 -2
  5. {unitx-0.16.2 → unitx-0.16.3}/docs/typing.md +6 -1
  6. {unitx-0.16.2 → unitx-0.16.3}/pyproject.toml +1 -1
  7. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_numerics/numpy.py +36 -0
  8. unitx-0.16.3/tests/test_numpy_shapes.py +217 -0
  9. {unitx-0.16.2 → unitx-0.16.3}/.github/consumers/pyrightconfig.json +0 -0
  10. {unitx-0.16.2 → unitx-0.16.3}/.github/consumers/scalars.py +0 -0
  11. {unitx-0.16.2 → unitx-0.16.3}/.github/workflows/ci.yml +0 -0
  12. {unitx-0.16.2 → unitx-0.16.3}/.github/workflows/pages.yml +0 -0
  13. {unitx-0.16.2 → unitx-0.16.3}/.github/workflows/release.yml +0 -0
  14. {unitx-0.16.2 → unitx-0.16.3}/.gitignore +0 -0
  15. {unitx-0.16.2 → unitx-0.16.3}/.readthedocs.yaml +0 -0
  16. {unitx-0.16.2 → unitx-0.16.3}/CONTRIBUTING.md +0 -0
  17. {unitx-0.16.2 → unitx-0.16.3}/LICENSE +0 -0
  18. {unitx-0.16.2 → unitx-0.16.3}/README.md +0 -0
  19. {unitx-0.16.2 → unitx-0.16.3}/benchmarks/README.md +0 -0
  20. {unitx-0.16.2 → unitx-0.16.3}/benchmarks/numpy_writes.py +0 -0
  21. {unitx-0.16.2 → unitx-0.16.3}/benchmarks/scalars.py +0 -0
  22. {unitx-0.16.2 → unitx-0.16.3}/conftest.py +0 -0
  23. {unitx-0.16.2 → unitx-0.16.3}/docs/README.md +0 -0
  24. {unitx-0.16.2 → unitx-0.16.3}/docs/affine.md +0 -0
  25. {unitx-0.16.2 → unitx-0.16.3}/docs/architecture.md +0 -0
  26. {unitx-0.16.2 → unitx-0.16.3}/docs/catalogues/README.md +0 -0
  27. {unitx-0.16.2 → unitx-0.16.3}/docs/catalogues/cgs.md +0 -0
  28. {unitx-0.16.2 → unitx-0.16.3}/docs/catalogues/imperial.md +0 -0
  29. {unitx-0.16.2 → unitx-0.16.3}/docs/catalogues/si.md +0 -0
  30. {unitx-0.16.2 → unitx-0.16.3}/docs/custom-units.md +0 -0
  31. {unitx-0.16.2 → unitx-0.16.3}/docs/display.md +0 -0
  32. {unitx-0.16.2 → unitx-0.16.3}/docs/images/blackbody.svg +0 -0
  33. {unitx-0.16.2 → unitx-0.16.3}/docs/javascripts/mermaid.mjs +0 -0
  34. {unitx-0.16.2 → unitx-0.16.3}/docs/logarithmic.md +0 -0
  35. {unitx-0.16.2 → unitx-0.16.3}/docs/migration.md +0 -0
  36. {unitx-0.16.2 → unitx-0.16.3}/docs/numpy/calculus.md +0 -0
  37. {unitx-0.16.2 → unitx-0.16.3}/docs/numpy/selection.md +0 -0
  38. {unitx-0.16.2 → unitx-0.16.3}/docs/numpy/statistics.md +0 -0
  39. {unitx-0.16.2 → unitx-0.16.3}/docs/numpy/writes.md +0 -0
  40. {unitx-0.16.2 → unitx-0.16.3}/docs/requirements.txt +0 -0
  41. {unitx-0.16.2 → unitx-0.16.3}/docs/scalars.md +0 -0
  42. {unitx-0.16.2 → unitx-0.16.3}/docs/serialization.md +0 -0
  43. {unitx-0.16.2 → unitx-0.16.3}/docs/stdlib.md +0 -0
  44. {unitx-0.16.2 → unitx-0.16.3}/docs/units.md +0 -0
  45. {unitx-0.16.2 → unitx-0.16.3}/examples/README.md +0 -0
  46. {unitx-0.16.2 → unitx-0.16.3}/examples/arrhenius_fit.ipynb +0 -0
  47. {unitx-0.16.2 → unitx-0.16.3}/examples/blackbody.ipynb +0 -0
  48. {unitx-0.16.2 → unitx-0.16.3}/examples/damped_oscillator.ipynb +0 -0
  49. {unitx-0.16.2 → unitx-0.16.3}/examples/gaussian_wavepacket.ipynb +0 -0
  50. {unitx-0.16.2 → unitx-0.16.3}/examples/impedance.md +0 -0
  51. {unitx-0.16.2 → unitx-0.16.3}/examples/impedance.py +0 -0
  52. {unitx-0.16.2 → unitx-0.16.3}/examples/optical_attenuation.ipynb +0 -0
  53. {unitx-0.16.2 → unitx-0.16.3}/examples/pendulum_uncertainty.ipynb +0 -0
  54. {unitx-0.16.2 → unitx-0.16.3}/examples/smith_chart.ipynb +0 -0
  55. {unitx-0.16.2 → unitx-0.16.3}/mkdocs.yml +0 -0
  56. {unitx-0.16.2 → unitx-0.16.3}/pyrightconfig.json +0 -0
  57. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/__init__.py +0 -0
  58. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_constnamespace.py +0 -0
  59. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_coordinates/__init__.py +0 -0
  60. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_coordinates/affine.py +0 -0
  61. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_coordinates/definition.py +0 -0
  62. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_coordinates/logarithmic.py +0 -0
  63. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_coordinates/logarithms.py +0 -0
  64. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_coordinates/points.py +0 -0
  65. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_coordinates/resolve.py +0 -0
  66. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_electromagnetism/__init__.py +0 -0
  67. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_electromagnetism/coefficients.py +0 -0
  68. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_electromagnetism/conversion.py +0 -0
  69. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_formatting/__init__.py +0 -0
  70. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_formatting/render.py +0 -0
  71. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_formatting/unicode.py +0 -0
  72. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_immutable.py +0 -0
  73. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_numerics/__init__.py +0 -0
  74. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_numerics/conversion.py +0 -0
  75. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_numerics/numpy_typing.py +0 -0
  76. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_numerics/scaling.py +0 -0
  77. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_operands.py +0 -0
  78. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_registry/__init__.py +0 -0
  79. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_registry/builtin_units.py +0 -0
  80. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_registry/prefixes.py +0 -0
  81. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/_registry/reconstruction.py +0 -0
  82. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/affinequantity.py +0 -0
  83. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/affineunit.py +0 -0
  84. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/cgs/__init__.py +0 -0
  85. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/cgs/units.py +0 -0
  86. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/dimension.py +0 -0
  87. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/exceptions.py +0 -0
  88. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/factor.py +0 -0
  89. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/imperial/__init__.py +0 -0
  90. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/imperial/units.py +0 -0
  91. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/logquantity.py +0 -0
  92. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/logunit.py +0 -0
  93. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/math.py +0 -0
  94. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/monomial.py +0 -0
  95. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/py.typed +0 -0
  96. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/quantity.py +0 -0
  97. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/serialization.py +0 -0
  98. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/si/__init__.py +0 -0
  99. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/si/bases.py +0 -0
  100. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/si/constants.py +0 -0
  101. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/si/units.py +0 -0
  102. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/singleunit.py +0 -0
  103. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/time.py +0 -0
  104. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/unit.py +0 -0
  105. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/unitcatalog.py +0 -0
  106. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/unitdefinition.py +0 -0
  107. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/unitparser.py +0 -0
  108. {unitx-0.16.2 → unitx-0.16.3}/src/unitx/unitregistry.py +0 -0
  109. {unitx-0.16.2 → unitx-0.16.3}/tests/__init__.py +0 -0
  110. {unitx-0.16.2 → unitx-0.16.3}/tests/_notebooks.py +0 -0
  111. {unitx-0.16.2 → unitx-0.16.3}/tests/numpy_typing/check_asarray.py +0 -0
  112. {unitx-0.16.2 → unitx-0.16.3}/tests/numpy_typing/check_cgs.py +0 -0
  113. {unitx-0.16.2 → unitx-0.16.3}/tests/numpy_typing/check_logarithmic.py +0 -0
  114. {unitx-0.16.2 → unitx-0.16.3}/tests/numpy_typing/check_math.py +0 -0
  115. {unitx-0.16.2 → unitx-0.16.3}/tests/numpy_typing/check_numpy.py +0 -0
  116. {unitx-0.16.2 → unitx-0.16.3}/tests/test_affine.py +0 -0
  117. {unitx-0.16.2 → unitx-0.16.3}/tests/test_affinequantity.py +0 -0
  118. {unitx-0.16.2 → unitx-0.16.3}/tests/test_builtin_definitions.py +0 -0
  119. {unitx-0.16.2 → unitx-0.16.3}/tests/test_catalog_collection.py +0 -0
  120. {unitx-0.16.2 → unitx-0.16.3}/tests/test_cgs.py +0 -0
  121. {unitx-0.16.2 → unitx-0.16.3}/tests/test_cgs_numpy.py +0 -0
  122. {unitx-0.16.2 → unitx-0.16.3}/tests/test_cgs_systems.py +0 -0
  123. {unitx-0.16.2 → unitx-0.16.3}/tests/test_decibel_call.py +0 -0
  124. {unitx-0.16.2 → unitx-0.16.3}/tests/test_definition_scope.py +0 -0
  125. {unitx-0.16.2 → unitx-0.16.3}/tests/test_dimension.py +0 -0
  126. {unitx-0.16.2 → unitx-0.16.3}/tests/test_display.py +0 -0
  127. {unitx-0.16.2 → unitx-0.16.3}/tests/test_examples.py +0 -0
  128. {unitx-0.16.2 → unitx-0.16.3}/tests/test_factor.py +0 -0
  129. {unitx-0.16.2 → unitx-0.16.3}/tests/test_immutable.py +0 -0
  130. {unitx-0.16.2 → unitx-0.16.3}/tests/test_impedance_example.py +0 -0
  131. {unitx-0.16.2 → unitx-0.16.3}/tests/test_imperial.py +0 -0
  132. {unitx-0.16.2 → unitx-0.16.3}/tests/test_import.py +0 -0
  133. {unitx-0.16.2 → unitx-0.16.3}/tests/test_linear_boundaries.py +0 -0
  134. {unitx-0.16.2 → unitx-0.16.3}/tests/test_logarithmic_model.py +0 -0
  135. {unitx-0.16.2 → unitx-0.16.3}/tests/test_math.py +0 -0
  136. {unitx-0.16.2 → unitx-0.16.3}/tests/test_math_numpy.py +0 -0
  137. {unitx-0.16.2 → unitx-0.16.3}/tests/test_monomial.py +0 -0
  138. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numeric.py +0 -0
  139. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy.py +0 -0
  140. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_affine.py +0 -0
  141. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_differentiation.py +0 -0
  142. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_integration.py +0 -0
  143. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_interpolation.py +0 -0
  144. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_multi_outputs.py +0 -0
  145. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_mutation.py +0 -0
  146. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_nan_reductions.py +0 -0
  147. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_nan_statistics.py +0 -0
  148. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_outputs.py +0 -0
  149. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_products.py +0 -0
  150. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_selection.py +0 -0
  151. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_statistics.py +0 -0
  152. {unitx-0.16.2 → unitx-0.16.3}/tests/test_numpy_weighted_statistics.py +0 -0
  153. {unitx-0.16.2 → unitx-0.16.3}/tests/test_quantity.py +0 -0
  154. {unitx-0.16.2 → unitx-0.16.3}/tests/test_registry.py +0 -0
  155. {unitx-0.16.2 → unitx-0.16.3}/tests/test_registry_scope.py +0 -0
  156. {unitx-0.16.2 → unitx-0.16.3}/tests/test_serialization.py +0 -0
  157. {unitx-0.16.2 → unitx-0.16.3}/tests/test_si.py +0 -0
  158. {unitx-0.16.2 → unitx-0.16.3}/tests/test_singleunit.py +0 -0
  159. {unitx-0.16.2 → unitx-0.16.3}/tests/test_siunitx_display.py +0 -0
  160. {unitx-0.16.2 → unitx-0.16.3}/tests/test_time.py +0 -0
  161. {unitx-0.16.2 → unitx-0.16.3}/tests/test_typing.py +0 -0
  162. {unitx-0.16.2 → unitx-0.16.3}/tests/test_unicode.py +0 -0
  163. {unitx-0.16.2 → unitx-0.16.3}/tests/test_unicode_units.py +0 -0
  164. {unitx-0.16.2 → unitx-0.16.3}/tests/test_unit.py +0 -0
  165. {unitx-0.16.2 → unitx-0.16.3}/tests/test_unit_definition.py +0 -0
  166. {unitx-0.16.2 → unitx-0.16.3}/tests/test_unitparser.py +0 -0
  167. {unitx-0.16.2 → unitx-0.16.3}/tests/test_unitregistry.py +0 -0
  168. {unitx-0.16.2 → unitx-0.16.3}/tests/test_warnings.py +0 -0
  169. {unitx-0.16.2 → unitx-0.16.3}/tests/typing/check_affine.py +0 -0
  170. {unitx-0.16.2 → unitx-0.16.3}/tests/typing/check_cgs.py +0 -0
  171. {unitx-0.16.2 → unitx-0.16.3}/tests/typing/check_impedance_example.py +0 -0
  172. {unitx-0.16.2 → unitx-0.16.3}/tests/typing/check_imperial.py +0 -0
  173. {unitx-0.16.2 → unitx-0.16.3}/tests/typing/check_logarithmic.py +0 -0
  174. {unitx-0.16.2 → unitx-0.16.3}/tests/typing/check_math_time.py +0 -0
  175. {unitx-0.16.2 → unitx-0.16.3}/tests/typing/check_quantity.py +0 -0
  176. {unitx-0.16.2 → unitx-0.16.3}/tests/typing/check_registry.py +0 -0
  177. {unitx-0.16.2 → unitx-0.16.3}/tests/typing/check_serialization.py +0 -0
  178. {unitx-0.16.2 → unitx-0.16.3}/tests/typing/check_si.py +0 -0
@@ -5,12 +5,14 @@ from typing import assert_type, cast
5
5
  import numpy as np
6
6
  from numpy.typing import NDArray
7
7
 
8
- from unitx import LogQuantity, LogUnit, Quantity, Unit
8
+ from unitx import AffineQuantity, LogQuantity, LogUnit, Quantity, Unit
9
9
  from unitx._numerics import Array
10
10
  from unitx.cgs import emu, esu, gaussian, hl
11
11
  from unitx.exceptions import DimensionError
12
12
  from unitx.si import si
13
13
 
14
+ type ArrayQuantity = Quantity[Array] | AffineQuantity[Array] | LogQuantity[Array]
15
+
14
16
 
15
17
  def check_arrays(values: NDArray[np.float64]) -> None:
16
18
  charge = gaussian.convert(Quantity(values, si.C), gaussian.statC)
@@ -47,6 +49,24 @@ def check_arrays(values: NDArray[np.float64]) -> None:
47
49
 
48
50
  # NumPy's stubs lose the wrapper on these returns. State the supported
49
51
  # array result domain explicitly and verify the wrapper at runtime.
52
+ for sample in (distance, AffineQuantity(values, si.degC), level):
53
+ expanded = cast(ArrayQuantity, np.expand_dims(sample, (0, -1)))
54
+ # moveaxis/rollaxis stubs require ndarray input despite runtime dispatch.
55
+ moved = cast(
56
+ ArrayQuantity,
57
+ expanded.__array_function__(np.moveaxis, (type(expanded),), (expanded, 0, -1), {}),
58
+ )
59
+ swapped = cast(ArrayQuantity, np.swapaxes(moved, 0, 1))
60
+ rolled = cast(
61
+ ArrayQuantity,
62
+ swapped.__array_function__(np.rollaxis, (type(swapped),), (swapped, 2, 0), {}),
63
+ )
64
+ squeezed = np.squeeze(rolled)
65
+ result = cast(ArrayQuantity, np.ravel(squeezed))
66
+ assert type(result) is type(sample) and result.unit == sample.unit
67
+ np.testing.assert_array_equal(result.value, values)
68
+ assert np.shares_memory(result.value, sample.value)
69
+
50
70
  bounded = cast(Quantity[Array], np.clip(distance, Quantity(500, si.cm), Quantity(25, si.m)))
51
71
  assert isinstance(bounded, Quantity) and bounded.unit == si.m
52
72
  np.testing.assert_allclose(bounded.value, [5, 20, 25])
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.16.3] - 2026-10-25
6
+
7
+ ### Added
8
+
9
+ - Support NumPy `squeeze`, `expand_dims`, `moveaxis`, `swapaxes`, `rollaxis`
10
+ and `ravel`, preserving complete units or point coordinates. Retain NumPy's
11
+ axis rules, dtype, shared views, read-only storage and necessary copies.
12
+
5
13
  ## [0.16.2] - 2026-10-25
6
14
 
7
15
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unitx
3
- Version: 0.16.2
3
+ Version: 0.16.3
4
4
  Summary: Physical units, quantities, and unit conversion with exact unit algebra, type hints, and NumPy integration.
5
5
  Project-URL: Homepage, https://github.com/DaitTHU/unitx
6
6
  Project-URL: Documentation, https://daitthu.github.io/unitx/
@@ -38,8 +38,8 @@ use `np.asarray(q.value_in(target_unit))`.
38
38
 
39
39
  Construction, unit-only multiplication/division, and an identity conversion
40
40
  preserve the magnitude object. Factor/SymbolicConstant operands change the
41
- exact unit scale. Indexing, iteration, `.T`, reshape, and transpose follow
42
- NumPy's scalar/view/copy rules; `.flatten(order='C')` copies. Zero-dimensional
41
+ exact unit scale. Indexing, iteration, `.T`, and the supported shape transformations
42
+ follow NumPy's scalar/view/copy rules; `.flatten(order='C')` copies. Zero-dimensional
43
43
  arrays cannot be iterated. `.shape`, `.ndim`, `.dtype`, and `.size` are available
44
44
  for NumPy magnitudes. Python scalars do not acquire array attributes. Exposed
45
45
  values and views may share writable storage.
@@ -115,6 +115,9 @@ functions, including exp/log. Python scalar numerical semantics remain native.
115
115
  | NumPy function | Unit/result rule | Supported options |
116
116
  | --- | --- | --- |
117
117
  | reshape, transpose | Preserve unit | shape/order/copy; axes |
118
+ | squeeze, expand_dims | Preserve unit or point coordinate | axis |
119
+ | moveaxis, swapaxes, rollaxis | Preserve unit or point coordinate | source/destination; axis1/axis2; axis/start |
120
+ | ravel | Preserve unit or point coordinate | order: C, F, A, K |
118
121
  | concatenate, stack | Convert to first Quantity's unit; bare arrays remain dimensionless | axis, dtype, casting, out; dtype and out are mutually exclusive |
119
122
  | sum, nansum | Preserve unit | axis, dtype, keepdims, initial, where, out |
120
123
  | mean, nanmean | Preserve unit or point coordinate | axis, dtype, keepdims, where, out |
@@ -146,6 +149,35 @@ Indexed assignment and augmented arithmetic on ndarrays follow the fixed-target
146
149
  rules below. Python and NumPy scalar magnitudes retain ordinary result/rebinding
147
150
  behavior for augmented arithmetic.
148
151
 
152
+ ## Shape transformations
153
+
154
+ `squeeze`, `expand_dims`, `moveaxis`, `swapaxes`, `rollaxis`, and `ravel`
155
+ operate on magnitudes without conversion. Like reshape and transpose, they
156
+ retain the complete unit or point coordinate and return the same family:
157
+ Quantity, AffineQuantity, or LogQuantity. Only the NumPy function forms are
158
+ supported; these functions have no `out` option.
159
+
160
+ Axis rules and errors follow NumPy, including negative and tuple axes where
161
+ supported. Squeezing an ndarray whose axes all have length one produces a
162
+ zero-dimensional ndarray magnitude. Views share storage and its writeability
163
+ with the input.
164
+ `ravel` returns a contiguous one-dimensional magnitude, copying only when needed;
165
+ it can copy a noncontiguous input even when reshape could return a view.
166
+ Its C/F/A/K orders follow NumPy. Prefer `moveaxis` for new code; `rollaxis` is
167
+ also supported for compatibility with existing NumPy code.
168
+
169
+ ```python
170
+ import numpy as np
171
+ from unitx import Quantity
172
+
173
+ samples = Quantity(np.arange(6).reshape(1, 2, 3), 'cm')
174
+ matrix = np.squeeze(samples, axis=0)
175
+ assert matrix.shape == (2, 3)
176
+ assert np.moveaxis(matrix, 0, -1).shape == (3, 2)
177
+ assert matrix.unit == samples.unit
178
+ assert np.shares_memory(matrix.value, samples.value)
179
+ ```
180
+
149
181
  ## Selection and clipping
150
182
 
151
183
  See [selection and clipping](numpy/selection.md) for `where`, `clip`,
@@ -94,11 +94,16 @@ ufunc stubs preserve the wrapper at every intermediate call. For an explicit
94
94
  numerical boundary, use `np.expm1(np.asarray(q.value_in(si.one)))` after
95
95
  checking dimensions and normalizing the scale.
96
96
 
97
- The same NumPy stub limitation applies to `where`, `clip`,
97
+ The same NumPy stub limitation applies to the supported shape transformations,
98
+ `where`, `clip`,
98
99
  `minimum/maximum`, `average`, `quantile`, `percentile`, the supported `nan*`
99
100
  statistics, and `interp`: runtime results
100
101
  preserve the documented Quantity/point wrappers, but NumPy may annotate them
101
102
  as ndarrays or Any. NumPy's `out` annotations also accept only ndarrays.
103
+ The `moveaxis` and `rollaxis` input annotations likewise require ndarrays
104
+ and can reject a Quantity/point argument during static checking, despite
105
+ working at runtime. Their installed type consumers check the dispatch protocol
106
+ directly; runtime tests use ordinary NumPy calls.
102
107
  Runtime support and installed-consumer checks cover
103
108
  these calls; unitx does not replace NumPy's stubs.
104
109
 
@@ -4,7 +4,7 @@ build-backend = 'hatchling.build'
4
4
 
5
5
  [project]
6
6
  name = 'unitx'
7
- version = '0.16.2'
7
+ version = '0.16.3'
8
8
  description = 'Physical units, quantities, and unit conversion with exact unit algebra, type hints, and NumPy integration.'
9
9
  readme = 'README.md'
10
10
  requires-python = '>=3.12'
@@ -604,6 +604,12 @@ def dispatch_function(
604
604
  np.allclose,
605
605
  np.reshape,
606
606
  np.transpose,
607
+ np.squeeze,
608
+ np.expand_dims,
609
+ np.moveaxis,
610
+ np.swapaxes,
611
+ np.rollaxis,
612
+ np.ravel,
607
613
  np.argmin,
608
614
  np.argmax,
609
615
  np.nanargmin,
@@ -754,6 +760,30 @@ def _transpose(q: Any, axes: Any = None) -> Any:
754
760
  return _wrap(np.transpose(q.value, axes), q.unit)
755
761
 
756
762
 
763
+ def _squeeze(q: Any, axis: Any = None) -> Any:
764
+ return _wrap(np.squeeze(q.value, axis=axis), q.unit)
765
+
766
+
767
+ def _expand_dims(q: Any, axis: Any) -> Any:
768
+ return _wrap(np.expand_dims(q.value, axis), q.unit)
769
+
770
+
771
+ def _moveaxis(q: Any, source: Any, destination: Any) -> Any:
772
+ return _wrap(np.moveaxis(q.value, source, destination), q.unit)
773
+
774
+
775
+ def _swapaxes(q: Any, axis1: Any, axis2: Any) -> Any:
776
+ return _wrap(np.swapaxes(q.value, axis1, axis2), q.unit)
777
+
778
+
779
+ def _rollaxis(q: Any, axis: Any, start: Any = 0) -> Any:
780
+ return _wrap(np.rollaxis(q.value, axis, start), q.unit)
781
+
782
+
783
+ def _ravel(q: Any, order: Literal['C', 'F', 'A', 'K'] = 'C') -> Any:
784
+ return _wrap(np.ravel(q.value, order=order), q.unit)
785
+
786
+
757
787
  def _interp(
758
788
  x: Any, xp: Any, fp: Any, left: Any = None, right: Any = None, period: Any = None
759
789
  ) -> Any:
@@ -1216,6 +1246,12 @@ def _extremum(
1216
1246
  _QUANTITY_FUNCTIONS = {
1217
1247
  np.reshape: _reshape,
1218
1248
  np.transpose: _transpose,
1249
+ np.squeeze: _squeeze,
1250
+ np.expand_dims: _expand_dims,
1251
+ np.moveaxis: _moveaxis,
1252
+ np.swapaxes: _swapaxes,
1253
+ np.rollaxis: _rollaxis,
1254
+ np.ravel: _ravel,
1219
1255
  np.argmin: partial(_argmin_max, np.argmin),
1220
1256
  np.argmax: partial(_argmin_max, np.argmax),
1221
1257
  np.nanargmin: partial(_argmin_max, np.nanargmin),
@@ -0,0 +1,217 @@
1
+ """Shape transformations retain units, coordinates and NumPy storage behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ import pytest
9
+
10
+ from unitx import AffineQuantity, LogQuantity, Quantity, Unit
11
+ from unitx._numerics import Array
12
+ from unitx.factor import Factor, pi
13
+
14
+ np = pytest.importorskip('numpy')
15
+
16
+ ArrayQuantity = Quantity[Array] | AffineQuantity[Array] | LogQuantity[Array]
17
+
18
+ _TRANSFORMS = [
19
+ pytest.param(np.squeeze, (), {}, id='squeeze'),
20
+ pytest.param(np.squeeze, (), {'axis': (0, -2)}, id='squeeze-tuple'),
21
+ pytest.param(np.expand_dims, (0,), {}, id='expand-dims'),
22
+ pytest.param(np.expand_dims, (), {'axis': (0, -1)}, id='expand-dims-tuple'),
23
+ pytest.param(np.moveaxis, (0, -1), {}, id='moveaxis'),
24
+ pytest.param(np.moveaxis, (), {'source': (0, 3), 'destination': (3, 0)}, id='moveaxis-tuple'),
25
+ pytest.param(np.swapaxes, (), {'axis1': 1, 'axis2': -1}, id='swapaxes'),
26
+ pytest.param(np.rollaxis, (3,), {}, id='rollaxis'),
27
+ pytest.param(np.rollaxis, (), {'axis': 0, 'start': 4}, id='rollaxis-end'),
28
+ pytest.param(np.rollaxis, (3, -3), {}, id='rollaxis-negative-start'),
29
+ pytest.param(np.ravel, (), {}, id='ravel'),
30
+ ]
31
+
32
+
33
+ @pytest.fixture(params=['linear', 'affine', 'logarithmic'])
34
+ def sample(request: pytest.FixtureRequest) -> ArrayQuantity:
35
+ values = np.arange(24, dtype=np.int16).reshape(1, 2, 1, 12)[..., ::4]
36
+ if request.param == 'linear':
37
+ return Quantity(values, Factor(2) * pi * Unit('cm'))
38
+ if request.param == 'affine':
39
+ return AffineQuantity(values, 'degC')
40
+ return LogQuantity(values, 'dBm')
41
+
42
+
43
+ @pytest.mark.parametrize('operation,args,kwargs', _TRANSFORMS)
44
+ @pytest.mark.parametrize('readonly', [False, True])
45
+ def test_numpy_shape_transform_storage(
46
+ sample: ArrayQuantity,
47
+ operation: Callable[..., Any],
48
+ args: tuple[object, ...],
49
+ kwargs: dict[str, object],
50
+ readonly: bool,
51
+ ) -> None:
52
+ sample.value.flags.writeable = not readonly
53
+ before = sample.value.copy()
54
+ expected = operation(sample.value, *args, **kwargs)
55
+ result = operation(sample, *args, **kwargs)
56
+ assert type(result) is type(sample)
57
+ assert result.unit == sample.unit
58
+ assert type(result.value) is type(expected)
59
+ assert result.value.dtype == expected.dtype
60
+ assert result.value.shape == expected.shape
61
+ assert result.value.strides == expected.strides
62
+ assert result.value.flags.writeable == expected.flags.writeable
63
+ assert np.shares_memory(result.value, sample.value) == np.shares_memory(expected, sample.value)
64
+ np.testing.assert_array_equal(result.value, expected)
65
+ np.testing.assert_array_equal(sample.value, before)
66
+
67
+
68
+ @pytest.mark.parametrize('operation,args,kwargs', _TRANSFORMS)
69
+ def test_numpy_shape_transform_empty(
70
+ sample: ArrayQuantity,
71
+ operation: Callable[..., Any],
72
+ args: tuple[object, ...],
73
+ kwargs: dict[str, object],
74
+ ) -> None:
75
+ q = type(sample)(np.empty((1, 0, 1, 3), dtype=np.float32), sample.unit)
76
+ expected = operation(q.value, *args, **kwargs)
77
+ result = operation(q, *args, **kwargs)
78
+ assert type(result) is type(q) and result.unit == q.unit
79
+ assert result.value.shape == expected.shape
80
+ assert result.value.dtype == expected.dtype
81
+
82
+
83
+ @pytest.mark.parametrize(
84
+ 'operation,args',
85
+ [(np.squeeze, ()), (np.expand_dims, (-1,)), (np.moveaxis, ((), ())), (np.ravel, ())],
86
+ )
87
+ @pytest.mark.parametrize('value', [2, np.float32(2), np.array(2, dtype=np.int16)])
88
+ def test_numpy_shape_transform_scalar(
89
+ sample: ArrayQuantity, operation: Callable[..., Any], args: tuple[object, ...], value: Any
90
+ ) -> None:
91
+ q = type(sample)(value, sample.unit)
92
+ expected = operation(value, *args)
93
+ result = operation(q, *args)
94
+ assert type(result) is type(q) and result.unit == q.unit
95
+ assert type(result.value) is type(expected)
96
+ assert result.value.shape == expected.shape
97
+ assert result.value.dtype == expected.dtype
98
+ np.testing.assert_array_equal(result.value, expected)
99
+
100
+
101
+ def test_numpy_squeeze_zero_dimensional_result(sample: ArrayQuantity) -> None:
102
+ q = type(sample)(np.array([[2]], dtype=np.int16), sample.unit)
103
+ result = np.squeeze(q)
104
+ assert type(result) is type(q) and result.unit == q.unit
105
+ assert type(result.value) is np.ndarray and result.value.shape == ()
106
+ assert result.value.item() == 2
107
+ assert np.shares_memory(result.value, q.value)
108
+
109
+
110
+ @pytest.mark.parametrize('operation,args,kwargs', _TRANSFORMS)
111
+ def test_numpy_shape_transform_shared_write(
112
+ sample: ArrayQuantity,
113
+ operation: Callable[..., Any],
114
+ args: tuple[object, ...],
115
+ kwargs: dict[str, object],
116
+ ) -> None:
117
+ q = type(sample)(np.arange(6.0).reshape(1, 2, 1, 3), sample.unit)
118
+ result = operation(q, *args, **kwargs)
119
+ assert np.shares_memory(result.value, q.value)
120
+ result.value.flat[0] = 42
121
+ assert q.value.flat[0] == 42
122
+ assert q.unit == sample.unit
123
+
124
+
125
+ @pytest.mark.parametrize('order', ['C', 'F', 'A', 'K'])
126
+ @pytest.mark.parametrize('layout', ['C', 'F', 'transpose', 'strided', 'negative'])
127
+ def test_numpy_ravel_order(sample: ArrayQuantity, order: str, layout: str) -> None:
128
+ values = np.arange(12, dtype=np.int16).reshape(3, 4)
129
+ if layout == 'F':
130
+ values = np.asfortranarray(values)
131
+ elif layout == 'transpose':
132
+ values = values.T
133
+ elif layout == 'strided':
134
+ values = values[:, ::2]
135
+ elif layout == 'negative':
136
+ values = values[:, ::-1]
137
+ q = type(sample)(values, sample.unit)
138
+ before = values.copy()
139
+ expected = np.ravel(values, order)
140
+ result = np.ravel(q, order)
141
+ assert type(result) is type(q) and result.unit == q.unit
142
+ assert result.value.ndim == 1 and result.value.flags.c_contiguous
143
+ assert np.shares_memory(result.value, values) == np.shares_memory(expected, values)
144
+ np.testing.assert_array_equal(result.value, expected)
145
+ if not np.shares_memory(result.value, values):
146
+ result.value[0] = -1
147
+ np.testing.assert_array_equal(values, before)
148
+
149
+
150
+ @pytest.mark.parametrize(
151
+ 'operation,args',
152
+ [
153
+ (np.squeeze, (4,)),
154
+ (np.expand_dims, (-6,)),
155
+ (np.moveaxis, (4, 0)),
156
+ (np.swapaxes, (0, -5)),
157
+ (np.rollaxis, (0, 5)),
158
+ ],
159
+ )
160
+ def test_numpy_shape_transform_invalid_axis(
161
+ sample: ArrayQuantity, operation: Callable[..., Any], args: tuple[object, ...]
162
+ ) -> None:
163
+ before = sample.value.copy()
164
+ with pytest.raises(np.exceptions.AxisError):
165
+ operation(sample, *args)
166
+ np.testing.assert_array_equal(sample.value, before)
167
+
168
+
169
+ def test_numpy_squeeze_invalid_axis_size(sample: ArrayQuantity) -> None:
170
+ with pytest.raises(ValueError, match='size not equal to one'):
171
+ np.squeeze(sample, axis=1)
172
+
173
+
174
+ def test_numpy_squeeze_invalid_repeated_axis(sample: ArrayQuantity) -> None:
175
+ with pytest.raises(ValueError, match='duplicate value'):
176
+ np.squeeze(sample, axis=(0, 0))
177
+
178
+
179
+ @pytest.mark.parametrize(
180
+ 'operation,args',
181
+ [
182
+ (np.expand_dims, ((0, 0),)),
183
+ (np.moveaxis, ((0, 0), (1, 2))),
184
+ (np.moveaxis, ((0, 1), (2, 2))),
185
+ ],
186
+ )
187
+ def test_numpy_shape_transform_invalid_repeated_axis(
188
+ sample: ArrayQuantity, operation: Callable[..., Any], args: tuple[object, ...]
189
+ ) -> None:
190
+ with pytest.raises(ValueError, match='repeated axis'):
191
+ operation(sample, *args)
192
+
193
+
194
+ def test_numpy_moveaxis_invalid_axis_count(sample: ArrayQuantity) -> None:
195
+ with pytest.raises(ValueError, match='same number of elements'):
196
+ np.moveaxis(sample, (0, 1), (2,))
197
+
198
+
199
+ @pytest.mark.parametrize('operation,args,kwargs', _TRANSFORMS)
200
+ def test_numpy_shape_transform_invalid_keyword(
201
+ sample: ArrayQuantity,
202
+ operation: Callable[..., Any],
203
+ args: tuple[object, ...],
204
+ kwargs: dict[str, object],
205
+ ) -> None:
206
+ with pytest.raises(TypeError, match='unexpected keyword'):
207
+ operation(sample, *args, **kwargs, out=sample)
208
+
209
+
210
+ def test_numpy_ravel_invalid_order(sample: ArrayQuantity) -> None:
211
+ with pytest.raises(ValueError, match='order'):
212
+ np.ravel(sample, order='invalid')
213
+
214
+
215
+ def test_numpy_ravel_invalid_copy(sample: ArrayQuantity) -> None:
216
+ with pytest.raises(TypeError, match='unexpected keyword'):
217
+ np.ravel(sample, copy=True)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes