unitx 0.8.2__tar.gz → 0.8.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 (63) hide show
  1. {unitx-0.8.2 → unitx-0.8.3}/CHANGELOG.md +12 -0
  2. {unitx-0.8.2 → unitx-0.8.3}/PKG-INFO +1 -1
  3. {unitx-0.8.2 → unitx-0.8.3}/docs/numpy.md +40 -15
  4. {unitx-0.8.2 → unitx-0.8.3}/pyproject.toml +1 -1
  5. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/_numpy.py +51 -30
  6. {unitx-0.8.2 → unitx-0.8.3}/tests/numpy_typing/check_numpy.py +6 -0
  7. {unitx-0.8.2 → unitx-0.8.3}/tests/test_numpy.py +9 -8
  8. unitx-0.8.3/tests/test_numpy_outputs.py +229 -0
  9. {unitx-0.8.2 → unitx-0.8.3}/.github/workflows/ci.yml +0 -0
  10. {unitx-0.8.2 → unitx-0.8.3}/.github/workflows/release.yml +0 -0
  11. {unitx-0.8.2 → unitx-0.8.3}/.gitignore +0 -0
  12. {unitx-0.8.2 → unitx-0.8.3}/LICENSE +0 -0
  13. {unitx-0.8.2 → unitx-0.8.3}/README.md +0 -0
  14. {unitx-0.8.2 → unitx-0.8.3}/conftest.py +0 -0
  15. {unitx-0.8.2 → unitx-0.8.3}/docs/images/blackbody.png +0 -0
  16. {unitx-0.8.2 → unitx-0.8.3}/docs/migration.md +0 -0
  17. {unitx-0.8.2 → unitx-0.8.3}/docs/scalars.md +0 -0
  18. {unitx-0.8.2 → unitx-0.8.3}/docs/units.md +0 -0
  19. {unitx-0.8.2 → unitx-0.8.3}/examples/blackbody.py +0 -0
  20. {unitx-0.8.2 → unitx-0.8.3}/pyrightconfig.asarray.json +0 -0
  21. {unitx-0.8.2 → unitx-0.8.3}/pyrightconfig.examples.json +0 -0
  22. {unitx-0.8.2 → unitx-0.8.3}/pyrightconfig.json +0 -0
  23. {unitx-0.8.2 → unitx-0.8.3}/pyrightconfig.numpy.json +0 -0
  24. {unitx-0.8.2 → unitx-0.8.3}/pyrightconfig.scalar.json +0 -0
  25. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/__init__.py +0 -0
  26. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/_constnamespace.py +0 -0
  27. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/_numerics.py +0 -0
  28. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/_numpy_typing.py +0 -0
  29. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/_unicode.py +0 -0
  30. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/dimension.py +0 -0
  31. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/exceptions.py +0 -0
  32. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/factor.py +0 -0
  33. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/monomial.py +0 -0
  34. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/py.typed +0 -0
  35. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/quantity.py +0 -0
  36. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/si/__init__.py +0 -0
  37. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/si/bases.py +0 -0
  38. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/si/constants.py +0 -0
  39. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/si/units.py +0 -0
  40. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/singleunit.py +0 -0
  41. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/unit.py +0 -0
  42. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/unitparser.py +0 -0
  43. {unitx-0.8.2 → unitx-0.8.3}/src/unitx/unitregistry.py +0 -0
  44. {unitx-0.8.2 → unitx-0.8.3}/tests/__init__.py +0 -0
  45. {unitx-0.8.2 → unitx-0.8.3}/tests/example_typing/check_blackbody.py +0 -0
  46. {unitx-0.8.2 → unitx-0.8.3}/tests/test_builtin_definitions.py +0 -0
  47. {unitx-0.8.2 → unitx-0.8.3}/tests/test_dimension.py +0 -0
  48. {unitx-0.8.2 → unitx-0.8.3}/tests/test_factor.py +0 -0
  49. {unitx-0.8.2 → unitx-0.8.3}/tests/test_import.py +0 -0
  50. {unitx-0.8.2 → unitx-0.8.3}/tests/test_monomial.py +0 -0
  51. {unitx-0.8.2 → unitx-0.8.3}/tests/test_numeric.py +0 -0
  52. {unitx-0.8.2 → unitx-0.8.3}/tests/test_quantity.py +0 -0
  53. {unitx-0.8.2 → unitx-0.8.3}/tests/test_si.py +0 -0
  54. {unitx-0.8.2 → unitx-0.8.3}/tests/test_singleunit.py +0 -0
  55. {unitx-0.8.2 → unitx-0.8.3}/tests/test_typing.py +0 -0
  56. {unitx-0.8.2 → unitx-0.8.3}/tests/test_unicode.py +0 -0
  57. {unitx-0.8.2 → unitx-0.8.3}/tests/test_unit.py +0 -0
  58. {unitx-0.8.2 → unitx-0.8.3}/tests/test_unit_definition.py +0 -0
  59. {unitx-0.8.2 → unitx-0.8.3}/tests/test_unitparser.py +0 -0
  60. {unitx-0.8.2 → unitx-0.8.3}/tests/test_unitregistry.py +0 -0
  61. {unitx-0.8.2 → unitx-0.8.3}/tests/test_warnings.py +0 -0
  62. {unitx-0.8.2 → unitx-0.8.3}/tests/typing/check_quantity.py +0 -0
  63. {unitx-0.8.2 → unitx-0.8.3}/tests/typing/check_si.py +0 -0
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.3] - 2026-10-20
4
+
5
+ ### Added
6
+
7
+ - Support unit-aware `out` for sum, mean, median, std, var, cumsum,
8
+ concatenate, stack, and add.reduce/accumulate. Require the exact result shape,
9
+ preserve target identity, unit, and dtype, and stage computation, conversion,
10
+ and casting before writing. Argmin/argmax accept bare integer ndarray outputs.
11
+ - Document function/reduction output casting and add regressions for scaled
12
+ units, positional outputs, overlapping/strided buffers, and unchanged outputs
13
+ on failure, plus installed-consumer type checks.
14
+
3
15
  ## [0.8.2] - 2026-10-20
4
16
 
5
17
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unitx
3
- Version: 0.8.2
3
+ Version: 0.8.3
4
4
  Summary: A modern, intuitive Python library for units, quantities, and dimensional analysis.
5
5
  Project-URL: Homepage, https://github.com/DaitTHU/unitx
6
6
  Project-URL: Issues, https://github.com/DaitTHU/unitx/issues
@@ -79,7 +79,7 @@ methods, mixed array types, and keywords fail explicitly.
79
79
  | power, float_power / `**` | Dimensional bases require a finite real scalar exponent; dimensionless bases/exponents normalize their scales |
80
80
  | sin, cos, tan, exp, expm1, log, log2, log10, log1p | Require coherent dimensionless values; return dimensionless Quantity |
81
81
  | isfinite, isnan, isinf, signbit | Bare boolean results |
82
- | add.reduce, add.accumulate | Preserve unit; accept axis and dtype, plus keepdims for reduce |
82
+ | add.reduce, add.accumulate | Preserve unit; accept axis, dtype, out, plus keepdims for reduce |
83
83
 
84
84
  Ufunc `__call__` accepts `where`, `dtype`, `casting`, `order`, `subok`, and
85
85
  single-output `out` as described below. Each kernel validates its own options.
@@ -95,25 +95,29 @@ functions, including exp/log. Python scalar numerical semantics remain native.
95
95
  | NumPy function | Unit/result rule | Supported options |
96
96
  | --- | --- | --- |
97
97
  | reshape, transpose | Preserve unit | shape/order/copy; axes |
98
- | concatenate, stack | Convert to first Quantity's unit; bare arrays remain dimensionless | axis, dtype, casting |
99
- | sum, mean | Preserve unit | axis, dtype, keepdims |
100
- | median | Preserve unit | axis, keepdims; overwrite_input=False |
101
- | std, var | Preserve unit / square unit | axis, dtype, ddof, keepdims |
102
- | cumsum | Preserve unit | axis, dtype |
103
- | argmin, argmax | Bare indices | axis, keepdims |
98
+ | concatenate, stack | Convert to first Quantity's unit; bare arrays remain dimensionless | axis, dtype, casting, out; dtype and out are mutually exclusive |
99
+ | sum, mean | Preserve unit | axis, dtype, keepdims, out |
100
+ | median | Preserve unit | axis, keepdims, out; overwrite_input=False |
101
+ | std, var | Preserve unit / square unit | axis, dtype, ddof, keepdims, out |
102
+ | cumsum | Preserve unit | axis, dtype, out |
103
+ | argmin, argmax | Bare indices | axis, keepdims, out |
104
104
  | isclose, allclose | Bare mask / Python bool | rtol, atol, equal_nan |
105
105
 
106
106
  Supply the first magnitude argument positionally; concatenate/stack take a
107
107
  sequence. Options follow each supported signature, including positional out
108
- checks. Function and reduction `out` and overwrite_input=True are rejected. Unsupported
109
- initial, statistical where, mean, correction, and other keywords raise TypeError.
108
+ checks. Function and reduction outputs follow the rules below;
109
+ overwrite_input=True is rejected. Unsupported initial, reduction/statistical
110
+ where, mean, correction, and other keywords raise TypeError.
110
111
  There is no `__setitem__`; `q += other` creates a result and rebinds q.
111
112
 
112
- ## Ufunc output buffers
113
+ ## Output buffers
113
114
 
114
- Supported single-output ufunc calls accept a Quantity whose magnitude is a
115
+ Supported single-output ufunc calls, add.reduce/accumulate, and the numeric
116
+ functions listed with `out` above accept a Quantity whose magnitude is a
115
117
  writable plain numeric ndarray. `out=target`, `out=(target,)`, and positional
116
- outputs return that same target. Its magnitude object, dtype, and unit stay
118
+ outputs for ufuncs return that same target; functions accept `out=target` or
119
+ the positional output argument in their NumPy signature, without a tuple.
120
+ The target's magnitude object, dtype, and unit stay
117
121
  fixed. Zero-dimensional, empty, and noncontiguous output arrays are supported.
118
122
  NumPy scalars and Python scalars cannot serve as output buffers.
119
123
 
@@ -127,7 +131,10 @@ converts that result to the target unit and casts it into a staging buffer.
127
131
  The numerical kernel uses the input dtypes and any explicit `dtype`; the
128
132
  target dtype is used for the final cast. This is not a promise of NumPy's
129
133
  output-dependent kernel selection or allocation-free execution. Both the
130
- result and `where` mask must broadcast to the target shape. Only true mask
134
+ result and `where` mask must broadcast to the target shape for ufunc calls.
135
+ Function and reduction outputs must have exactly the ordinary result's shape,
136
+ including axes retained by `keepdims`; they do not broadcast into a larger
137
+ target. Only true mask
131
138
  positions are written; false positions retain their original target values.
132
139
  Unit conversion also skips false positions. Input/output overlap and shared
133
140
  slices are handled through temporaries; a mask sharing output storage is copied.
@@ -138,6 +145,8 @@ length target. `ScaledUnit` factors participate in the conversion. Every numeric
138
145
  result, including a coherent or scaled dimensionless result, requires a Quantity
139
146
  target; writing it to a bare ndarray would discard its unit. Boolean predicates
140
147
  and comparisons instead accept only a bare boolean ndarray output.
148
+ `argmin` and `argmax` accept only a bare integer ndarray output, with the
149
+ same exact shape requirement as other function outputs.
141
150
 
142
151
  Final casting uses `casting='same_kind'` by default, or the explicitly requested
143
152
  NumPy casting policy. It does not silently enable `unsafe`; nonintegral unit
@@ -148,8 +157,24 @@ The final copy is not a general transaction against concurrent changes or
148
157
  external buffer failures. Temporaries cost memory proportional to the result
149
158
  and target; `out` provides controlled writes, not a zero-allocation guarantee.
150
159
 
151
- `out=None` and `out=(None,)` allocate ordinary results. Multiple-output ufuncs,
152
- ufunc reductions with an output, and function outputs remain unsupported.
160
+ Functions and add.reduce/accumulate use `same_kind` for the final cast and do
161
+ not accept a new `casting` keyword. Concatenate and stack use their existing
162
+ `casting` option for the final write as well as the kernel; their `dtype` and
163
+ `out` options cannot be combined. Index outputs also use `same_kind`, including
164
+ its allowance for integer narrowing. These policies intentionally do not
165
+ reproduce every function's native output casting rule. An explicit `dtype`
166
+ controls the temporary numerical computation for supported reductions; the
167
+ target dtype does not select the accumulator or kernel dtype.
168
+
169
+ ```python
170
+ totals = Quantity(np.empty(3), 'cm')
171
+ assert np.sum(q, axis=0, out=totals) is totals
172
+ indices = np.empty(3, dtype=np.intp)
173
+ assert np.argmax(q, axis=0, out=indices) is indices
174
+ ```
175
+
176
+ `out=None` and ufunc `out=(None,)` allocate ordinary results.
177
+ Multiple-output ufuncs and product reductions remain unsupported.
153
178
  Output writes preserve the magnitude's type and dtype, so the covariant
154
179
  `Quantity[T]` contract remains valid; no setter or arbitrary replacement of T
155
180
  is introduced. NumPy's own stubs do not accept Quantity outputs or infer wrapped
@@ -4,7 +4,7 @@ build-backend = 'hatchling.build'
4
4
 
5
5
  [project]
6
6
  name = 'unitx'
7
- version = '0.8.2'
7
+ version = '0.8.3'
8
8
  description = 'A modern, intuitive Python library for units, quantities, and dimensional analysis.'
9
9
  readme = 'README.md'
10
10
  requires-python = '>=3.12'
@@ -3,7 +3,7 @@
3
3
  An identity scale retains storage. Integral scales use a Python int multiplier;
4
4
  nonintegral rational scales use one Python float multiplier, and irrational
5
5
  scales use Factor's approximation. NumPy then controls promotion and errors.
6
- No Fraction/Decimal object arrays are produced. Explicit ufunc outputs write
6
+ No Fraction/Decimal object arrays are produced. Explicit outputs write
7
7
  only after computing, converting, and casting a temporary result.
8
8
  """
9
9
 
@@ -96,7 +96,11 @@ def _validate_where(kwargs: dict[str, Any]) -> None:
96
96
  def dispatch_ufunc(ufunc: Any, method: str, inputs: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
97
97
  outputs = kwargs.get('out', ())
98
98
  if any(x is not None for x in outputs):
99
- if method != '__call__' or ufunc.nout != 1 or len(outputs) != 1:
99
+ if (
100
+ method not in ('__call__', 'reduce', 'accumulate')
101
+ or ufunc.nout != 1
102
+ or len(outputs) != 1
103
+ ):
100
104
  return NotImplemented
101
105
  target = outputs[0]
102
106
  predicates = (
@@ -120,33 +124,56 @@ def dispatch_ufunc(ufunc: Any, method: str, inputs: tuple[Any, ...], kwargs: dic
120
124
  result = _dispatch_ufunc(ufunc, method, inputs, options)
121
125
  if result is NotImplemented:
122
126
  return NotImplemented
123
- if isinstance(target, Quantity) and type(target.value) is not np.ndarray:
124
- raise TypeError('Quantity out requires a plain numeric ndarray buffer.')
125
- if not (target.value if isinstance(target, Quantity) else target).flags.writeable:
126
- raise ValueError('out buffer is read-only.')
127
- return _write_ufunc_output(result, target, options)
127
+ return _write_output(
128
+ result,
129
+ target,
130
+ where=options.get('where', True),
131
+ casting=options.get('casting', 'same_kind'),
132
+ broadcast=method == '__call__',
133
+ )
128
134
  return _dispatch_ufunc(ufunc, method, inputs, kwargs)
129
135
 
130
136
 
131
- def _write_ufunc_output(result: Any, target: Any, options: dict[str, Any]) -> Any:
137
+ def _write_output(
138
+ result: Any,
139
+ target: Any,
140
+ *,
141
+ where: Any = True,
142
+ casting: Any = 'same_kind',
143
+ broadcast: bool = False,
144
+ ) -> Any:
132
145
  """Stage a single output without replacing its magnitude, dtype, or unit."""
133
- mask = options.get('where', True)
146
+ if target is None:
147
+ return result
134
148
  if isinstance(result, Quantity):
149
+ if not isinstance(target, Quantity) or type(target.value) is not np.ndarray:
150
+ raise TypeError('Quantity out requires a plain numeric ndarray buffer.')
135
151
  if result.dimension != target.dimension:
136
152
  raise DimensionError('out has an incompatible dimension.')
137
- value = scale_magnitude(result.value, result.unit.factor / target.unit.factor, where=mask)
153
+ factor = result.unit.factor / target.unit.factor
138
154
  buffer = target.value
139
155
  else:
140
- value, buffer = result, target
156
+ kinds = 'b' if np.asarray(result).dtype.kind == 'b' else 'iu'
157
+ if type(target) is not np.ndarray or target.dtype.kind not in kinds:
158
+ raise TypeError('Unitless out requires a plain ndarray with the matching result kind.')
159
+ factor = ONE
160
+ buffer = target
161
+ if not buffer.flags.writeable:
162
+ raise ValueError('out buffer is read-only.')
163
+ value = result.value if isinstance(result, Quantity) else result
164
+ if not broadcast and np.shape(value) != buffer.shape:
165
+ raise ValueError('out must have exactly the result shape.')
166
+ if isinstance(result, Quantity):
167
+ value = scale_magnitude(value, factor, where=where)
141
168
  # Validate broadcasting and casting before touching shared storage. Casting
142
169
  # also runs in temporary storage so e.g. float narrowing errors cannot
143
170
  # partially overwrite the caller's buffer.
144
171
  value = np.broadcast_to(value, buffer.shape)
145
- mask = np.broadcast_to(mask, buffer.shape)
172
+ mask = np.broadcast_to(where, buffer.shape)
146
173
  if np.may_share_memory(mask, buffer):
147
174
  mask = mask.copy()
148
175
  staged = np.empty(buffer.shape, dtype=buffer.dtype)
149
- np.copyto(staged, value, where=mask, casting=options.get('casting', 'same_kind'))
176
+ np.copyto(staged, value, where=mask, casting=casting)
150
177
  np.copyto(buffer, staged, where=mask, casting='no')
151
178
  return target
152
179
 
@@ -306,11 +333,6 @@ def isclose(
306
333
  return result
307
334
 
308
335
 
309
- def _reject_out(out: Any) -> None:
310
- if out is not None:
311
- raise TypeError('Writable out is not supported for Quantity.')
312
-
313
-
314
336
  def dispatch_function(
315
337
  func: Any, types: tuple[type, ...], args: tuple[Any, ...], kwargs: dict[str, Any]
316
338
  ) -> Any:
@@ -364,7 +386,8 @@ def _join_arrays(
364
386
  dtype: Any = None,
365
387
  casting: str = 'same_kind',
366
388
  ) -> Any:
367
- _reject_out(out)
389
+ if out is not None and dtype is not None:
390
+ raise TypeError('Cannot provide both dtype and out for concatenate/stack.')
368
391
  arrays = tuple(arrays)
369
392
  if not arrays or not all(_is_supported_operand(x) for x in arrays):
370
393
  return NotImplemented
@@ -379,7 +402,8 @@ def _join_arrays(
379
402
  values.append(scale_magnitude(raw, unit.factor / first.unit.factor))
380
403
  if dtype is not None and np.dtype(dtype).kind not in 'iufc':
381
404
  raise TypeError('Only numeric output dtypes are supported.')
382
- return Quantity(func(values, axis=axis, dtype=dtype, casting=casting), first.unit)
405
+ result = Quantity(func(values, axis=axis, dtype=dtype, casting=casting), first.unit)
406
+ return _write_output(result, out, casting=casting)
383
407
 
384
408
 
385
409
  def _reshape(q: Any, shape: Any, order: Literal['C', 'F', 'A'] = 'C', *, copy: Any = None) -> Any:
@@ -393,31 +417,28 @@ def _transpose(q: Any, axes: Any = None) -> Any:
393
417
  def _argmin_max(
394
418
  func: Any, q: Any, axis: Any = None, out: Any = None, *, keepdims: bool = False
395
419
  ) -> Any:
396
- _reject_out(out)
397
- return func(q.value, axis=axis, keepdims=keepdims)
420
+ return _write_output(func(q.value, axis=axis, keepdims=keepdims), out)
398
421
 
399
422
 
400
423
  def _median(
401
424
  q: Any, axis: Any = None, out: Any = None, overwrite_input: bool = False, keepdims: bool = False
402
425
  ) -> Any:
403
- _reject_out(out)
404
426
  if overwrite_input:
405
427
  raise TypeError('overwrite_input is not supported for Quantity.')
406
- return Quantity(np.median(q.value, axis=axis, keepdims=keepdims), q.unit)
428
+ return _write_output(Quantity(np.median(q.value, axis=axis, keepdims=keepdims), q.unit), out)
407
429
 
408
430
 
409
431
  def _cumsum(q: Any, axis: Any = None, dtype: Any = None, out: Any = None) -> Any:
410
- _reject_out(out)
411
432
  _validate_dtype(dtype)
412
- return Quantity(np.cumsum(q.value, axis=axis, dtype=dtype), q.unit)
433
+ return _write_output(Quantity(np.cumsum(q.value, axis=axis, dtype=dtype), q.unit), out)
413
434
 
414
435
 
415
436
  def _sum_mean(
416
437
  func: Any, q: Any, axis: Any = None, dtype: Any = None, out: Any = None, keepdims: bool = False
417
438
  ) -> Any:
418
- _reject_out(out)
419
439
  _validate_dtype(dtype)
420
- return Quantity(func(q.value, axis=axis, dtype=dtype, keepdims=keepdims), q.unit)
440
+ result = Quantity(func(q.value, axis=axis, dtype=dtype, keepdims=keepdims), q.unit)
441
+ return _write_output(result, out)
421
442
 
422
443
 
423
444
  def _std_var(
@@ -429,10 +450,10 @@ def _std_var(
429
450
  ddof: Any = 0,
430
451
  keepdims: bool = False,
431
452
  ) -> Any:
432
- _reject_out(out)
433
453
  _validate_dtype(dtype)
434
454
  unit = q.unit**2 if func is np.var else q.unit
435
- return Quantity(func(q.value, axis=axis, dtype=dtype, ddof=ddof, keepdims=keepdims), unit)
455
+ result = Quantity(func(q.value, axis=axis, dtype=dtype, ddof=ddof, keepdims=keepdims), unit)
456
+ return _write_output(result, out)
436
457
 
437
458
 
438
459
  def _validate_dtype(dtype: Any) -> None:
@@ -78,6 +78,12 @@ def output_contracts(a: npt.NDArray[np.float64]) -> None:
78
78
  # never the magnitude object or dtype described by the narrower type.
79
79
  # NumPy's own out stubs accept ndarray only; check the protocol entry here.
80
80
  q.__array_ufunc__(np.add, '__call__', q, q, out=(widened,))
81
+ q.__array_ufunc__(np.add, 'accumulate', q, out=(widened,))
82
+ q.__array_function__(np.cumsum, (Quantity,), (q,), {'out': widened})
83
+ scalar_target = Quantity(np.empty(()), 'cm')
84
+ q.__array_ufunc__(np.add, 'reduce', q, out=(scalar_target,))
85
+ q.__array_function__(np.sum, (Quantity,), (q,), {'out': scalar_target})
86
+ assert_type(scalar_target.value.dtype, np.dtype[np.float64])
81
87
  assert_type(widened.value, Array)
82
88
  assert_type(q.value, npt.NDArray[np.float64])
83
89
  assert_type(q * Unit('s'), Quantity[npt.NDArray[np.float64]])
@@ -427,13 +427,14 @@ def test_unsupported_outputs_do_not_mutate():
427
427
  target = Quantity(buffer, 'm')
428
428
  with pytest.raises(TypeError):
429
429
  np.add(q, q, out=buffer)
430
- for out in (buffer, target):
431
- with pytest.raises(TypeError):
432
- np.sum(q, out=out)
433
- with pytest.raises(TypeError):
434
- np.sum(q, None, None, out)
435
- with pytest.raises(TypeError):
436
- np.concatenate((q, q), out=out)
430
+ with pytest.raises(TypeError):
431
+ np.sum(q, out=buffer)
432
+ with pytest.raises(ValueError, match='result shape'):
433
+ np.sum(q, None, None, target)
434
+ with pytest.raises(TypeError):
435
+ np.concatenate((q, q), out=buffer)
436
+ with pytest.raises(ValueError, match='result shape'):
437
+ np.concatenate((q, q), out=target)
437
438
  np.testing.assert_array_equal(buffer, [-123] * 3)
438
439
  result = np.add(q, q, where=np.array([True, False, True]))
439
440
  np.testing.assert_array_equal(result.value[[0, 2]], [0, 4])
@@ -642,7 +643,7 @@ def test_ufunc_out_protocol():
642
643
 
643
644
  assert np.add(q, q, out=Foreign()) == 'foreign dispatched'
644
645
  target = Quantity(np.full(3, -123.0), 'm')
645
- for operation in (np.add.reduce, np.multiply.accumulate, np.modf):
646
+ for operation in (np.multiply.reduce, np.multiply.accumulate, np.modf):
646
647
  with pytest.raises(TypeError):
647
648
  operation(q, out=(target, None)) if operation is np.modf else operation(q, out=target)
648
649
  np.testing.assert_array_equal(target.value, [-123] * 3)
@@ -0,0 +1,229 @@
1
+ """Unit-aware function and reduction outputs, including shared storage."""
2
+
3
+ import pytest
4
+
5
+ np = pytest.importorskip('numpy')
6
+
7
+ from unitx import Quantity, Unit # noqa: E402
8
+ from unitx.exceptions import DimensionError # noqa: E402
9
+ from unitx.factor import Factor # noqa: E402
10
+
11
+
12
+ @pytest.mark.parametrize(
13
+ 'operation',
14
+ [np.sum, np.mean, np.median, np.std, np.var, np.cumsum, np.add.reduce, np.add.accumulate],
15
+ )
16
+ def test_numpy_function_out_conversion(operation):
17
+ unit = Factor(2) * Unit('m')
18
+ q = Quantity(np.arange(6.0).reshape(2, 3), unit)
19
+ natural_unit = unit**2 if operation is np.var else unit
20
+ target_unit = Unit('cm2' if operation is np.var else 'cm')
21
+ expected = Quantity(operation(q.value, axis=0), natural_unit).value_in(target_unit)
22
+ target = Quantity(np.full(np.shape(expected), -123.0), target_unit)
23
+ buffer = target.value
24
+ assert operation(q, axis=0, out=target) is target
25
+ assert target.value is buffer and target.unit is target_unit
26
+ np.testing.assert_allclose(buffer, expected)
27
+ np.testing.assert_array_equal(q.value, np.arange(6.0).reshape(2, 3))
28
+ ordinary = operation(q, axis=0, out=None)
29
+ assert ordinary.unit == natural_unit
30
+ np.testing.assert_allclose(ordinary.value, operation(q.value, axis=0))
31
+
32
+
33
+ @pytest.mark.parametrize('operation', [np.sum, np.mean, np.median, np.std, np.var, np.add.reduce])
34
+ def test_numpy_reduction_out_axes(operation):
35
+ q = Quantity(np.arange(24.0).reshape(2, 3, 4), 'm')
36
+ unit = 'm2' if operation is np.var else 'm'
37
+ expected = operation(q.value, axis=(0, -1), keepdims=True)
38
+ target = Quantity(np.empty((1, 3, 1)), unit)
39
+ assert operation(q, axis=(0, -1), keepdims=True, out=target) is target
40
+ np.testing.assert_allclose(target.value, expected)
41
+
42
+
43
+ def test_numpy_function_out_positional():
44
+ q = Quantity(np.array([1.0, 2.0, 3.0]), 'm')
45
+ target = Quantity(np.array(-123.0), 'cm')
46
+ for operation in (np.sum, np.mean, np.std, np.var):
47
+ actual = Quantity(target.value, 'cm2') if operation is np.var else target
48
+ assert operation(q, None, None, actual) is actual
49
+ assert np.median(q, None, target) is target
50
+ vector = Quantity(np.empty(3), 'cm')
51
+ assert np.cumsum(q, None, None, vector) is vector
52
+ assert np.add.reduce(q, None, None, target) is target
53
+ assert np.add.reduce(q, out=(target,)) is target
54
+ assert np.add.accumulate(q, out=(vector,)) is vector
55
+ assert np.add.reduce(q, out=(None,)).value == 6
56
+ index = np.array(-1, dtype=np.intp)
57
+ assert np.argmax(q, None, index) is index
58
+ assert index == 2
59
+
60
+
61
+ @pytest.mark.parametrize('operation', [np.concatenate, np.stack])
62
+ def test_numpy_join_out_conversion(operation):
63
+ q = Quantity(np.arange(3.0), 'm')
64
+ other = Quantity(np.arange(3.0) * 100, 'cm')
65
+ expected = operation((q.value, q.value)) * 1000
66
+ storage = np.full((*expected.shape, 2), -123.0)
67
+ target = Quantity(storage[..., 0], 'mm')
68
+ assert operation((q, other), out=target) is target
69
+ np.testing.assert_array_equal(target.value, expected)
70
+ np.testing.assert_array_equal(storage[..., 1], -123)
71
+ assert operation((q, other), 0, target) is target
72
+
73
+
74
+ def test_numpy_join_out_alias():
75
+ storage = np.arange(6.0)
76
+ q = Quantity(storage, 'm')
77
+ assert np.concatenate((q[3:], q[:3]), out=q) is q
78
+ np.testing.assert_array_equal(storage, [3, 4, 5, 0, 1, 2])
79
+
80
+
81
+ @pytest.mark.parametrize('operation', [np.argmin, np.argmax])
82
+ def test_numpy_index_out(operation):
83
+ q = Quantity(np.array([[3, 1, 4], [2, 7, 0]]), 'm')
84
+ storage = np.full((3, 2), -123, dtype=np.intp)
85
+ target = storage[:, 0]
86
+ assert operation(q, axis=0, out=target) is target
87
+ np.testing.assert_array_equal(target, operation(q.value, axis=0))
88
+ np.testing.assert_array_equal(storage[:, 1], -123)
89
+ kept = np.empty((1, 3), dtype=np.intp)
90
+ assert operation(q, axis=0, out=kept, keepdims=True) is kept
91
+ np.testing.assert_array_equal(kept, operation(q.value, axis=0, keepdims=True))
92
+
93
+
94
+ def test_numpy_reduction_out_storage():
95
+ storage = np.arange(6.0).reshape(2, 3)
96
+ q = Quantity(storage, 'm')
97
+ target = Quantity(storage[0], 'cm')
98
+ assert np.sum(q, axis=0, out=target) is target
99
+ np.testing.assert_array_equal(storage, [[300, 500, 700], [3, 4, 5]])
100
+ assert np.cumsum(q, axis=1, out=q) is q
101
+ np.testing.assert_array_equal(storage, [[300, 800, 1500], [3, 7, 12]])
102
+ assert np.add.accumulate(q, axis=0, out=q) is q
103
+ np.testing.assert_array_equal(storage, [[300, 800, 1500], [303, 807, 1512]])
104
+ q.value.flags.writeable = False
105
+ separate = Quantity(np.empty(3), 'm')
106
+ assert np.sum(q, axis=0, out=separate) is separate
107
+ np.testing.assert_array_equal(separate.value, [603, 1607, 3012])
108
+
109
+
110
+ def test_numpy_function_out_empty():
111
+ q = Quantity(np.empty((0, 3)), 'm')
112
+ target = Quantity(np.full(3, -123.0), 'cm')
113
+ assert np.sum(q, axis=0, out=target) is target
114
+ np.testing.assert_array_equal(target.value, 0)
115
+ empty = Quantity(np.empty((0, 3)), 'cm')
116
+ assert np.cumsum(q, axis=0, out=empty) is empty
117
+ assert np.add.accumulate(q, axis=0, out=empty) is empty
118
+ scalar = Quantity(np.array(2.0), 'km/m')
119
+ coherent = Quantity(np.array(-123.0), '')
120
+ assert np.sum(scalar, out=coherent) is coherent
121
+ assert coherent.value == 2000
122
+
123
+
124
+ @pytest.mark.parametrize('operation', [np.sum, np.add.reduce, np.cumsum, np.add.accumulate])
125
+ def test_numpy_function_out_invalid_dimension(operation):
126
+ q = Quantity(np.arange(3.0), 'm')
127
+ target = Quantity(np.full(np.shape(operation(q.value)), -123.0), 's')
128
+ with pytest.raises(DimensionError, match='out has an incompatible dimension'):
129
+ operation(q, out=target)
130
+ np.testing.assert_array_equal(target.value, -123)
131
+
132
+
133
+ @pytest.mark.parametrize('shape', [(1,), (2, 3), (0,)])
134
+ def test_numpy_reduction_out_invalid_shape(shape):
135
+ q = Quantity(np.arange(3.0), 'm')
136
+ for operation in (np.sum, np.add.reduce, np.argmax):
137
+ buffer = np.full(shape, -123, dtype=np.intp)
138
+ out = buffer if operation is np.argmax else Quantity(buffer, 'm')
139
+ with pytest.raises(ValueError, match='result shape'):
140
+ operation(q, out=out)
141
+ np.testing.assert_array_equal(buffer, -123)
142
+
143
+
144
+ def test_numpy_function_out_invalid_buffer():
145
+ q = Quantity(np.arange(3.0), 'm')
146
+ for target in (np.array(-123.0), Quantity(1, 'm'), Quantity(np.float64(1), 'm')):
147
+ with pytest.raises(TypeError, match='plain numeric ndarray'):
148
+ np.sum(q, out=target)
149
+ target = Quantity(np.array(-123.0), 'm')
150
+ target.value.flags.writeable = False
151
+ with pytest.raises(ValueError, match='read-only'):
152
+ np.sum(q, out=target)
153
+ assert target.value == -123
154
+
155
+
156
+ @pytest.mark.parametrize(
157
+ 'target', [np.array(-123.0), np.array(False), Quantity(np.array(-123), '')]
158
+ )
159
+ def test_numpy_index_out_invalid_buffer(target):
160
+ q = Quantity(np.arange(3.0), 'm')
161
+ with pytest.raises(TypeError, match='Unitless out'):
162
+ np.argmax(q, out=target)
163
+ assert (target.value if isinstance(target, Quantity) else target) == (
164
+ False if isinstance(target, np.ndarray) and target.dtype.kind == 'b' else -123
165
+ )
166
+
167
+
168
+ def test_numpy_function_out_invalid_cast():
169
+ q = Quantity(np.array([1.0, 2.5]), 'cm')
170
+ target = Quantity(np.array(-123), 'm')
171
+ for operation in (np.sum, np.mean, np.median, np.add.reduce):
172
+ with pytest.raises(TypeError):
173
+ operation(q, out=target)
174
+ assert target.value == -123
175
+ narrow = Quantity(np.array(-123, dtype=np.float32), 'm')
176
+ assert np.sum(q, dtype=np.float64, out=narrow) is narrow
177
+ assert narrow.value.dtype == np.dtype(np.float32)
178
+ np.testing.assert_allclose(narrow.value, 0.035)
179
+ with np.errstate(over='raise'):
180
+ with pytest.raises(FloatingPointError):
181
+ np.sum(Quantity(np.array([1e100]), 'm'), out=narrow)
182
+ np.testing.assert_allclose(narrow.value, 0.035)
183
+
184
+
185
+ def test_numpy_join_out_invalid_options():
186
+ q = Quantity(np.array([1.0]), 'cm')
187
+ target = Quantity(np.full(2, -123), 'm')
188
+ with pytest.raises(TypeError):
189
+ np.concatenate((q, q), out=target)
190
+ np.testing.assert_array_equal(target.value, -123)
191
+ assert np.concatenate((q, q), out=target, casting='unsafe') is target
192
+ np.testing.assert_array_equal(target.value, 0)
193
+ for operation in (np.concatenate, np.stack):
194
+ with pytest.raises(TypeError, match='both dtype and out'):
195
+ operation((q, q), out=target, dtype=float)
196
+ with pytest.raises(DimensionError):
197
+ np.concatenate((q, Quantity(np.array([1.0]), 's')), out=target)
198
+ np.testing.assert_array_equal(target.value, 0)
199
+
200
+
201
+ def test_numpy_function_out_invalid_kernel():
202
+ q = Quantity(np.array([np.inf, np.inf]), 'm')
203
+ target = Quantity(np.array(-123.0), 'm2')
204
+ with np.errstate(invalid='raise'):
205
+ with pytest.raises(FloatingPointError):
206
+ np.var(q, out=target)
207
+ assert target.value == -123
208
+ with pytest.raises(TypeError):
209
+ np.median(q, out=target, overwrite_input=True)
210
+ assert target.value == -123
211
+
212
+
213
+ def test_numpy_reduction_out_protocol():
214
+ q = Quantity(np.arange(3.0), 'm')
215
+
216
+ class Foreign:
217
+ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
218
+ return 'foreign dispatched'
219
+
220
+ def __array_function__(self, func, types, args, kwargs):
221
+ return 'foreign dispatched'
222
+
223
+ assert np.add.reduce(q, out=Foreign()) == 'foreign dispatched'
224
+ assert np.sum(q, out=Foreign()) == 'foreign dispatched'
225
+ target = Quantity(np.array(-123.0), 'm')
226
+ for options in ({'where': True}, {'initial': 1}, {'casting': 'unsafe'}):
227
+ with pytest.raises(TypeError):
228
+ np.add.reduce(q, out=target, **options)
229
+ assert target.value == -123
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