Build Combiner data and mask arrays with xp.stack - #976
Conversation
array-api-strict rejects a nested list of arrays passed to xp.asarray
("Nested Arrays are not allowed. Use `stack` instead."), which made every
Combiner construction fail under the strict backend. Stack the images and
masks instead, coercing each mask into the data's namespace and onto the
data's device (astropy's CCDData keeps masks as numpy arrays even when the
data is not), and use xp.bool rather than the Python bool for mask dtypes.
Also put scaling arrays on the device of the data, build the broadcasting
shape of the scaling array with a single xp.reshape, and index individual
images with a trailing ellipsis, all of which array-api-strict requires.
Fixes astropy#965
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTN9DnPnLKK2u7knnMJ2gA
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #976 +/- ##
==========================================
+ Coverage 95.83% 95.90% +0.07%
==========================================
Files 8 8
Lines 1631 1637 +6
==========================================
+ Hits 1563 1570 +7
+ Misses 68 67 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
pre-commit.ci has been failing on main since the black 26.5.1 bump in astropy#919 because black now requires a blank line after the module docstring in ccdproc/__init__.py. This is the only formatting change it wants. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTN9DnPnLKK2u7knnMJ2gA
There was a problem hiding this comment.
Pull request overview
This PR updates Combiner construction and related scaling/mask handling to be compatible with strict Array API backends (notably array-api-strict) by avoiding nested-array coercions and keeping derived arrays on the input data’s namespace/device.
Changes:
- Build
Combiner’s internal data/mask stacks usingxp.stack(and per-mask coercion) instead ofxp.asarrayon nested sequences. - Ensure scaling arrays are created/reshaped in a strict-backend-friendly way (device-aware, broadcastable shape, strict indexing).
- Add a regression test covering mixed mask sources (native namespace mask, no mask, numpy mask) on the testing device.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| CHANGES.rst | Documents the Combiner stacking/device behavior fix. |
| ccdproc/tests/test_combiner.py | Adds a regression test for stacking + device/namespace preservation and scaling device placement. |
| ccdproc/combiner.py | Switches internal array construction to xp.stack, fixes bool dtype usage, and makes scaling device-aware and strict-indexing compatible. |
| ccdproc/init.py | Adds a blank line after the module docstring (formatting-only). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review follow-up: taking the device from ccd_list[0].data regardless of the requested namespace handed a foreign device object (numpy's 'cpu') to xp.asarray when the caller passed an explicit xp, which jax and array-api-strict reject. Use the data's device only when its namespace is xp; otherwise let xp place the data on its default device. Also build the stack directly in the target dtype instead of casting the whole stack afterwards, which avoided nothing and cost a full copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTN9DnPnLKK2u7knnMJ2gA
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
ccdproc/combiner.py:286
- In the callable-scaling branch,
self._scalingbecomes a Python list of per-image results and is then passed toxp.asarray(...). If the scaling function returns backend scalars/0-D arrays (common for array-API code, e.g.xp.mean(image)), array-api-strict will reject this as a nested sequence of Arrays (same failure mode as #965). Building the 1-D scaling array viaxp.stackoverxp.asarray-coerced scalars avoids nested-array rejection and still keeps the result on the chosen device.
This issue also appears on line 1126 of the same file.
if callable(value):
self._scaling = [value(self._data_arr[i, ...]) for i in range(n_images)]
self._scaling = xp.asarray(self._scaling, device=device)
ccdproc/combiner.py:1128
- When
scaleis callable,scalevaluesis built fromscale(imgccd.data)and then converted withxp.asarray(scalevalues, ...). Ifscale(...)returns backend scalars/0-D arrays, this again becomes a nested sequence of Arrays and will be rejected by array-api-strict. Stacking per-elementxp.asarrayresults avoids nested-array errors while preserving the intended device placement.
to_set_in_combiner["scaling"] = xp.asarray(
scalevalues, device=array_api_compat.device(ccd.data)
)
A scaling callable that returns a 0-d backend array (e.g. xp.mean(img)) produced a list of arrays that xp.asarray rejects on array-api-strict, the same nested-array failure this branch fixes for the data and mask stacks. Stack per-element asarray conversions instead, which also keeps working when the callable returns a Python float. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTN9DnPnLKK2u7knnMJ2gA
|
Re Copilot's two suppressed comments ( — Written by Claude at @mwcraig's direction. |
mwcraig
left a comment
There was a problem hiding this comment.
Critical review of the diff (numpy-regression, array-API-semantics, and test-coverage passes, every claim re-run locally).
Verdict: sound. numpy/dask behaviour is bit-identical to main across ~70 combinations (int/float/mixed dtypes, numpy/int/float/MaskedArray masks, missing masks, scaling as list/tuple/ndarray/0-d/callable, 1-D and 4-D images, combine() with mem_limit chunking × scale × method). Peak memory is lower than main (10×1000² float64→float64: 170 MB → 100 MB, since asarray(list) built an intermediate). The device-pinning commit holds on strict (device1) and jax for every mixed-device / mixed-namespace ordering tried. jax suite has the same 6 pre-existing XPASSes as main; all 62 remaining strict failures in test_combiner.py are outside this diff (30 bottleneck-missing, 6 dtype=bool in the test wrapper, .nbytes, fancy-index __setitem__, …); no nested-asarray remains anywhere.
Inline: two small strict-only bugs the PR leaves reachable in the scaling setter (int scaling; backend array as scaling), one policy question about raw modules as xp, one CHANGES note, and two honesty notes on what the new tests actually guard.
— Written by Claude at @mwcraig's direction.
| # rejects as a nested sequence of arrays. | ||
| self._scaling = xp.stack( | ||
| [ | ||
| xp.asarray(value(self._data_arr[i, ...]), device=device) |
There was a problem hiding this comment.
Integer scaling breaks on array-api-strict. The setter never casts to the data dtype, so scaling=[1, 2], or a callable returning a Python int, yields an int64 array and self._data_arr * self.scaling (line 491) raises
TypeError: array_api_strict.float64 and array_api_strict.int64 cannot be type promoted together
numpy/dask/jax promote silently so nobody sees it today, but it is exactly the class of bug the strict job exists for, and this PR is what makes the scaling path reachable there. One token fixes it here and on line 308:
xp.asarray(value(self._data_arr[i, ...]), dtype=self._data_arr.dtype, device=device)That also covers a callable that returns an int for some images and a float for others — xp.stack of mixed 0-d dtypes fails on strict too. Suggest adding c.scaling = [1, 2] and c.scaling = lambda arr: 1 to test_combiner_stacks_arrays_on_input_device.
— Written by Claude at @mwcraig's direction.
There was a problem hiding this comment.
Fixed in 1570989: both the callable and array branches now pass dtype=self._data_arr.dtype. Added c.scaling = [1, 2, 3] and c.scaling = lambda arr: arr.shape[0] to test_combiner_stacks_arrays_on_input_device with dtype assertions; passes on array-api-strict.
— Written by Claude at @mwcraig's direction.
| ) | ||
| else: | ||
| try: | ||
| len(value) |
There was a problem hiding this comment.
A backend array as scaling gives a misleading error on strict. Standard arrays need not implement __len__ (array-api-strict's doesn't), so
c.scaling = xp.asarray([1.0, 0.5], device=device)is caught by this try/except and re-raised as "scaling must be a function or an array the same length as the number of images" even though the length is right. Pre-existing, but newly reachable. value.shape[0] (falling back to len() for lists/tuples) is standard-compliant.
— Written by Claude at @mwcraig's direction.
| data_xp = array_api_compat.array_namespace(ccd_list[0].data) | ||
| device = array_api_compat.device(ccd_list[0].data) if data_xp is xp else None | ||
| self._data_arr = xp.stack( | ||
| [xp.asarray(ccd.data, dtype=dtype, device=device) for ccd in ccd_list] |
There was a problem hiding this comment.
Behaviour change for a raw module passed as xp. Combiner(ccds, xp=dask.array) constructed fine on main; now da.asarray(..., device=...) raises TypeError: from_array() got an unexpected keyword argument 'device'. Same for raw numpy 1.26 (xp.bool, device=).
Mitigating: only the bare modules are affected (the array_api_compat wrappers work, combine() already normalises array_package), and on main the same raw-dask object died one call later in clip_extrema (module 'dask.array' has no attribute 'argsort'), while core.py on main already passes device= in five places. So this was never really supported — but it's worth deciding as policy. Cheapest hardening, mirroring what combine() does:
xp = array_api_compat.array_namespace(xp.asarray(0)) if xp is not None else ...or just say in the docstring that xp must be an array-API-compatible namespace.
— Written by Claude at @mwcraig's direction.
There was a problem hiding this comment.
| # reshape so that broadcasting occurs properly | ||
| for _ in range(len(self._data_arr.shape) - 1): | ||
| self._scaling = self.scaling[:, xp.newaxis] | ||
| self._scaling = xp.reshape( |
There was a problem hiding this comment.
Minor, worth a line in CHANGES: this replaces main's "append one axis per data dim" loop, so a (3, 1) scaling array or a callable returning a 1-element array now yields (3, 1, 1) instead of (3, 1, 1, 1), and average_combine() on (3, 3) images returns (3, 3) instead of (3, 3, 3). That is a fix, but it is user-visible.
— Written by Claude at @mwcraig's direction.
| "does not provide", | ||
| ) | ||
| def test_combine_scale_callable_returning_backend_scalar(): | ||
| # Regression test for the review of #976: ``combine(scale=<callable>)`` |
There was a problem hiding this comment.
Honesty note on "regression test": this currently cannot regress on any backend. On numpy/dask the pre-fix xp.asarray(scalevalues) already passed; on strict, combine() dies earlier at ccd.data.nbytes (combiner.py:829), which is why it is xfailed. The xfail reason is accurate, but until .nbytes is dealt with, the combine() half of the scaling fix (lines 1134–1139) is exercised by nothing. Suggest rewording the comment to say so.
— Written by Claude at @mwcraig's direction.
|
|
||
|
|
||
| def test_combiner_explicit_namespace_differs_from_data(): | ||
| # Regression test for the review of #976: when the caller passes an ``xp`` |
There was a problem hiding this comment.
This test guards the device logic only on array-api-strict. Traced per backend: on numpy, array_namespace(np_arr) is xp is true so the "different namespace" branch never runs; on dask, an unconditional da.asarray(np_arr, device='cpu') is accepted anyway so the old bug is undetectable; on strict it does catch it (ValueError: Unsupported device 'cpu') — but on main-strict it fails at the unrelated dtype=bool mask line first. Not wrong, just weaker than the comment suggests, and the strict job is currently continue-on-error. Worth either noting in the comment or adding an assertion on the device that fails on numpy too (e.g. pass xp=array_api_strict explicitly in this test).
— Written by Claude at @mwcraig's direction.
- Cast Combiner.scaling (list, array, or callable result) to the dtype of the data so integer scaling does not need int/float type promotion, which array-api-strict refuses. - Use value.shape[0] (falling back to len()) to check the scaling length, so a backend array without __len__ is accepted. - Normalise an explicit xp to its array-api-compat namespace so a raw module such as numpy works with xp.bool and device=. - Note the scaling-shape behaviour change in CHANGES and clarify what the new tests actually guard on each backend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTN9DnPnLKK2u7knnMJ2gA
array-api-strict rejects a nested list of arrays passed to
xp.asarray, so everyCombinerconstruction failed under the strict backend (50 of the 75test_combiner.pyfailures). This PR:_data_arrwithxp.stack([...])+xp.astype(..., dtype);_data_arr_maskby coercing each mask withxp.asarray(mask, dtype=xp.bool, device=<data device>)(astropy keepsCCDData.maskas numpy even when the data is not) and adtype=xp.bool, device=...placeholder for missing masks, thenxp.stack;dtype=boolwithdtype=xp.bool;xp.reshape, and indexes individual images with a trailing ellipsis (strict rejects both repeated[:, newaxis]on an N-D array and flatarr[i]indexing);Combinerfrom data on the testing device with a native-namespace mask, no mask, and a numpy mask, and checks the namespace, device, dtype, and values of.data,.mask, and.scaling.Test matrix (local,
ccdp-dev-py312):CCDPROC_ENFORCE_ESCAPE_BASELINE=1test_combiner.pyThe remaining strict failures in
test_combiner.pyare unrelated to this issue: 35No NaN-aware ... function available(bottleneck fallbacks), 6dtype=boolin the tests themselves, 6IrreducibleUnitpassed toxp.asarray, 5numpy.ndarray has no attribute _array, 3 flat-indexingIndexErrors in test code, 3Fancy indexing __setitem__, and a handful of others.Fixes #965
🤖 Generated with Claude Code
https://claude.ai/code/session_01DTN9DnPnLKK2u7knnMJ2gA