diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49aa6d5b1..874a08936 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,7 @@ jobs: - windows: py311 - macos: py310 - linux: py310-oldestdeps + - linux: asdf_schemas secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/ndcube/asdf/__init__.py b/ndcube/asdf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ndcube/asdf/converters/compoundwcs_converter.py b/ndcube/asdf/converters/compoundwcs_converter.py new file mode 100644 index 000000000..3cbd9430b --- /dev/null +++ b/ndcube/asdf/converters/compoundwcs_converter.py @@ -0,0 +1,18 @@ +from asdf.extension import Converter + + +class CompoundConverter(Converter): + tags = ["tag:sunpy.org:ndcube/compoundwcs-0.1.0"] + types = ["ndcube.wcs.wrappers.compound_wcs.CompoundLowLevelWCS"] + + def from_yaml_tree(self, node, tag, ctx): + from ndcube.wcs.wrappers import CompoundLowLevelWCS + + return(CompoundLowLevelWCS(*node["wcs"], mapping=node.get("mapping"), pixel_atol=node.get("atol"))) + + def to_yaml_tree(self, compoundwcs, tag, ctx): + node = {} + node["wcs"] = compoundwcs._wcs + node["mapping"] = compoundwcs.mapping.mapping + node["atol"] = compoundwcs.atol + return node diff --git a/ndcube/asdf/converters/extracoords_converter.py b/ndcube/asdf/converters/extracoords_converter.py new file mode 100644 index 000000000..c94795943 --- /dev/null +++ b/ndcube/asdf/converters/extracoords_converter.py @@ -0,0 +1,28 @@ +from asdf.extension import Converter + + +class ExtraCoordsConverter(Converter): + tags = ["tag:sunpy.org:ndcube/extra_coords/extra_coords/extracoords-*"] + types = ["ndcube.extra_coords.extra_coords.ExtraCoords"] + + def from_yaml_tree(self, node, tag, ctx): + from ndcube.extra_coords.extra_coords import ExtraCoords + extra_coords = ExtraCoords() + extra_coords._wcs = node.get("wcs") + extra_coords._mapping = node.get("mapping") + extra_coords._lookup_tables = node.get("lookup_tables", []) + extra_coords._dropped_tables = node.get("dropped_tables") + extra_coords._ndcube = node.get("ndcube") + return extra_coords + + def to_yaml_tree(self, extracoords, tag, ctx): + node = {} + if extracoords._wcs is not None: + node["wcs"] = extracoords._wcs + if extracoords._mapping is not None: + node["mapping"] = extracoords._mapping + if extracoords._lookup_tables: + node["lookup_tables"] = extracoords._lookup_tables + node["dropped_tables"] = extracoords._dropped_tables + node["ndcube"] = extracoords._ndcube + return node diff --git a/ndcube/asdf/converters/globalcoords_converter.py b/ndcube/asdf/converters/globalcoords_converter.py new file mode 100644 index 000000000..9700a5890 --- /dev/null +++ b/ndcube/asdf/converters/globalcoords_converter.py @@ -0,0 +1,24 @@ +from asdf.extension import Converter + + +class GlobalCoordsConverter(Converter): + tags = ["tag:sunpy.org:ndcube/global_coords/globalcoords-*"] + types = ["ndcube.global_coords.GlobalCoords"] + + def from_yaml_tree(self, node, tag, ctx): + from ndcube.global_coords import GlobalCoords + + globalcoords = GlobalCoords() + if "internal_coords" in node: + globalcoords._internal_coords = node["internal_coords"] + globalcoords._ndcube = node["ndcube"] + + return globalcoords + + def to_yaml_tree(self, globalcoords, tag, ctx): + node = {} + node["ndcube"] = globalcoords._ndcube + if globalcoords._internal_coords: + node["internal_coords"] = globalcoords._internal_coords + + return node diff --git a/ndcube/asdf/converters/ndcollection_converter.py b/ndcube/asdf/converters/ndcollection_converter.py new file mode 100644 index 000000000..810bb151c --- /dev/null +++ b/ndcube/asdf/converters/ndcollection_converter.py @@ -0,0 +1,28 @@ +from asdf.extension import Converter + + +class NDCollectionConverter(Converter): + tags = ["tag:sunpy.org:ndcube/ndcube/ndcollection-*"] + types = ["ndcube.ndcollection.NDCollection"] + + def from_yaml_tree(self, node, tag, ctx): + from ndcube.ndcollection import NDCollection + + key_value_pairs = list(zip(node["keys"], node["value"])) + aligned_axes = list(node.get("aligned_axes").values()) + aligned_axes = tuple(tuple(lst) for lst in aligned_axes) + ndcollection = NDCollection(key_value_pairs, + meta=node.get("meta"), + aligned_axes = aligned_axes) + return ndcollection + + def to_yaml_tree(self, ndcollection, tag, ctx): + node = {} + node["keys"] = tuple(ndcollection.keys()) + node["value"] = tuple(ndcollection.values()) + if ndcollection.meta is not None: + node["meta"] = ndcollection.meta + if ndcollection._aligned_axes is not None: + node["aligned_axes"] = ndcollection._aligned_axes + + return node diff --git a/ndcube/asdf/converters/ndcube_converter.py b/ndcube/asdf/converters/ndcube_converter.py new file mode 100644 index 000000000..bc855684d --- /dev/null +++ b/ndcube/asdf/converters/ndcube_converter.py @@ -0,0 +1,63 @@ +import warnings + +from asdf.extension import Converter + + +class NDCubeConverter(Converter): + tags = ["tag:sunpy.org:ndcube/ndcube/ndcube-*"] + types = ["ndcube.ndcube.NDCube"] + + def from_yaml_tree(self, node, tag, ctx): + from ndcube.ndcube import NDCube + + ndcube = NDCube(node["data"], + node["wcs"], + meta = node.get("meta"), + mask = node.get("mask"), + unit = node.get("unit")) + if "extra_coords" in node: + ndcube._extra_coords = node["extra_coords"] + if "global_coords" in node: + ndcube._global_coords = node["global_coords"] + + return ndcube + + def to_yaml_tree(self, ndcube, tag, ctx): + """ + Notes + ----- + This methods serializes the primary components of the NDCube object, + including the `data`, `wcs`, `extra_coords`, and `global_coords` attributes. + Issues a warning if unsupported attributes (uncertainty, mask, meta, unit) are present, + which are not currently serialized to ASDF. + + Warnings + -------- + UserWarning + Warns if the NDCube object has attributes 'uncertainty', 'mask', + or 'unit' that are present but not being saved in the ASDF serialization. + This ensures that users are aware of potentially important information + that is not included in the serialized output. + """ + from astropy.wcs.wcsapi import BaseHighLevelWCS + + node = {} + node["data"] = ndcube.data + if isinstance(ndcube.wcs, BaseHighLevelWCS): + node["wcs"] = ndcube.wcs.low_level_wcs + else: + node["wcs"] = ndcube.wcs + node["extra_coords"] = ndcube.extra_coords + node["global_coords"] = ndcube.global_coords + node["meta"] = ndcube.meta + if ndcube.mask is not None: + node["mask"] = ndcube.mask + if ndcube.unit is not None: + node["unit"] = ndcube.unit + + attributes = ['uncertainty', 'psf'] + for attr in attributes: + if getattr(ndcube, attr) is not None: + warnings.warn(f"Attribute '{attr}' is present but not being saved in ASDF serialization.", UserWarning) + + return node diff --git a/ndcube/asdf/converters/ndcubesequence_converter.py b/ndcube/asdf/converters/ndcubesequence_converter.py new file mode 100644 index 000000000..419c69497 --- /dev/null +++ b/ndcube/asdf/converters/ndcubesequence_converter.py @@ -0,0 +1,23 @@ +from asdf.extension import Converter + + +class NDCubeSequenceConverter(Converter): + tags = ["tag:sunpy.org:ndcube/ndcube/ndcube_sequence-*"] + types = ["ndcube.ndcube_sequence.NDCubeSequence"] + + def from_yaml_tree(self, node, tag, ctx): + from ndcube.ndcube_sequence import NDCubeSequence + + return NDCubeSequence(node["data"], + meta=node.get("meta"), + common_axis=node.get("common_axis")) + + def to_yaml_tree(self, ndcseq, tag, ctx): + node = {} + node["data"] = ndcseq.data + if ndcseq.meta is not None: + node["meta"] = ndcseq.meta + if ndcseq._common_axis is not None: + node["common_axis"] = ndcseq._common_axis + + return node diff --git a/ndcube/asdf/converters/reorderedwcs_converter.py b/ndcube/asdf/converters/reorderedwcs_converter.py new file mode 100644 index 000000000..715c11e2a --- /dev/null +++ b/ndcube/asdf/converters/reorderedwcs_converter.py @@ -0,0 +1,21 @@ +from asdf.extension import Converter + + +class ReorderedConverter(Converter): + tags = ["tag:sunpy.org:ndcube/reorderedwcs-*"] + types = ["ndcube.wcs.wrappers.reordered_wcs.ReorderedLowLevelWCS"] + + def from_yaml_tree(self, node, tag, ctx): + from ndcube.wcs.wrappers import ReorderedLowLevelWCS + + reorderedwcs = ReorderedLowLevelWCS(wcs=node["wcs"], + pixel_order=node.get("pixel_order"), + world_order=node.get("world_order")) + return reorderedwcs + + def to_yaml_tree(self, reorderedwcs, tag, ctx): + node = {} + node["wcs"] = reorderedwcs._wcs + node["pixel_order"] = reorderedwcs._pixel_order + node["world_order"] = reorderedwcs._world_order + return node diff --git a/ndcube/asdf/converters/resampled_converter.py b/ndcube/asdf/converters/resampled_converter.py new file mode 100644 index 000000000..6e23e1616 --- /dev/null +++ b/ndcube/asdf/converters/resampled_converter.py @@ -0,0 +1,23 @@ +from asdf.extension import Converter + + +class ResampledConverter(Converter): + tags = ["tag:sunpy.org:ndcube/resampledwcs-*"] + types = ["ndcube.wcs.wrappers.resampled_wcs.ResampledLowLevelWCS"] + + def from_yaml_tree(self, node, tag, ctx): + from ndcube.wcs.wrappers import ResampledLowLevelWCS + + resampledwcs = ResampledLowLevelWCS(wcs=node["wcs"], + offset=node.get("offset"), + factor=node.get("factor"), + ) + return resampledwcs + + def to_yaml_tree(self, resampledwcs, tag, ctx): + node = {} + node["wcs"] = resampledwcs._wcs + node["factor"] = resampledwcs._factor + node["offset"] = resampledwcs._offset + + return node diff --git a/ndcube/asdf/converters/tablecoord_converter.py b/ndcube/asdf/converters/tablecoord_converter.py new file mode 100644 index 000000000..b07e630e6 --- /dev/null +++ b/ndcube/asdf/converters/tablecoord_converter.py @@ -0,0 +1,80 @@ +from asdf.extension import Converter + + +class TimeTableCoordConverter(Converter): + tags = ["tag:sunpy.org:ndcube/extra_coords/table_coord/timetablecoordinate-*"] + types = ["ndcube.extra_coords.table_coord.TimeTableCoordinate"] + + def from_yaml_tree(self, node, tag, ctx): + from ndcube.extra_coords.table_coord import TimeTableCoordinate + + names = node.get("names") + physical_types = node.get("physical_types") + reference_time = node.get("reference_time") + timetablecoordinate = TimeTableCoordinate( + node["table"], names=names, physical_types=physical_types, reference_time=reference_time) + + return timetablecoordinate + + def to_yaml_tree(self, timetablecoordinate, tag, ctx): + node = {} + node["table"] = timetablecoordinate.table + node["names"] = timetablecoordinate.names + if timetablecoordinate.physical_types is not None: + node["physical_types"] = timetablecoordinate.physical_types + node["reference_time"] = timetablecoordinate.reference_time + + return node + + +class QuantityTableCoordinateConverter(Converter): + tags = ["tag:sunpy.org:ndcube/extra_coords/table_coord/quantitytablecoordinate-*"] + types = ["ndcube.extra_coords.table_coord.QuantityTableCoordinate"] + + def from_yaml_tree(self, node, tag, ctx): + from ndcube.extra_coords.table_coord import QuantityTableCoordinate + + names = node.get("names") + mesh = node.get("mesh") + physical_types = node.get("physical_types") + quantitytablecoordinate = QuantityTableCoordinate(*node["table"], + names=names, physical_types=physical_types) + quantitytablecoordinate.unit = node["unit"] + quantitytablecoordinate.mesh = mesh + return quantitytablecoordinate + + def to_yaml_tree(self, quantitytablecoordinate, tag, ctx): + node = {} + node["unit"] = quantitytablecoordinate.unit + node["table"] = quantitytablecoordinate.table + node["names"] = quantitytablecoordinate.names + node["mesh"] = quantitytablecoordinate.mesh + if quantitytablecoordinate.physical_types is not None: + node["physical_types"] = quantitytablecoordinate.physical_types + + return node + + +class SkyCoordTableCoordinateConverter(Converter): + tags = ["tag:sunpy.org:ndcube/extra_coords/table_coord/skycoordtablecoordinate-*"] + types = ["ndcube.extra_coords.table_coord.SkyCoordTableCoordinate"] + + def from_yaml_tree(self, node, tag, ctx): + from ndcube.extra_coords.table_coord import SkyCoordTableCoordinate + + names = node.get("names") + mesh = node.get("mesh") + physical_types = node.get("physical_types") + skycoordinatetablecoordinate = SkyCoordTableCoordinate(node["table"], mesh=mesh, + names=names, physical_types=physical_types) + return skycoordinatetablecoordinate + + def to_yaml_tree(self, skycoordinatetablecoordinate, tag, ctx): + node = {} + node["table"] = skycoordinatetablecoordinate.table + node["names"] = skycoordinatetablecoordinate.names + node["mesh"] = skycoordinatetablecoordinate.mesh + if skycoordinatetablecoordinate.physical_types is not None: + node["physical_types"] = skycoordinatetablecoordinate.physical_types + + return node diff --git a/ndcube/asdf/converters/tests/test_ndcollection_converter.py b/ndcube/asdf/converters/tests/test_ndcollection_converter.py new file mode 100644 index 000000000..19158da4f --- /dev/null +++ b/ndcube/asdf/converters/tests/test_ndcollection_converter.py @@ -0,0 +1,53 @@ +import pytest +from gwcs import __version__ as gwcs_version +from packaging.version import Version + +import asdf + +from ndcube.ndcollection import NDCollection +from ndcube.ndcube_sequence import NDCubeSequence +from ndcube.tests.helpers import assert_collections_equal + + +@pytest.fixture +def create_ndcollection_cube(ndcube_gwcs_3d_ln_lt_l, ndcube_gwcs_3d_ln_lt_l_ec_q_t_gc, ndcube_gwcs_3d_ln_lt_l_ec_dropped_dim): + aligned_axes = ((1, 2), (1, 2), (1, 2)) + cube_collection = NDCollection([("cube0", ndcube_gwcs_3d_ln_lt_l), + ("cube1", ndcube_gwcs_3d_ln_lt_l_ec_q_t_gc), + ("cube2", ndcube_gwcs_3d_ln_lt_l_ec_dropped_dim)], + aligned_axes=aligned_axes) + + return cube_collection + + +@pytest.mark.skipif(Version(gwcs_version) < Version("0.20"), reason="Requires gwcs>=0.20") +def test_serialization_cube(create_ndcollection_cube, tmp_path): + ndcollection = create_ndcollection_cube + file_path = tmp_path / "test.asdf" + with asdf.AsdfFile() as af: + af["ndcube_gwcs"] = ndcollection + af.write_to(file_path) + + with asdf.open(file_path) as af: + assert_collections_equal(af["ndcube_gwcs"], ndcollection) + + +@pytest.fixture +def create_ndcollection_sequence(ndcube_gwcs_3d_ln_lt_l, ndcube_gwcs_3d_ln_lt_l_ec_dropped_dim): + + sequence02 = NDCubeSequence([ndcube_gwcs_3d_ln_lt_l, ndcube_gwcs_3d_ln_lt_l_ec_dropped_dim]) + sequence20 = NDCubeSequence([ndcube_gwcs_3d_ln_lt_l_ec_dropped_dim, ndcube_gwcs_3d_ln_lt_l]) + seq_collection = NDCollection([("seq0", sequence02), ("seq1", sequence20)], aligned_axes="all") + return seq_collection + + +@pytest.mark.skipif(Version(gwcs_version) < Version("0.20"), reason="Requires gwcs>=0.20") +def test_serialization_sequence(create_ndcollection_sequence, tmp_path): + ndcollection = create_ndcollection_sequence + file_path = tmp_path / "test.asdf" + with asdf.AsdfFile() as af: + af["ndcube_gwcs"] = ndcollection + af.write_to(file_path) + + with asdf.open(file_path) as af: + assert_collections_equal(af["ndcube_gwcs"], ndcollection) diff --git a/ndcube/asdf/converters/tests/test_ndcube_converter.py b/ndcube/asdf/converters/tests/test_ndcube_converter.py new file mode 100644 index 000000000..649b08b2e --- /dev/null +++ b/ndcube/asdf/converters/tests/test_ndcube_converter.py @@ -0,0 +1,50 @@ +import numpy as np +import pytest +from gwcs import __version__ as gwcs_version +from packaging.version import Version + +import asdf + +from ndcube.tests.helpers import assert_cubes_equal + + +@pytest.mark.parametrize("ndc",["ndcube_gwcs_2d_ln_lt_mask", + "ndcube_gwcs_3d_ln_lt_l", + "ndcube_gwcs_3d_ln_lt_l_ec_dropped_dim", + "ndcube_gwcs_3d_ln_lt_l_ec_q_t_gc", + "ndcube_gwcs_3d_rotated", + "ndcube_gwcs_4d_ln_lt_l_t", + "ndcube_gwcs_4d_ln_lt_l_t_unit", + ], indirect=("ndc",)) +@pytest.mark.skipif(Version(gwcs_version) < Version("0.20"), reason="Requires gwcs>=0.20") +def test_serialization(ndc, tmp_path): + file_path = tmp_path / "test.asdf" + with asdf.AsdfFile() as af: + af["ndcube_gwcs"] = ndc + af.write_to(file_path) + + with asdf.open(file_path) as af: + assert_cubes_equal(af["ndcube_gwcs"], ndc) + + +@pytest.mark.xfail(reason="Serialization of sliced ndcube not supported") +def test_serialization_sliced_ndcube(ndcube_gwcs_3d_ln_lt_l, tmp_path): + sndc = ndcube_gwcs_3d_ln_lt_l[np.s_[0, :, :]] + file_path = tmp_path / "test.asdf" + with asdf.AsdfFile() as af: + af["ndcube_gwcs"] = sndc + af.write_to(file_path) + + with asdf.open(file_path) as af: + assert_cubes_equal(af["ndcube_gwcs"], sndc) + + +@pytest.mark.xfail(reason="Serialization of ndcube with .wcs attribute as astropy.wcs.wcs.WCS not supported") +def test_serialization_ndcube_wcs(ndcube_3d_ln_lt_l, tmp_path): + file_path = tmp_path / "test.asdf" + with asdf.AsdfFile() as af: + af["ndcube"] = ndcube_3d_ln_lt_l + af.write_to(file_path) + + with asdf.open(file_path) as af: + assert_cubes_equal(af["ndcube"], ndcube_3d_ln_lt_l) diff --git a/ndcube/asdf/converters/tests/test_ndcube_wcs_wrappers.py b/ndcube/asdf/converters/tests/test_ndcube_wcs_wrappers.py new file mode 100644 index 000000000..791c29385 --- /dev/null +++ b/ndcube/asdf/converters/tests/test_ndcube_wcs_wrappers.py @@ -0,0 +1,92 @@ +""" +Tests for roundtrip serialization of NDCube with various GWCS types. + +TODO: Add tests for the roundtrip serialization of NDCube with ResampledLowLevelWCS, ReorderedLowLevelWCS, and CompoundLowLevelWCS when using astropy.wcs.WCS. +""" + +import pytest +from gwcs import __version__ as gwcs_version +from packaging.version import Version + +import asdf + +from ndcube import NDCube +from ndcube.conftest import data_nd +from ndcube.tests.helpers import assert_cubes_equal +from ndcube.wcs.wrappers import CompoundLowLevelWCS, ReorderedLowLevelWCS, ResampledLowLevelWCS + + +@pytest.fixture +def create_ndcube_resampledwcs(gwcs_3d_lt_ln_l): + shape = (2, 3, 4) + new_wcs = ResampledLowLevelWCS(wcs=gwcs_3d_lt_ln_l, factor=2 , offset=1) + data = data_nd(shape) + return NDCube(data=data, wcs=new_wcs) + + +@pytest.mark.skipif(Version(gwcs_version) < Version("0.20"), reason="Requires gwcs>=0.20") +def test_serialization_resampled(create_ndcube_resampledwcs, tmp_path): + ndc = create_ndcube_resampledwcs + file_path = tmp_path / "test.asdf" + with asdf.AsdfFile() as af: + af["ndcube"] = ndc + af.write_to(file_path) + + with asdf.open(file_path) as af: + loaded_ndcube = af["ndcube"] + + loaded_resampledwcs = loaded_ndcube.wcs.low_level_wcs + resampledwcs = ndc.wcs.low_level_wcs + assert (loaded_resampledwcs._factor == resampledwcs._factor).all() + assert (loaded_resampledwcs._offset == resampledwcs._offset).all() + + assert_cubes_equal(loaded_ndcube, ndc) + + +@pytest.fixture +def create_ndcube_reorderedwcs(gwcs_3d_lt_ln_l): + shape = (2, 3, 4) + new_wcs = ReorderedLowLevelWCS(wcs = gwcs_3d_lt_ln_l, pixel_order=[1, 2, 0] ,world_order=[2, 0, 1]) + data = data_nd(shape) + return NDCube(data = data, wcs =new_wcs) + + +@pytest.mark.skipif(Version(gwcs_version) < Version("0.20"), reason="Requires gwcs>=0.20") +def test_serialization_reordered(create_ndcube_reorderedwcs, tmp_path): + ndc = create_ndcube_reorderedwcs + file_path = tmp_path / "test.asdf" + with asdf.AsdfFile() as af: + af["ndcube"] = ndc + af.write_to(file_path) + + with asdf.open(file_path) as af: + loaded_ndcube = af["ndcube"] + + loaded_reorderedwcs = loaded_ndcube.wcs.low_level_wcs + reorderedwcs = ndc.wcs.low_level_wcs + assert (loaded_reorderedwcs._pixel_order == reorderedwcs._pixel_order) + assert (loaded_reorderedwcs._world_order == reorderedwcs._world_order) + + assert_cubes_equal(loaded_ndcube, ndc) + +@pytest.fixture +def create_ndcube_compoundwcs(gwcs_2d_lt_ln, time_and_simple_extra_coords_2d): + + shape = (1, 2, 3, 4) + new_wcs = CompoundLowLevelWCS(gwcs_2d_lt_ln, time_and_simple_extra_coords_2d.wcs, mapping = [0, 1, 2, 3]) + data = data_nd(shape) + return NDCube(data = data, wcs = new_wcs) + +@pytest.mark.skipif(Version(gwcs_version) < Version("0.20"), reason="Requires gwcs>=0.20") +def test_serialization_compoundwcs(create_ndcube_compoundwcs, tmp_path): + ndc = create_ndcube_compoundwcs + file_path = tmp_path / "test.asdf" + with asdf.AsdfFile() as af: + af["ndcube"] = ndc + af.write_to(file_path) + + with asdf.open(file_path) as af: + loaded_ndcube = af["ndcube"] + assert_cubes_equal(loaded_ndcube, ndc) + assert (loaded_ndcube.wcs.low_level_wcs.mapping.mapping == ndc.wcs.low_level_wcs.mapping.mapping) + assert (loaded_ndcube.wcs.low_level_wcs.atol == ndc.wcs.low_level_wcs.atol) diff --git a/ndcube/asdf/converters/tests/test_ndcubesequence_converter.py b/ndcube/asdf/converters/tests/test_ndcubesequence_converter.py new file mode 100644 index 000000000..bf2faaa4a --- /dev/null +++ b/ndcube/asdf/converters/tests/test_ndcubesequence_converter.py @@ -0,0 +1,20 @@ +import pytest +from gwcs import __version__ as gwcs_version +from packaging.version import Version + +import asdf + +from ndcube.ndcube_sequence import NDCubeSequence +from ndcube.tests.helpers import assert_cubesequences_equal + + +@pytest.mark.skipif(Version(gwcs_version) < Version("0.20"), reason="Requires gwcs>=0.20") +def test_serialization(ndcube_gwcs_3d_ln_lt_l, ndcube_gwcs_3d_ln_lt_l_ec_q_t_gc, tmp_path): + file_path = tmp_path / "test.asdf" + ndcseq = NDCubeSequence([ndcube_gwcs_3d_ln_lt_l, ndcube_gwcs_3d_ln_lt_l_ec_q_t_gc], common_axis=1) + with asdf.AsdfFile() as af: + af["ndcube_gwcs"] = ndcseq + af.write_to(file_path) + + with asdf.open(file_path) as af: + assert_cubesequences_equal(af["ndcube_gwcs"], ndcseq) diff --git a/ndcube/asdf/entry_points.py b/ndcube/asdf/entry_points.py new file mode 100644 index 000000000..8fd907977 --- /dev/null +++ b/ndcube/asdf/entry_points.py @@ -0,0 +1,64 @@ +""" +This file contains the entry points for asdf. +""" +import importlib.resources as importlib_resources + +from asdf.extension import ManifestExtension +from asdf.resource import DirectoryResourceMapping + + +def get_resource_mappings(): + """ + Get the resource mapping instances for myschemas + and manifests. This method is registered with the + asdf.resource_mappings entry point. + + Returns + ------- + list of collections.abc.Mapping + """ + from ndcube.asdf import resources + resources_root = importlib_resources.files(resources) + return [ + DirectoryResourceMapping( + resources_root / "schemas", "asdf://sunpy.org/ndcube/schemas/"), + DirectoryResourceMapping( + resources_root / "manifests", "asdf://sunpy.org/ndcube/manifests/"), + ] + + +def get_extensions(): + """ + Get the list of extensions. + """ + from ndcube.asdf.converters.compoundwcs_converter import CompoundConverter + from ndcube.asdf.converters.extracoords_converter import ExtraCoordsConverter + from ndcube.asdf.converters.globalcoords_converter import GlobalCoordsConverter + from ndcube.asdf.converters.ndcollection_converter import NDCollectionConverter + from ndcube.asdf.converters.ndcube_converter import NDCubeConverter + from ndcube.asdf.converters.ndcubesequence_converter import NDCubeSequenceConverter + from ndcube.asdf.converters.reorderedwcs_converter import ReorderedConverter + from ndcube.asdf.converters.resampled_converter import ResampledConverter + from ndcube.asdf.converters.tablecoord_converter import ( + QuantityTableCoordinateConverter, + SkyCoordTableCoordinateConverter, + TimeTableCoordConverter, + ) + ndcube_converters = [ + NDCubeConverter(), + ExtraCoordsConverter(), + TimeTableCoordConverter(), + QuantityTableCoordinateConverter(), + SkyCoordTableCoordinateConverter(), + GlobalCoordsConverter(), + ResampledConverter(), + ReorderedConverter(), + CompoundConverter(), + NDCubeSequenceConverter(), + NDCollectionConverter(), + ] + _manifest_uri = "asdf://sunpy.org/ndcube/manifests/ndcube-0.1.0" + + return [ + ManifestExtension.from_uri(_manifest_uri, converters=ndcube_converters) + ] diff --git a/ndcube/asdf/resources/manifests/ndcube-0.1.0.yaml b/ndcube/asdf/resources/manifests/ndcube-0.1.0.yaml new file mode 100644 index 000000000..bcb0b8a76 --- /dev/null +++ b/ndcube/asdf/resources/manifests/ndcube-0.1.0.yaml @@ -0,0 +1,40 @@ +%YAML 1.1 +--- +id: asdf://sunpy.org/ndcube/manifests/ndcube-0.1.0 +extension_uri: asdf://sunpy.org/extensions/ndcube-0.1.0 +title: NDCube ASDF Manifest +description: ASDF schemas and tags for NDCube classes. + +tags: + - tag_uri: "tag:sunpy.org:ndcube/ndcube/ndcube-0.1.0" + schema_uri: "asdf://sunpy.org/ndcube/schemas/ndcube-0.1.0" + + - tag_uri: "tag:sunpy.org:ndcube/extra_coords/extra_coords/extracoords-0.1.0" + schema_uri: "asdf://sunpy.org/ndcube/schemas/extra_coords-0.1.0" + + - tag_uri: "tag:sunpy.org:ndcube/extra_coords/table_coord/timetablecoordinate-0.1.0" + schema_uri: "asdf://sunpy.org/ndcube/schemas/timetablecoordinate-0.1.0" + + - tag_uri: "tag:sunpy.org:ndcube/extra_coords/table_coord/quantitytablecoordinate-0.1.0" + schema_uri: "asdf://sunpy.org/ndcube/schemas/quantitytablecoordinate-0.1.0" + + - tag_uri: "tag:sunpy.org:ndcube/extra_coords/table_coord/skycoordtablecoordinate-0.1.0" + schema_uri: "asdf://sunpy.org/ndcube/schemas/skycoordtablecoordinate-0.1.0" + + - tag_uri: "tag:sunpy.org:ndcube/global_coords/globalcoords-0.1.0" + schema_uri: "asdf://sunpy.org/ndcube/schemas/global_coords-0.1.0" + + - tag_uri: "tag:sunpy.org:ndcube/resampledwcs-0.1.0" + schema_uri: "asdf://sunpy.org/ndcube/schemas/resampledwcs-0.1.0" + + - tag_uri: "tag:sunpy.org:ndcube/ndcube/ndcube_sequence-0.1.0" + schema_uri: "asdf://sunpy.org/ndcube/schemas/ndcube_sequence-0.1.0" + + - tag_uri: "tag:sunpy.org:ndcube/reorderedwcs-0.1.0" + schema_uri: "asdf://sunpy.org/ndcube/schemas/reorderedwcs-0.1.0" + + - tag_uri: "tag:sunpy.org:ndcube/compoundwcs-0.1.0" + schema_uri: "asdf://sunpy.org/ndcube/schemas/compoundwcs-0.1.0" + + - tag_uri: "tag:sunpy.org:ndcube/ndcube/ndcollection-0.1.0" + schema_uri: "asdf://sunpy.org/ndcube/schemas/ndcollection-0.1.0" diff --git a/ndcube/asdf/resources/schemas/compoundwcs-0.1.0.yaml b/ndcube/asdf/resources/schemas/compoundwcs-0.1.0.yaml new file mode 100644 index 000000000..24028920e --- /dev/null +++ b/ndcube/asdf/resources/schemas/compoundwcs-0.1.0.yaml @@ -0,0 +1,25 @@ +%YAML 1.1 +--- +$schema: "http://stsci.edu/schemas/yaml-schema/draft-01" +id: "asdf://sunpy.org/ndcube/schemas/Compoundwcs-0.1.0" + +title: + Represents the ndcube CompoundLowLevelWCS object + +description: + Represents the ndcube CompoundLowLevelWCS object + +type: object +properties: + wcs: + type: array + items: + tag: "tag:stsci.edu:gwcs/wcs-1.*" + mapping: + type: array + atol: + type: number + +required: [wcs] +additionalProperties: true +... diff --git a/ndcube/asdf/resources/schemas/extra_coords-0.1.0.yaml b/ndcube/asdf/resources/schemas/extra_coords-0.1.0.yaml new file mode 100644 index 000000000..6b16e3c9c --- /dev/null +++ b/ndcube/asdf/resources/schemas/extra_coords-0.1.0.yaml @@ -0,0 +1,37 @@ +%YAML 1.1 +--- +$schema: "http://stsci.edu/schemas/yaml-schema/draft-01" +id: "asdf://sunpy.org/ndcube/schemas/extra_coords-0.1.0" + +title: + Represents the ndcube ExtraCoords object + +description: + Represents the ndcube ExtraCoords object + +type: object +properties: + wcs: + tag: "tag:stsci.edu:gwcs/wcs-1.*" + mapping: + type: array + lookup_tables: + type: array + items: + type: array + items: + - oneOf: + - type: number + - type: array + - oneOf: + - tag: "tag:sunpy.org:ndcube/extra_coords/table_coord/quantitytablecoordinate-0.*" + - tag: "tag:sunpy.org:ndcube/extra_coords/table_coord/skycoordtablecoordinate-0.*" + - tag: "tag:sunpy.org:ndcube/extra_coords/table_coord/timetablecoordinate-0.*" + dropped_tables: + type: array + ndcube: + tag: "tag:sunpy.org:ndcube/ndcube/ndcube-0.*" + +required: [ndcube] +additionalProperties: false +... diff --git a/ndcube/asdf/resources/schemas/global_coords-0.1.0.yaml b/ndcube/asdf/resources/schemas/global_coords-0.1.0.yaml new file mode 100644 index 000000000..09657fede --- /dev/null +++ b/ndcube/asdf/resources/schemas/global_coords-0.1.0.yaml @@ -0,0 +1,29 @@ +%YAML 1.1 +--- +$schema: "http://stsci.edu/schemas/yaml-schema/draft-01" +id: "asdf://sunpy.org/ndcube/schemas/global_coords-0.1.0" + +title: + Represents the ndcube GlobalCoords object + +description: + Represents the ndcube GlobalCoords object + +type: object +properties: + internal_coords: + type: object + additionalProperties: + type: array + items: + - type: string + - type: object + oneOf: + - tag: "tag:stsci.edu:asdf/unit/quantity-*" + - tag: "tag:astropy.org:astropy/coordinates/skycoord-*" + ndcube: + tag: "tag:sunpy.org:ndcube/ndcube/ndcube-0.*" + +required: [ndcube] +additionalProperties: false +... diff --git a/ndcube/asdf/resources/schemas/ndcollection-0.1.0.yaml b/ndcube/asdf/resources/schemas/ndcollection-0.1.0.yaml new file mode 100644 index 000000000..02c76e418 --- /dev/null +++ b/ndcube/asdf/resources/schemas/ndcollection-0.1.0.yaml @@ -0,0 +1,30 @@ +%YAML 1.1 +--- +$schema: "http://stsci.edu/schemas/yaml-schema/draft-01" +id: "asdf://sunpy.org/ndcube/schemas/ndcollection-0.1.0" + +title: + Represents the ndcube.ndcollection.NDCollection object + +description: + Represents the ndcube ndcube.ndcollection.NDCollection object + +type: object +properties: + keys: + type: array + value: + type: array + items: + - type: object + oneOf: + - tag: "tag:sunpy.org:ndcube/ndcube/ndcube-0.*" + - tag: "tag:sunpy.org:ndcube/ndcube/ndcube_sequence-0.*" + aligned_axes: + anyOf: + - type: object + - type: string + +required: [keys, value] +additionalProperties: true +... diff --git a/ndcube/asdf/resources/schemas/ndcube-0.1.0.yaml b/ndcube/asdf/resources/schemas/ndcube-0.1.0.yaml new file mode 100644 index 000000000..0b2a414e0 --- /dev/null +++ b/ndcube/asdf/resources/schemas/ndcube-0.1.0.yaml @@ -0,0 +1,37 @@ +%YAML 1.1 +--- +$schema: "http://stsci.edu/schemas/yaml-schema/draft-01" +id: "asdf://sunpy.org/ndcube/schemas/NDCube-0.1.0" + +title: + Represents the ndcube NDCube object + +description: + Represents the ndcube NDCube object + +type: object +properties: + data: + description: "Must be compatible with ASDF serialization/deserialization and supported by NDCube." + wcs: + anyOf: + - tag: "tag:stsci.edu:gwcs/wcs-1.*" + - tag: "tag:sunpy.org:ndcube/resampledwcs-*" + - tag: "tag:sunpy.org:ndcube/reorderedwcs-*" + - tag: "tag:sunpy.org:ndcube/compoundwcs-*" + extra_coords: + tag: "tag:sunpy.org:ndcube/extra_coords/extra_coords/extracoords-0.*" + global_coords: + tag: "tag:sunpy.org:ndcube/global_coords/globalcoords-0.*" + meta: + type: object + mask: + type: object + unit: + anyOf: + - tag: "tag:stsci.edu:asdf/unit/unit-1.*" + - tag: "tag:astropy.org:astropy/units/unit-1.*" + +required: [data, wcs] +additionalProperties: true +... diff --git a/ndcube/asdf/resources/schemas/ndcube_sequence-0.1.0.yaml b/ndcube/asdf/resources/schemas/ndcube_sequence-0.1.0.yaml new file mode 100644 index 000000000..155665502 --- /dev/null +++ b/ndcube/asdf/resources/schemas/ndcube_sequence-0.1.0.yaml @@ -0,0 +1,25 @@ +%YAML 1.1 +--- +$schema: "http://stsci.edu/schemas/yaml-schema/draft-01" +id: "asdf://sunpy.org/ndcube/schemas/ndcube_sequence-0.1.0" + +title: + Represents the ndcube.ndcube_sequence.NDCubeSequence object + +description: + Represents the ndcube.ndcube_sequence.NDCubeSequence object + +type: object +properties: + data: + type: array + items: + tag: "tag:sunpy.org:ndcube/ndcube/ndcube-0.*" + meta: + type: object + common_axis: + type: integer + +required: [data] +additionalProperties: true +... diff --git a/ndcube/asdf/resources/schemas/quantitytablecoordinate-0.1.0.yaml b/ndcube/asdf/resources/schemas/quantitytablecoordinate-0.1.0.yaml new file mode 100644 index 000000000..728d46353 --- /dev/null +++ b/ndcube/asdf/resources/schemas/quantitytablecoordinate-0.1.0.yaml @@ -0,0 +1,31 @@ +%YAML 1.1 +--- +$schema: "http://stsci.edu/schemas/yaml-schema/draft-01" +id: "asdf://sunpy.org/ndcube/schemas/quantitytablecoordinate-0.1.0" + +title: + Represents the QuantityTableCoords class + +description: + Represents the QuantityTableCoords class + +type: object +properties: + unit: + anyOf: + - tag: "tag:stsci.edu:asdf/unit/unit-*" + - tag: "tag:astropy.org:astropy/units/unit-1.*" + table: + type: array + items: + tag: "tag:stsci.edu:asdf/unit/quantity-*" + names: + type: array + mesh: + type: boolean + physical_types: + type: array + +required: ["table", "unit"] +additionalProperties: False +... diff --git a/ndcube/asdf/resources/schemas/reorderedwcs-0.1.0.yaml b/ndcube/asdf/resources/schemas/reorderedwcs-0.1.0.yaml new file mode 100644 index 000000000..76e54521b --- /dev/null +++ b/ndcube/asdf/resources/schemas/reorderedwcs-0.1.0.yaml @@ -0,0 +1,23 @@ +%YAML 1.1 +--- +$schema: "http://stsci.edu/schemas/yaml-schema/draft-01" +id: "asdf://sunpy.org/ndcube/schemas/resampledwcs-0.1.0" + +title: + Represents the ndcube ReorderedLowLevelWCS object + +description: + Represents the ndcube ReorderedLowLevelWCS object + +type: object +properties: + wcs: + tag: "tag:stsci.edu:gwcs/wcs-1.*" + pixel_order: + type: array + world_order: + type: array + +required: [wcs] +additionalProperties: true +... diff --git a/ndcube/asdf/resources/schemas/resampledwcs-0.1.0.yaml b/ndcube/asdf/resources/schemas/resampledwcs-0.1.0.yaml new file mode 100644 index 000000000..bfecfa023 --- /dev/null +++ b/ndcube/asdf/resources/schemas/resampledwcs-0.1.0.yaml @@ -0,0 +1,23 @@ +%YAML 1.1 +--- +$schema: "http://stsci.edu/schemas/yaml-schema/draft-01" +id: "asdf://sunpy.org/ndcube/schemas/resampledwcs-0.1.0" + +title: + Represents the ndcube ResampledLowLevelWCS object + +description: + Represents the ndcube ResampledLowLevelWCS object + +type: object +properties: + wcs: + tag: "tag:stsci.edu:gwcs/wcs-1.*" + factor: + tag: "tag:stsci.edu:asdf/core/ndarray-1.0.0" + offset: + tag: "tag:stsci.edu:asdf/core/ndarray-1.0.0" + +required: [wcs] +additionalProperties: true +... diff --git a/ndcube/asdf/resources/schemas/skycoordtablecoordinate-0.1.0.yaml b/ndcube/asdf/resources/schemas/skycoordtablecoordinate-0.1.0.yaml new file mode 100644 index 000000000..97f22843a --- /dev/null +++ b/ndcube/asdf/resources/schemas/skycoordtablecoordinate-0.1.0.yaml @@ -0,0 +1,25 @@ +%YAML 1.1 +--- +$schema: "http://stsci.edu/schemas/yaml-schema/draft-01" +id: "asdf://sunpy.org/ndcube/schemas/skycoordtablecoordinate-0.1.0" + +title: + Represents the SkyCoordTableCoordinate class + +description: + Represents the SkyCoordTableCoordinate class + +type: object +properties: + table: + tag: "tag:astropy.org:astropy/coordinates/skycoord-*" + names: + type: array + mesh: + type: boolean + physical_types: + type: array + +required: ["table"] +additionalProperties: False +... diff --git a/ndcube/asdf/resources/schemas/timetablecoordinate-0.1.0.yaml b/ndcube/asdf/resources/schemas/timetablecoordinate-0.1.0.yaml new file mode 100644 index 000000000..3c5a828ce --- /dev/null +++ b/ndcube/asdf/resources/schemas/timetablecoordinate-0.1.0.yaml @@ -0,0 +1,25 @@ +%YAML 1.1 +--- +$schema: "http://stsci.edu/schemas/yaml-schema/draft-01" +id: "asdf://sunpy.org/ndcube/schemas/timetablecoordinate-0.1.0" + +title: + Represents the TimeTableCoords class + +description: + Represents the TimeTableCoords class + +type: object +properties: + table: + tag: "tag:stsci.edu:asdf/time/time-1*" + names: + type: array + physical_types: + type: array + reference_time: + tag: "tag:stsci.edu:asdf/time/time-1*" + +required: ["table"] +additionalProperties: False +... diff --git a/ndcube/conftest.py b/ndcube/conftest.py index ad34271d0..5a6220fa2 100644 --- a/ndcube/conftest.py +++ b/ndcube/conftest.py @@ -7,10 +7,14 @@ import dask.array import numpy as np import pytest +from gwcs import coordinate_frames as cf +from gwcs import wcs import astropy.nddata import astropy.units as u +from astropy import coordinates as coord from astropy.coordinates import SkyCoord +from astropy.modeling import models from astropy.nddata import StdDevUncertainty from astropy.time import Time, TimeDelta from astropy.wcs import WCS @@ -35,6 +39,10 @@ # Helper Functions ################################################################################ +def time_lut(shape): + base_time = Time('2000-01-01', format='fits', scale='utc') + timestamps = Time([base_time + TimeDelta(60 * i, format='sec') for i in range(shape[0])]) + return timestamps def skycoord_2d_lut(shape): total_len = np.prod(shape) @@ -81,6 +89,136 @@ def gen_ndcube_3d_l_ln_lt_ectime(wcs_3d_lt_ln_l, time_axis, time_base, global_co # WCS Fixtures ################################################################################ +@pytest.fixture +def gwcs_4d_t_l_lt_ln(): + """ + Creates a 4D GWCS object with time, wavelength, and celestial coordinates. + + - Time: Axis 0 + - Wavelength: Axis 1 + - Sky: Axes 2 and 3 + + Returns: + wcs.WCS: 4D GWCS object. + """ + + time_model = models.Identity(1) + time_frame = cf.TemporalFrame(axes_order=(0, ), unit=u.s, + reference_frame=Time("2000-01-01T00:00:00")) + + wave_frame = cf.SpectralFrame(axes_order=(1, ), unit=u.m, axes_names=('wavelength',)) + wave_model = models.Scale(0.2) + + shift = models.Shift(-5) & models.Shift(0) + scale = models.Scale(5) & models.Scale(20) + tan = models.Pix2Sky_TAN() + celestial_rotation = models.RotateNative2Celestial(0, 0, 180) + cel_model = shift | scale | tan | celestial_rotation + sky_frame = cf.CelestialFrame(axes_order=(2, 3), name='icrs', + reference_frame=coord.ICRS(), + axes_names=("longitude", "latitude")) + + transform = time_model & wave_model & cel_model + + frame = cf.CompositeFrame([time_frame, wave_frame, sky_frame]) + detector_frame = cf.CoordinateFrame(name="detector", naxes=4, + axes_order=(0, 1, 2, 3), + axes_type=("pixel", "pixel", "pixel", "pixel"), + unit=(u.pix, u.pix, u.pix, u.pix)) + + return (wcs.WCS(forward_transform=transform, output_frame=frame, input_frame=detector_frame)) + +@pytest.fixture +def gwcs_3d_lt_ln_l(): + """ + Creates a 3D GWCS object with celestial coordinates and wavelength. + + - Sky: Axes 0 and 1 + - Wavelength: Axis 2 + + Returns: + wcs.WCS: 3D GWCS object. + """ + + shift = models.Shift(-5) & models.Identity(1) + scale = models.Scale(5) & models.Scale(10) + tan = models.Pix2Sky_TAN() + celestial_rotation = models.RotateNative2Celestial(0, 0, 180) + cel_model = shift | scale | tan | celestial_rotation + sky_frame = cf.CelestialFrame(axes_order=(0, 1), name='icrs', + reference_frame=coord.ICRS(), + axes_names=("longitude", "latitude")) + + wave_model = models.Identity(1) | models.Scale(0.2) | models.Shift(10) + wave_frame = cf.SpectralFrame(axes_order=(2, ), unit=u.nm, axes_names=("wavelength",)) + + transform = cel_model & wave_model + + frame = cf.CompositeFrame([sky_frame, wave_frame]) + detector_frame = cf.CoordinateFrame(name="detector", naxes=3, + axes_order=(0, 1, 2), + axes_type=("pixel", "pixel", "pixel"), + axes_names=("x", "y", "z"), unit=(u.pix, u.pix, u.pix)) + + return (wcs.WCS(forward_transform=transform, output_frame=frame, input_frame=detector_frame)) + +@pytest.fixture +def gwcs_3d_ln_lt_t_rotated(): + """ + Creates a 3D GWCS object with celestial coordinates and wavelength, including rotation. + + - Sky: Axes 0 and 1 + - Wavelength: Axis 2 + + Returns: + wcs.WCS: 3D GWCS object with rotation. + """ + shift = models.Shift(-5) & models.Identity(1) + scale = models.Scale(5) & models.Scale(10) + matrix = np.array([[1.290551569736E-05, 5.9525007864732E-06], + [5.0226382102765E-06 , -1.2644844123757E-05]]) + rotation = models.AffineTransformation2D(matrix) + tan = models.Pix2Sky_TAN() + celestial_rotation = models.RotateNative2Celestial(0, 0, 180) + cel_model = shift | scale| rotation | tan | celestial_rotation + sky_frame = cf.CelestialFrame(axes_order=(0, 1), name='icrs', + reference_frame=coord.ICRS(), + axes_names=("longitude", "latitude")) + + wave_model = models.Identity(1) | models.Scale(0.2) | models.Shift(10) + wave_frame = cf.SpectralFrame(axes_order=(2, ), unit=u.nm, axes_names=("wavelength",)) + + transform = cel_model & wave_model + + frame = cf.CompositeFrame([sky_frame, wave_frame]) + detector_frame = cf.CoordinateFrame(name="detector", naxes=3, + axes_order=(0, 1, 2), + axes_type=("pixel", "pixel", "pixel"), + axes_names=("x", "y", "z"), unit=(u.pix, u.pix, u.pix)) + + return (wcs.WCS(forward_transform=transform, output_frame=frame, input_frame=detector_frame)) + +@pytest.fixture +def gwcs_2d_lt_ln(): + """ + Creates a 2D GWCS object with celestial coordinates. + + - Sky: Axes 0 and 1 + + Returns: + wcs.WCS: 2D GWCS object. + """ + shift = models.Shift(-5) & models.Shift(-5) + scale = models.Scale(2) & models.Scale(4) + tan = models.Pix2Sky_TAN() + celestial_rotation = models.RotateNative2Celestial(0, 0, 180) + cel_model = shift | scale | tan | celestial_rotation + input_frame = cf.Frame2D(name="detector", axes_names=("x", "y")) + sky_frame = cf.CelestialFrame(axes_order=(0, 1), name='icrs', + reference_frame=coord.ICRS(), + axes_names=("longitude", "latitude")) + + return (wcs.WCS(forward_transform=cel_model, output_frame=sky_frame, input_frame=input_frame)) @pytest.fixture def wcs_4d_t_l_lt_ln(): @@ -318,6 +456,71 @@ def extra_coords_sharing_axis(): # NDCube Fixtures ################################################################################ +@pytest.fixture +def ndcube_gwcs_4d_ln_lt_l_t(gwcs_4d_t_l_lt_ln): + shape = (5, 8, 10, 12) + gwcs_4d_t_l_lt_ln.array_shape = shape + data_cube = data_nd(shape) + return NDCube(data_cube, wcs=gwcs_4d_t_l_lt_ln) + + +@pytest.fixture +def ndcube_gwcs_4d_ln_lt_l_t_unit(gwcs_4d_t_l_lt_ln): + shape = (5, 8, 10, 12) + gwcs_4d_t_l_lt_ln.array_shape = shape + data_cube = data_nd(shape) + return NDCube(data_cube, wcs=gwcs_4d_t_l_lt_ln, unit=u.DN) + + +@pytest.fixture +def ndcube_gwcs_3d_ln_lt_l(gwcs_3d_lt_ln_l): + shape = (2, 3, 4) + gwcs_3d_lt_ln_l.array_shape = shape + data_cube = data_nd(shape) + return NDCube(data_cube, wcs=gwcs_3d_lt_ln_l) + +@pytest.fixture +def ndcube_gwcs_3d_rotated(gwcs_3d_lt_ln_l, simple_extra_coords_3d): + data_rotated = np.array([[[1, 2, 3, 4, 6], [2, 4, 5, 3, 1], [0, -1, 2, 4, 2], [3, 5, 1, 2, 0]], + [[2, 4, 5, 1, 3], [1, 5, 2, 2, 4], [2, 3, 4, 0, 5], [0, 1, 2, 3, 4]]]) + cube = NDCube( + data_rotated, + wcs=gwcs_3d_lt_ln_l) + cube._extra_coords = simple_extra_coords_3d + return cube + +@pytest.fixture +def ndcube_gwcs_3d_ln_lt_l_ec_dropped_dim(gwcs_3d_lt_ln_l, time_and_simple_extra_coords_2d): + shape = (2, 3, 4) + gwcs_3d_lt_ln_l.array_shape = shape + data_cube = data_nd(shape) + cube = NDCube(data_cube, wcs=gwcs_3d_lt_ln_l) + cube._extra_coords = time_and_simple_extra_coords_2d[0] + return cube + +@pytest.fixture +def ndcube_gwcs_3d_ln_lt_l_ec_q_t_gc(gwcs_3d_lt_ln_l): + shape = (3, 3, 4) + gwcs_3d_lt_ln_l.array_shape = shape + data_cube = data_nd(shape) + cube = NDCube(data_cube, wcs=gwcs_3d_lt_ln_l) + coord1 = 1 * u.m + cube.global_coords.add('name1', 'custom:physical_type1', coord1) + cube.extra_coords.add("time", 0, time_lut(shape)) + cube.extra_coords.add("exposure_lut", 1, range(shape[1]) * u.s) + return cube + +@pytest.fixture +def ndcube_gwcs_2d_ln_lt_mask(gwcs_2d_lt_ln): + shape = (10, 12) + data_cube = data_nd(shape) + mask = np.zeros(shape, dtype=bool) + mask[1, 1] = True + mask[2, 0] = True + mask[3, 3] = True + mask[4:6, :4] = True + return NDCube(data_cube, wcs=gwcs_2d_lt_ln, mask=mask) + @pytest.fixture def ndcube_4d_ln_l_t_lt(wcs_4d_lt_t_l_ln): shape = (5, 10, 12, 8) diff --git a/ndcube/extra_coords/table_coord.py b/ndcube/extra_coords/table_coord.py index 5ff887351..98b030f65 100644 --- a/ndcube/extra_coords/table_coord.py +++ b/ndcube/extra_coords/table_coord.py @@ -11,7 +11,7 @@ from astropy.coordinates import SkyCoord from astropy.modeling import models from astropy.modeling.models import tabular_model -from astropy.modeling.tabular import _Tabular +from astropy.modeling.tabular import Tabular1D, Tabular2D, _Tabular from astropy.time import Time from astropy.wcs.wcsapi.wrappers.sliced_wcs import combine_slices, sanitize_slices @@ -136,7 +136,17 @@ def _generate_tabular(lookup_table, interpolation='linear', points_unit=u.pix, * raise TypeError("lookup_table must be a Quantity.") # pragma: no cover ndim = lookup_table.ndim - TabularND = tabular_model(ndim, name=f"Tabular{ndim}D") + + # Use existing Tabular1D and Tabular2D classes for 1D and 2D models + # to ensure compatibility with asdf-astropy converters and avoid + # dynamically generated classes that are not recognized by asdf. + # See PR #751 for details on the issue this addresses. + if ndim == 1: + TabularND = Tabular1D + elif ndim == 2: + TabularND = Tabular2D + else: + TabularND = tabular_model(ndim, name=f"Tabular{ndim}D") # The integer location is at the centre of the pixel. points = [(np.arange(size) - 0) * points_unit for size in lookup_table.shape] diff --git a/pyproject.toml b/pyproject.toml index be5bd7c14..2098a5e2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,12 @@ exclude = ["ndcube._dev*"] [tool.setuptools_scm] write_to = "ndcube/_version.py" +[project.entry-points."asdf.resource_mappings"] +ndcube = "ndcube.asdf.entry_points:get_resource_mappings" + +[project.entry-points."asdf.extensions"] +ndcube = "ndcube.asdf.entry_points:get_extensions" + [tool.towncrier] package = "ndcube" filename = "CHANGELOG.rst" diff --git a/pytest.ini b/pytest.ini index 9528576c5..24c5d5bb5 100644 --- a/pytest.ini +++ b/pytest.ini @@ -25,6 +25,11 @@ addopts = -p no:threadexception -m "not mpl_image_compare" --doctest-ignore-import-errors +asdf_schema_tests_enabled = true +asdf_schema_root = ndcube/asdf/resources +remote_data_strict = True +doctest_subpackage_requires = + docs/explaining_ndcube/* = numpy>=2.0.0 --doctest-continue-on-failure mpl-results-path = figure_test_images mpl-use-full-test-name = true @@ -51,6 +56,3 @@ filterwarnings = ignore:Animating a NDCube does not support transposing the array. The world axes may not display as expected because the array will not be transposed:UserWarning # This is raised by the Windows and mac os build for visualization.rst ignore:FigureCanvasAgg is non-interactive, and thus cannot be shown:UserWarning -remote_data_strict = True -doctest_subpackage_requires = - docs/explaining_ndcube/* = numpy>=2.0.0 diff --git a/tox.ini b/tox.ini index d9000660f..5d5aae52c 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ envlist = py310-oldestdeps codestyle build_docs + asdf_schemas [testenv] # We use bash in some of our environments so we have to whitelist it. @@ -94,6 +95,16 @@ commands = {toxinidir}/docs \ {posargs} +[testenv:asdf_schemas] +description = Run schema tests +deps = + pytest + asdf +set_env = + asdf_schema_root = {toxinidir}/ndcube/asdf/resources +commands = + pytest {env:asdf_schema_root} + [testenv:build_docs] change_dir = docs description = Invoke sphinx-build to build the HTML docs