Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/887.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add new method, `ndcube.NDCube.to_nddata`, which allows easy conversion of an `~ndcube.NDCube` to a subclass of `~astropy.nddata.NDData`. Attribute values can be altered during the conversion by supplying the new values via kwargs.
98 changes: 98 additions & 0 deletions ndcube/ndcube.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
except ImportError:
pass

COPY = object()


class NDCubeABC(astropy.nddata.NDDataBase):

Expand Down Expand Up @@ -1461,6 +1463,102 @@ def fill_masked(self, fill_value, uncertainty_fill_value=None, unmask=False, fil
self.mask = False
return None

def to_nddata(self,
*,
data=COPY,
wcs=COPY,
uncertainty=COPY,
mask=COPY,
unit=COPY,
meta=COPY,
psf=COPY,
extra_coords=COPY,
global_coords=COPY,
nddata_type=NDData,
**kwargs,
):
"""
Constructs new type instance with the same attribute values as this `~ndcube.NDCube`.

Attribute values can be altered on the output object by setting a kwarg with the new
value, e.g. ``data=new_data``.
Any attributes not supported by the new class (``nddata_type``), will be discarded.

Parameters
----------
data: array-like, optional
Data array of new instance. Default is to use data of this instance.
wcs: `astropy.wcs.wcsapi.BaseLowLevelWCS`, `astropy.wcs.wcsapi.BaseHighLevelWCS`, optional
WCS object of new instance. Default is to use data of this instance.
uncertainty: Any, optional
Uncertainy object of new instance. Default is to use data of this instance.
mask: Any, optional
Mask object of new instance. Default is to use data of this instance.
unit: Any, optional
Unit of new instance. Default is to use data of this instance.
meta: dict-like, optional
Metadata object of new instance. Default is to use data of this instance.
psf: Any, optional
PSF object of new instance. Default is to use data of this instance.
extra_coords: `ndcube.ExtraCoordsABC`, optional
Extra coords object of new instance. Default is to use data of this instance.
global_coords: `ndcube.GlobalCoordsABC`, optional
WCS object of new instance. Default is to use data of this instance.
nddata_type: Any, optional
The type of the returned object. Must be a subclass of `~astropy.nddata.NDData`
or a class that behaves like one. Default=`~astropy.nddata.NDData`.
kwargs:
Additional inputs to the ``nddata_type`` constructor that should differ from,
or are not represented by, the attributes of this instance. For example, to
set different data values on the returned object, set a kwarg ``data=new_data``,
where ``new_data`` is an array of compatible shape and dtype. Note that kwargs
given by the user and attributes on this instance that are not supported by the
``nddata_type`` constructor are ignored.

Returns
-------
new_nddata: Any
The new instance of class given by ``nddata_type`` with the same attribute values
as this `~ndcube.NDCube` instance, except for any alterations specified by the
kwargs.

Examples
--------
To create an `~astropy.nddata.NDData` instance which is a copy of an `~ndcube.NDCube`
(called ``cube``) without a WCS, do:

>>> nddata_without_coords = cube.to_nddata(wcs=None) # doctest: +SKIP
"""
# Build dictionary of new attribute values from this NDCube instance
# and update with user-defined kwargs. Remove any kwargs not set by user.
user_kwargs = {"data": data,
"wcs": wcs,
"uncertainty": uncertainty,
"mask": mask,
"unit": unit,
"meta": meta,
"psf": psf,
"extra_coords": extra_coords,
"global_coords": global_coords}
user_kwargs = {key: value for key, value in user_kwargs.items() if value is not COPY}
user_kwargs.update(kwargs)
all_kwargs = {key.strip("_"): value for key, value in self.__dict__.items()}
all_kwargs.update(user_kwargs)
# Inspect call signature of new_nddata class and
# remove unsupported items from new_kwargs.
all_kwargs = {key: value for key, value in all_kwargs.items()
if key in inspect.signature(nddata_type).parameters.keys()}
Comment on lines +1545 to +1550
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not a fan of extracting all the attributes like this, it feels exceptionally brittle.

Do we really need to do this? Can't we just say if you need to copy custom attributes of a non-NDData class you need to specify them as explicit keyword arguments? Then this dictionary comprehension is just for the things where the kwarg value is COPY.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless there's a better way to filter the kwargs by the call signature of nddata_type, then yes, we need this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That wasn't the part I was objecting to. I'll open a followup PR when I get the chance.

# Construct and return new instance.
new_nddata = nddata_type(**all_kwargs)
if isinstance(new_nddata, NDCubeBase):
if extra_coords is COPY:
extra_coords = copy.copy(self._extra_coords)
extra_coords._ndcube = new_nddata
new_nddata._extra_coords = extra_coords
if global_coords is COPY:
new_nddata._global_coords = copy.copy(self._global_coords)
Comment on lines +1554 to +1559
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should really add these as kwargs to ndcube's constructor lol.

Copy link
Member Author

@DanRyanIrish DanRyanIrish Oct 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree! Let's keep that as separate to this PR though.

return new_nddata


def _create_masked_array_for_rebinning(data, mask, operation_ignores_mask):
m = None if (mask is None or mask is False or operation_ignores_mask) else mask
Expand Down
7 changes: 7 additions & 0 deletions ndcube/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
'assert_cubes_equal',
'assert_cubesequences_equal',
'assert_extra_coords_equal',
'assert_global_coords_equal',
'assert_metas_equal',
'assert_wcs_are_equal',
'figure_test',
Expand Down Expand Up @@ -93,6 +94,12 @@ def assert_extra_coords_equal(test_input, extra_coords):
assert_wcs_are_equal(test_input._wcs, extra_coords._wcs)



def assert_global_coords_equal(test_input, global_coords):
assert test_input.items() == global_coords.items()
assert test_input.physical_types == global_coords.physical_types


def assert_metas_equal(test_input, expected_output):
if type(test_input) is not type(expected_output):
raise AssertionError(
Expand Down
21 changes: 21 additions & 0 deletions ndcube/tests/test_ndcube.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import numpy as np
import pytest

import astropy.nddata
import astropy.units as u
import astropy.wcs
from astropy.wcs.wcsapi import BaseHighLevelWCS
Expand Down Expand Up @@ -238,3 +239,23 @@ def test_fill_masked_ndc_uncertainty_none(ndc, fill_value, uncertainty_fill_valu
uncertainty_fill_value=uncertainty_fill_value,
fill_in_place=True
)


def test_to_nddata(ndcube_2d_ln_lt):
ndc = ndcube_2d_ln_lt
new_data = ndc.data * 2
output = ndc.to_nddata(data=new_data, wcs=None)
assert type(output) is astropy.nddata.NDData
assert output.wcs is None
assert (output.data == new_data).all()


def test_to_nddata_type_ndcube(ndcube_2d_ln_lt_uncert_ec):
ndc = ndcube_2d_ln_lt_uncert_ec
ndc.global_coords.add("wavelength", "em.wl", 100*u.nm)
new_data = ndc.data * 2
output = ndc.to_nddata(data=new_data, nddata_type=NDCube)
assert type(output) is NDCube
assert (output.data == new_data).all()
helpers.assert_extra_coords_equal(output.extra_coords, ndc.extra_coords)
helpers.assert_global_coords_equal(output.global_coords, ndc.global_coords)