Skip to content

Commit 55e7ea0

Browse files
DanRyanIrishCadair
andauthored
Support Subtract and Division of NDCube by NDData (#880)
* Enable subtraction of NDCube and NDData. * Enable division of NDCube by NDData. * Add 880 changelog. * Add NDCube-NDData arithmetic tests for subtraction and division that preserve dask laziness. * Remove in-place modification in NDCube arithmetic subtraction. * Remove duplicate test. * Fix bug whereby adding unitful NDCube and NDData did not output result with underlying arrays as dask. * Add more tests of dask-backed arithmetic operations. * Remove duplicate test. * First commit of docs explaining arithmetic operations. * Next commit on docs explaining arithmetic operations. * Rename arithmetic operations docs file and next commit in arithmetic docs. * Fix codestyle. * Fix bugs in arithmetic docs. * Next commit on arithmetic docs. * First complete draft of arithmetic docs. * Updates to arithmetic docs. * Updates to arithmetic docs. * Fix bug raising error when two coordinate aware objects are combined via arithmetic operations. * Updates arithmetic docs. * Update arithmetic docs. * Fix undefined variable in arithmetic docs. * More fixes to arithmetic docs. * doc formatting fix. * Fix test of to_nddata to ndcube. * Mention motivating use case of arithmetic operations in NDCube.to_nddata docstring. * Apply suggestions from code review Co-authored-by: Stuart Mumford <[email protected]> * Apply suggestions from code review * Whoops * Move ``to_nddata`` specific docs to ``to_nddata`` docstring * Fix doc error * Fix ``to_nddata`` docs * Show warning about dropping uncertainty --------- Co-authored-by: Stuart Mumford <[email protected]>
1 parent a548c8c commit 55e7ea0

File tree

9 files changed

+695
-18
lines changed

9 files changed

+695
-18
lines changed

changelog/880.bugfix.1.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed adding unitful `~ndcube.NDCube` and ``astropy.nddata.NDData`` objects backed by ``dask`` not preserving underlying arrays as ``dask`` arrays.

changelog/880.bugfix.2.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix bug where error was returned rather than raised with trying to perform arithmetic operation between `~ndcube.NDCube` and an object whose WCS attribute is not ``None``.

changelog/880.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Enable subtraction and division of `~ndcube.NDCube` by an `~astropy.nddata.NDData` instance (without a WCS), including uncertainty, mask and unit support.

docs/explaining_ndcube/arithmetic.rst

Lines changed: 392 additions & 0 deletions
Large diffs are not rendered by default.

docs/explaining_ndcube/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ Explaining ``ndcube``
1414
tabular_coordinates
1515
reproject
1616
visualization
17+
arithmetic
1718
asdf_serialization

ndcube/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,11 @@ def ndcube_2d_dask(wcs_2d_lt_ln):
10521052
return NDCube(darr, wcs=wcs_2d_lt_ln, uncertainty=da_uncert, mask=da_mask, unit=u.J)
10531053

10541054

1055+
@pytest.fixture
1056+
def nddata_2d_dask(ndcube_2d_dask):
1057+
return ndcube_2d_dask.to_nddata(wcs=None)
1058+
1059+
10551060
@pytest.fixture
10561061
def ndcube_2d(request):
10571062
"""

ndcube/extra_coords/extra_coords.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def wcs(self) -> BaseHighLevelWCS:
104104
"""
105105

106106
@property
107-
@abc.abstractproperty
107+
@abc.abstractmethod
108108
def is_empty(self):
109109
"""Return True if no extra coords present, else return False."""
110110

ndcube/ndcube.py

Lines changed: 116 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -980,34 +980,49 @@ def _arithmetic_handle_mask(self, self_mask, value_mask):
980980
def _arithmetic_operate_with_nddata(self, operation, value):
981981
handle_mask = self._arithmetic_handle_mask
982982
if value.wcs is not None:
983-
return TypeError("Cannot add coordinate-aware NDCubes together.")
984-
983+
raise TypeError("Cannot add coordinate-aware objects to NDCubes.")
985984
kwargs = {}
986985
if operation == "add":
987986
# Handle units
988987
if self.unit is not None and value.unit is not None:
989-
value_data = (value.data * value.unit).to_value(self.unit)
988+
value_data = value.data * (value.unit / self.unit).to(u.dimensionless_unscaled)
990989
elif self.unit is None and value.unit is None:
991990
value_data = value.data
992991
else:
993992
raise TypeError("Adding objects requires that both have a unit or neither has a unit.") # change the test as well.
994993
# Handle data and uncertainty
995994
kwargs["data"] = self.data + value_data
996995
uncert_op = np.add
997-
elif operation == "multiply":
996+
elif operation in ("multiply", "true_divide"):
998997
# Handle units
999998
if self.unit is not None or value.unit is not None:
1000999
cube_unit = u.Unit('') if self.unit is None else self.unit
10011000
value_unit = u.Unit('') if value.unit is None else value.unit
1002-
kwargs["unit"] = cube_unit * value_unit
1003-
kwargs["data"] = self.data * value.data
1004-
uncert_op = np.multiply
1001+
kwargs["unit"] = (cube_unit * value_unit if operation == "multiply"
1002+
else cube_unit / value_unit)
1003+
if operation == "multiply":
1004+
kwargs["data"] = self.data * value.data
1005+
uncert_op = np.multiply
1006+
else:
1007+
kwargs["data"] = self.data / value.data
1008+
uncert_op = np.true_divide
10051009
else:
10061010
raise ValueError("Value of operation argument is not recognized.")
1007-
kwargs["uncertainty"] = self._combine_uncertainty(uncert_op, value, kwargs["data"])
1011+
# Calculate uncertainty.
1012+
new_uncert = self._combine_uncertainty(uncert_op, value, kwargs["data"])
1013+
if new_uncert:
1014+
# New uncertainty object must be decoupled from its original
1015+
# parent_nddata object. Set this to None here, and the parent_nddata
1016+
# will become the new cube on instantiation.
1017+
new_uncert.parent_nddata = None
1018+
uncert_unit = kwargs.get("unit", self.unit)
1019+
if uncert_unit:
1020+
# Give uncertainty object the same units as the new NDCube.
1021+
new_uncert.unit = uncert_unit
1022+
kwargs["uncertainty"] = new_uncert
10081023
kwargs["mask"] = handle_mask(self.mask, value.mask)
10091024

1010-
return kwargs # return the new NDCube instance
1025+
return kwargs
10111026

10121027
def __add__(self, value):
10131028
kwargs = {}
@@ -1052,7 +1067,12 @@ def __radd__(self, value):
10521067
return self.__add__(value)
10531068

10541069
def __sub__(self, value):
1055-
return self.__add__(-value)
1070+
if isinstance(value, NDData):
1071+
new_value = copy.copy(value)
1072+
new_value._data = -value.data
1073+
else:
1074+
new_value = -value
1075+
return self.__add__(new_value)
10561076

10571077
def __rsub__(self, value):
10581078
return self.__neg__().__add__(value)
@@ -1084,6 +1104,9 @@ def __rmul__(self, value):
10841104
return self.__mul__(value)
10851105

10861106
def __truediv__(self, value):
1107+
if isinstance(value, NDData):
1108+
kwargs = self._arithmetic_operate_with_nddata("true_divide", value)
1109+
return self._new_instance(**kwargs)
10871110
return self.__mul__(1/value)
10881111

10891112
def __rtruediv__(self, value):
@@ -1485,8 +1508,11 @@ def to_nddata(self,
14851508
keyword to ``"copy"``, for example ``mycube.to_nddata(spam="copy")``
14861509
is the equivalent of setting
14871510
``mycube.to_nddata(spam=mycube.spam)``.
1488-
Any attributes not supported by the new class
1489-
(``nddata_type``), will be discarded.
1511+
1512+
A motivating use case for this method is in enabling arithmetic operations between
1513+
`~ndcube.NDCube` instances by removing coordinate-awareness. See the section of the
1514+
ndcube documentation on
1515+
:ref:`arithmetic`.
14901516
14911517
Parameters
14921518
----------
@@ -1524,16 +1550,89 @@ def to_nddata(self,
15241550
15251551
Examples
15261552
--------
1553+
.. expanding-code-block:: python
1554+
:summary: Expand to see cube instantiated.
1555+
1556+
>>> import astropy.units as u
1557+
>>> import astropy.wcs
1558+
>>> import numpy as np
1559+
>>> from astropy.nddata import StdDevUncertainty
1560+
1561+
>>> from ndcube import NDCube
1562+
1563+
>>> # Define data array.
1564+
>>> data = np.arange(2*3).reshape((2, 3)) + 10
1565+
1566+
>>> # Define WCS transformations in an astropy WCS object.
1567+
>>> wcs = astropy.wcs.WCS(naxis=2)
1568+
>>> wcs.wcs.ctype = 'HPLT-TAN', 'HPLN-TAN'
1569+
>>> wcs.wcs.cunit = 'deg', 'deg'
1570+
>>> wcs.wcs.cdelt = 0.5, 0.4
1571+
>>> wcs.wcs.crpix = 2, 2
1572+
>>> wcs.wcs.crval = 0.5, 1
1573+
1574+
>>> # Define mask. Initially set all elements unmasked.
1575+
>>> mask = np.zeros_like(data, dtype=bool)
1576+
>>> mask[0, :] = True # Now mask some values.
1577+
>>> # Define uncertainty, metadata and unit.
1578+
>>> uncertainty = StdDevUncertainty(np.abs(data) * 0.1)
1579+
>>> meta = {"Description": "This is example NDCube metadata."}
1580+
>>> unit = u.ct
1581+
1582+
>>> # Instantiate NDCube with supporting data.
1583+
>>> cube = NDCube(data, wcs=wcs, uncertainty=uncertainty, mask=mask, meta=meta)
1584+
15271585
To create an `~astropy.nddata.NDData` instance which is a copy of an `~ndcube.NDCube`
1528-
(called ``cube``) without a WCS, do::
1586+
(called ``cube``) without a WCS, do:
15291587
1530-
>>> nddata_without_coords = cube.to_nddata(wcs=None) # doctest: +SKIP
1588+
.. code-block:: python
1589+
1590+
>>> nddata_without_coords = cube.to_nddata(wcs=None)
1591+
>>> nddata_without_coords
1592+
NDData([[——, ——, ——],
1593+
[13, 14, 15]])
15311594
15321595
To create a new `~ndcube.NDCube` instance which is a copy of
15331596
an `~ndcube.NDCube` (called ``cube``) without an uncertainty,
1534-
but with ``global_coords`` and ``extra_coords`` do::
1535-
1536-
>>> nddata_without_coords = cube.to_nddata(uncertainty=None, global_coords=True, extra_coords=True) # doctest: +SKIP
1597+
but with ``global_coords`` and ``extra_coords`` do:
1598+
1599+
.. code-block:: python
1600+
1601+
>>> ndcube_without_uncertainty = cube.to_nddata(
1602+
... uncertainty=None,
1603+
... global_coords="copy",
1604+
... extra_coords="copy",
1605+
... nddata_type=NDCube,
1606+
... )
1607+
>>> ndcube_without_uncertainty
1608+
<ndcube.ndcube.NDCube object at ...>
1609+
NDCube
1610+
------
1611+
Shape: (2, 3)
1612+
Physical Types of Axes: [('custom:pos.helioprojective.lat', 'custom:pos.helioprojective.lon'), ('custom:pos.helioprojective.lat', 'custom:pos.helioprojective.lon')]
1613+
Unit: None
1614+
Data Type: int64
1615+
1616+
To create a different type of ``NDData`` object do:
1617+
1618+
.. code-block:: python
1619+
1620+
>>> from astropy.nddata import NDDataRef
1621+
>>> nddataref = cube.to_nddata(wcs=None, nddata_type=NDDataRef)
1622+
>>> nddataref
1623+
NDDataRef([[——, ——, ——],
1624+
[13, 14, 15]])
1625+
1626+
The value of any input supported by the ``nddata_type``'s
1627+
constructor can be altered by setting a kwarg for that input,
1628+
e.g.:
1629+
1630+
.. code-block:: python
1631+
1632+
>>> nddata_ones = cube.to_nddata(data=np.ones(cube.data.shape))
1633+
>>> nddata_ones.data
1634+
array([[1., 1., 1.],
1635+
[1., 1., 1.]])
15371636
"""
15381637
# Put all NDData kwargs in a dict
15391638
user_kwargs = {"data": data,

0 commit comments

Comments
 (0)