# Licensed under a 3-clause BSD style license - see LICENSE.rst
from contextlib import nullcontext

import numpy as np
import pytest
from numpy.testing import assert_allclose, assert_almost_equal, assert_equal
from packaging.version import Version

from astropy import units as u
from astropy.coordinates import ITRS, BaseCoordinateFrame, EarthLocation, SkyCoord
from astropy.coordinates.representation import SphericalRepresentation
from astropy.coordinates.representation.geodetic import (
    BaseBodycentricRepresentation,
    BaseGeodeticRepresentation,
)

# Preserve the original REPRESENTATION_CLASSES dict so that importing
#   the test file doesn't add a persistent test subclass
from astropy.coordinates.tests.test_representation import (  # noqa: F401
    setup_function,
    teardown_function,
)
from astropy.io import fits
from astropy.time import Time
from astropy.units import Quantity
from astropy.utils import unbroadcast
from astropy.utils.compat.optional_deps import HAS_SCIPY
from astropy.utils.data import get_pkg_data_contents, get_pkg_data_filename
from astropy.utils.exceptions import AstropyUserWarning
from astropy.wcs.utils import (
    FRAME_WCS_MAPPINGS,
    WCS_FRAME_MAPPINGS,
    _pixel_to_pixel_correlation_matrix,
    _pixel_to_world_correlation_matrix,
    _split_matrix,
    add_stokes_axis_to_wcs,
    celestial_frame_to_wcs,
    custom_frame_to_wcs_mappings,
    custom_wcs_to_frame_mappings,
    fit_wcs_from_points,
    is_proj_plane_distorted,
    local_partial_pixel_derivatives,
    non_celestial_pixel_scales,
    obsgeo_to_frame,
    pixel_to_pixel,
    pixel_to_skycoord,
    proj_plane_pixel_scales,
    skycoord_to_pixel,
    wcs_to_celestial_frame,
)
from astropy.wcs.wcs import (
    WCS,
    WCSLIB_VERSION,
    WCSSUB_LATITUDE,
    WCSSUB_LONGITUDE,
    DistortionLookupTable,
    FITSFixedWarning,
    Sip,
)
from astropy.wcs.wcsapi.fitswcs import SlicedFITSWCS


def test_wcs_dropping():
    wcs = WCS(naxis=4)
    wcs.wcs.pc = np.zeros([4, 4])
    np.fill_diagonal(wcs.wcs.pc, np.arange(1, 5))
    pc = wcs.wcs.pc  # for later use below

    dropped = wcs.dropaxis(0)
    assert np.all(dropped.wcs.get_pc().diagonal() == np.array([2, 3, 4]))
    dropped = wcs.dropaxis(1)
    assert np.all(dropped.wcs.get_pc().diagonal() == np.array([1, 3, 4]))
    dropped = wcs.dropaxis(2)
    assert np.all(dropped.wcs.get_pc().diagonal() == np.array([1, 2, 4]))
    dropped = wcs.dropaxis(3)
    assert np.all(dropped.wcs.get_pc().diagonal() == np.array([1, 2, 3]))

    wcs = WCS(naxis=4)
    wcs.wcs.cd = pc

    dropped = wcs.dropaxis(0)
    assert np.all(dropped.wcs.get_pc().diagonal() == np.array([2, 3, 4]))
    dropped = wcs.dropaxis(1)
    assert np.all(dropped.wcs.get_pc().diagonal() == np.array([1, 3, 4]))
    dropped = wcs.dropaxis(2)
    assert np.all(dropped.wcs.get_pc().diagonal() == np.array([1, 2, 4]))
    dropped = wcs.dropaxis(3)
    assert np.all(dropped.wcs.get_pc().diagonal() == np.array([1, 2, 3]))


def test_wcs_swapping():
    wcs = WCS(naxis=4)
    wcs.wcs.pc = np.zeros([4, 4])
    np.fill_diagonal(wcs.wcs.pc, np.arange(1, 5))
    pc = wcs.wcs.pc  # for later use below

    swapped = wcs.swapaxes(0, 1)
    assert np.all(swapped.wcs.get_pc().diagonal() == np.array([2, 1, 3, 4]))
    swapped = wcs.swapaxes(0, 3)
    assert np.all(swapped.wcs.get_pc().diagonal() == np.array([4, 2, 3, 1]))
    swapped = wcs.swapaxes(2, 3)
    assert np.all(swapped.wcs.get_pc().diagonal() == np.array([1, 2, 4, 3]))

    wcs = WCS(naxis=4)
    wcs.wcs.cd = pc

    swapped = wcs.swapaxes(0, 1)
    assert np.all(swapped.wcs.get_pc().diagonal() == np.array([2, 1, 3, 4]))
    swapped = wcs.swapaxes(0, 3)
    assert np.all(swapped.wcs.get_pc().diagonal() == np.array([4, 2, 3, 1]))
    swapped = wcs.swapaxes(2, 3)
    assert np.all(swapped.wcs.get_pc().diagonal() == np.array([1, 2, 4, 3]))


@pytest.mark.parametrize("ndim", (2, 3))
def test_add_stokes(ndim):
    wcs = WCS(naxis=ndim)

    for ii in range(ndim + 1):
        outwcs = add_stokes_axis_to_wcs(wcs, ii)
        assert outwcs.wcs.naxis == ndim + 1
        assert outwcs.wcs.ctype[ii] == "STOKES"
        assert outwcs.wcs.cname[ii] == "STOKES"


def test_slice():
    mywcs = WCS(naxis=2)
    mywcs.wcs.crval = [1, 1]
    mywcs.wcs.cdelt = [0.1, 0.1]
    mywcs.wcs.crpix = [1, 1]
    mywcs._naxis = [1000, 500]
    pscale = 0.1  # from cdelt

    slice_wcs = mywcs.slice([slice(1, None), slice(0, None)])
    assert np.all(slice_wcs.wcs.crpix == np.array([1, 0]))
    assert slice_wcs._naxis == [1000, 499]

    # test that CRPIX maps to CRVAL:
    assert_allclose(
        slice_wcs.wcs_pix2world(*slice_wcs.wcs.crpix, 1),
        slice_wcs.wcs.crval,
        rtol=0.0,
        atol=1e-6 * pscale,
    )

    slice_wcs = mywcs.slice([slice(1, None, 2), slice(0, None, 4)])
    assert np.all(slice_wcs.wcs.crpix == np.array([0.625, 0.25]))
    assert np.all(slice_wcs.wcs.cdelt == np.array([0.4, 0.2]))
    assert slice_wcs._naxis == [250, 250]

    slice_wcs = mywcs.slice([slice(None, None, 2), slice(0, None, 2)])
    assert np.all(slice_wcs.wcs.cdelt == np.array([0.2, 0.2]))
    assert slice_wcs._naxis == [500, 250]

    # Non-integral values do not alter the naxis attribute
    with pytest.warns(AstropyUserWarning):
        slice_wcs = mywcs.slice([slice(50.0), slice(20.0)])
    assert slice_wcs._naxis == [1000, 500]
    with pytest.warns(AstropyUserWarning):
        slice_wcs = mywcs.slice([slice(50.0), slice(20)])
    assert slice_wcs._naxis == [20, 500]
    with pytest.warns(AstropyUserWarning):
        slice_wcs = mywcs.slice([slice(50), slice(20.5)])
    assert slice_wcs._naxis == [1000, 50]


def test_slice_with_sip():
    mywcs = WCS(naxis=2)
    mywcs.wcs.crval = [1, 1]
    mywcs.wcs.cdelt = [0.1, 0.1]
    mywcs.wcs.crpix = [1, 1]
    mywcs._naxis = [1000, 500]
    mywcs.wcs.ctype = ["RA---TAN-SIP", "DEC--TAN-SIP"]
    a = np.array(
        [
            [0, 0, 5.33092692e-08, 3.73753773e-11, -2.02111473e-13],
            [0, 2.44084308e-05, 2.81394789e-11, 5.17856895e-13, 0.0],
            [-2.41334657e-07, 1.29289255e-10, 2.35753629e-14, 0.0, 0.0],
            [-2.37162007e-10, 5.43714947e-13, 0.0, 0.0, 0.0],
            [-2.81029767e-13, 0.0, 0.0, 0.0, 0.0],
        ]
    )
    b = np.array(
        [
            [0, 0, 2.99270374e-05, -2.38136074e-10, 7.23205168e-13],
            [0, -1.71073858e-07, 6.31243431e-11, -5.16744347e-14, 0.0],
            [6.95458963e-06, -3.08278961e-10, -1.75800917e-13, 0.0, 0.0],
            [3.51974159e-11, 5.60993016e-14, 0.0, 0.0, 0.0],
            [-5.92438525e-13, 0.0, 0.0, 0.0, 0.0],
        ]
    )
    mywcs.sip = Sip(a, b, None, None, mywcs.wcs.crpix)
    mywcs.wcs.set()
    pscale = 0.1  # from cdelt

    slice_wcs = mywcs.slice([slice(1, None), slice(0, None)])
    # test that CRPIX maps to CRVAL:
    assert_allclose(
        slice_wcs.all_pix2world(*slice_wcs.wcs.crpix, 1),
        slice_wcs.wcs.crval,
        rtol=0.0,
        atol=1e-6 * pscale,
    )

    slice_wcs = mywcs.slice([slice(1, None, 2), slice(0, None, 4)])
    # test that CRPIX maps to CRVAL:
    assert_allclose(
        slice_wcs.all_pix2world(*slice_wcs.wcs.crpix, 1),
        slice_wcs.wcs.crval,
        rtol=0.0,
        atol=1e-6 * pscale,
    )


def test_slice_with_cpdis_tables():
    # A basic WCS
    mywcs = WCS(naxis=2)
    mywcs.wcs.crval = [1, 1]
    mywcs.wcs.cdelt = [0.1, 0.1]
    mywcs.wcs.crpix = [1, 1]
    mywcs.wcs.ctype = ["RA---TAN", "DEC--TAN"]

    # Arbitrary distortion maps for X and Y
    distortion_array = np.arange(25 * 25, dtype=np.float32).reshape((25, 25))
    mywcs.cpdis1 = DistortionLookupTable(distortion_array, (1, 1), (1, 1), (10, 10))
    mywcs.cpdis2 = DistortionLookupTable(distortion_array, (1, 1), (1, 1), (10, 10))

    # Test that equivalent pixels produce the same coordinates, whether or not
    # they've been sliced out.
    coord_from_slice = mywcs[40:, 50:].pixel_to_world(30, 60)
    coord_from_full = mywcs.pixel_to_world(50 + 30, 40 + 60)

    assert coord_from_full == coord_from_slice

    # Test the same with a step size. (Note, per discussion in gh-10897,
    # slicing a WCS means "binning", rather than "resampling", so there's a
    # quarter-pixel offset to get the "equivalent" spot. The centers of the
    # post-slice pixels are at the dividing line between the two "input" pixels
    # that form this binned, post-slice pixel.)
    coord_from_slice = mywcs[50::2, 50::2].pixel_to_world(24.75, 24.75)
    coord_from_full = mywcs.pixel_to_world(100, 100)

    assert coord_from_full == coord_from_slice


def test_slice_getitem():
    mywcs = WCS(naxis=2)
    mywcs.wcs.crval = [1, 1]
    mywcs.wcs.cdelt = [0.1, 0.1]
    mywcs.wcs.crpix = [1, 1]

    slice_wcs = mywcs[1::2, 0::4]
    assert np.all(slice_wcs.wcs.crpix == np.array([0.625, 0.25]))
    assert np.all(slice_wcs.wcs.cdelt == np.array([0.4, 0.2]))

    mywcs.wcs.crpix = [2, 2]
    slice_wcs = mywcs[1::2, 0::4]
    assert np.all(slice_wcs.wcs.crpix == np.array([0.875, 0.75]))
    assert np.all(slice_wcs.wcs.cdelt == np.array([0.4, 0.2]))

    # Default: numpy order
    slice_wcs = mywcs[1::2]
    assert np.all(slice_wcs.wcs.crpix == np.array([2, 0.75]))
    assert np.all(slice_wcs.wcs.cdelt == np.array([0.1, 0.2]))


def test_slice_fitsorder():
    mywcs = WCS(naxis=2)
    mywcs.wcs.crval = [1, 1]
    mywcs.wcs.cdelt = [0.1, 0.1]
    mywcs.wcs.crpix = [1, 1]

    slice_wcs = mywcs.slice([slice(1, None), slice(0, None)], numpy_order=False)
    assert np.all(slice_wcs.wcs.crpix == np.array([0, 1]))

    slice_wcs = mywcs.slice([slice(1, None, 2), slice(0, None, 4)], numpy_order=False)
    assert np.all(slice_wcs.wcs.crpix == np.array([0.25, 0.625]))
    assert np.all(slice_wcs.wcs.cdelt == np.array([0.2, 0.4]))

    slice_wcs = mywcs.slice([slice(1, None, 2)], numpy_order=False)
    assert np.all(slice_wcs.wcs.crpix == np.array([0.25, 1]))
    assert np.all(slice_wcs.wcs.cdelt == np.array([0.2, 0.1]))


def test_slice_wcs():
    mywcs = WCS(naxis=2)

    sub = mywcs[0]
    assert isinstance(sub, SlicedFITSWCS)

    with pytest.raises(IndexError, match="Slicing WCS with a step is not supported."):
        mywcs[0, ::2]


def test_slice_nd():
    # Regression test for a bug that caused slicing to not work correctly for
    # WCS with more than 2 dimensions.

    sub = WCS(naxis=3)[:, :, :]
    assert isinstance(sub, WCS)


def test_slice_ellipsis():
    # Regression test for a bug that caused slicing with an ellipsis to not
    # return a WCS object but a SlicedFITSWCS instead

    sub = WCS(naxis=3)[...]
    assert isinstance(sub, WCS)


def test_slice_drop_dimensions_order():
    # Regression test for a bug that caused WCS.slice to ignore
    # ``numpy_order=False`` if dimensions were dropped.

    wcs = WCS(naxis=3)
    wcs.wcs.ctype = "RA---TAN", "DEC--TAN", "FREQ"

    wcs_sliced_1 = wcs.slice([0, slice(None), slice(None)], numpy_order=True)
    assert wcs_sliced_1.world_axis_physical_types == ["pos.eq.ra", "pos.eq.dec"]

    wcs_sliced_2 = wcs.slice([slice(None), slice(None), 0], numpy_order=False)
    assert wcs_sliced_2.world_axis_physical_types == ["pos.eq.ra", "pos.eq.dec"]


def test_axis_names():
    mywcs = WCS(naxis=4)
    mywcs.wcs.ctype = ["RA---TAN", "DEC--TAN", "VOPT-LSR", "STOKES"]

    assert mywcs.axis_type_names == ["RA", "DEC", "VOPT", "STOKES"]

    mywcs.wcs.cname = ["RA", "DEC", "VOPT", "STOKES"]

    assert mywcs.axis_type_names == ["RA", "DEC", "VOPT", "STOKES"]


def test_celestial():
    mywcs = WCS(naxis=4)
    mywcs.wcs.ctype = ["RA---TAN", "DEC--TAN", "VOPT", "STOKES"]
    cel = mywcs.celestial
    assert tuple(cel.wcs.ctype) == ("RA---TAN", "DEC--TAN")
    assert cel.axis_type_names == ["RA", "DEC"]


def test_wcs_to_celestial_frame():
    # Import astropy.coordinates here to avoid circular imports
    from astropy.coordinates.builtin_frames import FK4, FK5, ICRS, ITRS, Galactic

    mywcs = WCS(naxis=2)
    mywcs.wcs.set()
    with pytest.raises(
        ValueError,
        match=(
            "Could not determine celestial frame "
            "corresponding to the specified WCS object"
        ),
    ):
        assert wcs_to_celestial_frame(mywcs) is None

    mywcs = WCS(naxis=2)
    mywcs.wcs.ctype = ["XOFFSET", "YOFFSET"]
    mywcs.wcs.set()
    with pytest.raises(ValueError):
        assert wcs_to_celestial_frame(mywcs) is None

    mywcs = WCS(naxis=2)
    mywcs.wcs.ctype = ["RA---TAN", "DEC--TAN"]
    mywcs.wcs.set()
    frame = wcs_to_celestial_frame(mywcs)
    assert isinstance(frame, ICRS)

    mywcs = WCS(naxis=2)
    mywcs.wcs.ctype = ["RA---TAN", "DEC--TAN"]
    mywcs.wcs.equinox = 1987.0
    mywcs.wcs.set()
    frame = wcs_to_celestial_frame(mywcs)
    assert isinstance(frame, FK5)
    assert frame.equinox == Time(1987.0, format="jyear")

    mywcs = WCS(naxis=2)
    mywcs.wcs.ctype = ["RA---TAN", "DEC--TAN"]
    mywcs.wcs.equinox = 1982
    mywcs.wcs.set()
    frame = wcs_to_celestial_frame(mywcs)
    assert isinstance(frame, FK4)
    assert frame.equinox == Time(1982.0, format="byear")

    mywcs = WCS(naxis=2)
    mywcs.wcs.ctype = ["GLON-SIN", "GLAT-SIN"]
    mywcs.wcs.set()
    frame = wcs_to_celestial_frame(mywcs)
    assert isinstance(frame, Galactic)

    mywcs = WCS(naxis=2)
    mywcs.wcs.ctype = ["TLON-CAR", "TLAT-CAR"]
    mywcs.wcs.dateobs = "2017-08-17T12:41:04.430"
    mywcs.wcs.set()
    frame = wcs_to_celestial_frame(mywcs)
    assert isinstance(frame, ITRS)
    assert frame.obstime == Time("2017-08-17T12:41:04.430")

    for equinox in [np.nan, 1987, 1982]:
        mywcs = WCS(naxis=2)
        mywcs.wcs.ctype = ["RA---TAN", "DEC--TAN"]
        mywcs.wcs.radesys = "ICRS"
        mywcs.wcs.equinox = equinox
        mywcs.wcs.set()
        frame = wcs_to_celestial_frame(mywcs)
        assert isinstance(frame, ICRS)

    # Flipped order
    mywcs = WCS(naxis=2)
    mywcs.wcs.ctype = ["DEC--TAN", "RA---TAN"]
    mywcs.wcs.set()
    frame = wcs_to_celestial_frame(mywcs)
    assert isinstance(frame, ICRS)

    # More than two dimensions
    mywcs = WCS(naxis=3)
    mywcs.wcs.ctype = ["DEC--TAN", "VELOCITY", "RA---TAN"]
    mywcs.wcs.set()
    frame = wcs_to_celestial_frame(mywcs)
    assert isinstance(frame, ICRS)

    mywcs = WCS(naxis=3)
    mywcs.wcs.ctype = ["GLAT-CAR", "VELOCITY", "GLON-CAR"]
    mywcs.wcs.set()
    frame = wcs_to_celestial_frame(mywcs)
    assert isinstance(frame, Galactic)


def test_wcs_to_body_frame():
    mywcs = WCS(naxis=2)
    mywcs.wcs.ctype = ["VELN-TAN", "VELT-TAN"]
    mywcs.wcs.dateobs = "2017-08-17T12:41:04.430"
    mywcs.wcs.name = "Venus Geodetic Body-Fixed"

    mywcs.wcs.aux.a_radius = 6051800.0
    mywcs.wcs.aux.b_radius = 6051800.0
    mywcs.wcs.aux.c_radius = 6051800.0
    framev = wcs_to_celestial_frame(mywcs)
    assert issubclass(framev, BaseCoordinateFrame)
    assert issubclass(framev.representation_type, BaseGeodeticRepresentation)
    assert framev.name == "Venus"
    assert framev.representation_type._equatorial_radius == 6051800.0 * u.m
    assert framev.representation_type._flattening == 0.0

    # Check that frames are cached appropriately
    framev2 = wcs_to_celestial_frame(mywcs)
    assert framev2.representation_type is framev.representation_type
    assert framev2 is framev

    mywcs = WCS(naxis=2)
    mywcs.wcs.ctype = ["MALN-TAN", "MALT-TAN"]
    mywcs.wcs.dateobs = "2017-08-17T12:41:04.430"
    mywcs.wcs.name = "Mars Bodycentric Body-Fixed"

    mywcs.wcs.aux.a_radius = 3396190.0
    mywcs.wcs.aux.b_radius = 3396190.0
    mywcs.wcs.aux.c_radius = 3376190.0
    framem = wcs_to_celestial_frame(mywcs)
    assert issubclass(framem, BaseCoordinateFrame)
    assert issubclass(framem.representation_type, BaseBodycentricRepresentation)
    assert framem.name == "Mars"
    assert framem.representation_type._equatorial_radius == 3396190.0 * u.m
    assert_almost_equal(framem.representation_type._flattening, 0.005888952031541227)
    assert framem.representation_type is not framev.representation_type
    assert framem is not framev

    mywcs = WCS(naxis=2)
    mywcs.wcs.ctype = ["EALN-TAN", "EALT-TAN"]
    mywcs.wcs.name = "Earth Geodetic Body-Fixed"
    mywcs.wcs.aux.a_radius = 6378137.0
    mywcs.wcs.aux.b_radius = 6378137.0
    mywcs.wcs.aux.c_radius = 6356752.3
    mywcs.wcs.set()
    frame = wcs_to_celestial_frame(mywcs)
    assert issubclass(frame, BaseCoordinateFrame)
    assert issubclass(frame.representation_type, BaseGeodeticRepresentation)
    assert frame.representation_type._equatorial_radius == 6378137.0 * u.m
    assert_almost_equal(frame.representation_type._flattening, 0.0033528128981864433)

    unknown_wcs = WCS(naxis=2)
    unknown_wcs.wcs.ctype = ["UTLN-TAN", "UTLT-TAN"]
    with pytest.raises(
        ValueError,
        match="Could not determine celestial frame corresponding to the specified WCS object",
    ):
        frame = wcs_to_celestial_frame(unknown_wcs)

    triaxial_wcs = WCS(naxis=2)
    triaxial_wcs.wcs.ctype = ["MELN-TAN", "MELT-TAN"]
    triaxial_wcs.wcs.aux.a_radius = 2439700.0
    triaxial_wcs.wcs.aux.b_radius = 2439900.0
    triaxial_wcs.wcs.aux.c_radius = 2438800.0
    with pytest.raises(
        NotImplementedError, match="triaxial systems are not supported at this time"
    ):
        frame = wcs_to_celestial_frame(triaxial_wcs)


def test_wcs_to_celestial_frame_correlated():
    # Regression test for a bug that caused wcs_to_celestial_frame to fail when
    # the celestial axes were correlated with other axes.

    # Import astropy.coordinates here to avoid circular imports
    from astropy.coordinates.builtin_frames import ICRS

    mywcs = WCS(naxis=3)
    mywcs.wcs.ctype = "RA---TAN", "DEC--TAN", "FREQ"
    mywcs.wcs.cd = np.ones((3, 3))
    mywcs.wcs.set()
    frame = wcs_to_celestial_frame(mywcs)
    assert isinstance(frame, ICRS)


def test_wcs_to_celestial_frame_extend():
    mywcs = WCS(naxis=2)
    mywcs.wcs.ctype = ["XOFFSET", "YOFFSET"]
    mywcs.wcs.set()
    with pytest.raises(ValueError):
        wcs_to_celestial_frame(mywcs)

    class OffsetFrame:
        pass

    def identify_offset(wcs):
        if wcs.wcs.ctype[0].endswith("OFFSET") and wcs.wcs.ctype[1].endswith("OFFSET"):
            return OffsetFrame()

    with custom_wcs_to_frame_mappings(identify_offset):
        frame = wcs_to_celestial_frame(mywcs)
    assert isinstance(frame, OffsetFrame)

    # Check that things are back to normal after the context manager
    with pytest.raises(ValueError):
        wcs_to_celestial_frame(mywcs)


def test_celestial_frame_to_wcs():
    # Import astropy.coordinates here to avoid circular imports
    from astropy.coordinates import (
        FK4,
        FK5,
        ICRS,
        ITRS,
        BaseCoordinateFrame,
        FK4NoETerms,
        Galactic,
    )

    class FakeFrame(BaseCoordinateFrame):
        pass

    frame = FakeFrame()
    with pytest.raises(
        ValueError,
        match=(
            r"Could not determine WCS corresponding to the specified coordinate frame."
        ),
    ):
        celestial_frame_to_wcs(frame)

    frame = ICRS()
    mywcs = celestial_frame_to_wcs(frame)
    mywcs.wcs.set()
    assert tuple(mywcs.wcs.ctype) == ("RA---TAN", "DEC--TAN")
    assert mywcs.wcs.radesys == "ICRS"
    assert np.isnan(mywcs.wcs.equinox)
    assert mywcs.wcs.lonpole == 180
    assert mywcs.wcs.latpole == 0

    frame = FK5(equinox="J1987")
    mywcs = celestial_frame_to_wcs(frame)
    assert tuple(mywcs.wcs.ctype) == ("RA---TAN", "DEC--TAN")
    assert mywcs.wcs.radesys == "FK5"
    assert mywcs.wcs.equinox == 1987.0

    frame = FK4(equinox="B1982")
    mywcs = celestial_frame_to_wcs(frame)
    assert tuple(mywcs.wcs.ctype) == ("RA---TAN", "DEC--TAN")
    assert mywcs.wcs.radesys == "FK4"
    assert mywcs.wcs.equinox == 1982.0

    frame = FK4NoETerms(equinox="B1982")
    mywcs = celestial_frame_to_wcs(frame)
    assert tuple(mywcs.wcs.ctype) == ("RA---TAN", "DEC--TAN")
    assert mywcs.wcs.radesys == "FK4-NO-E"
    assert mywcs.wcs.equinox == 1982.0

    frame = Galactic()
    mywcs = celestial_frame_to_wcs(frame)
    assert tuple(mywcs.wcs.ctype) == ("GLON-TAN", "GLAT-TAN")
    assert mywcs.wcs.radesys == ""
    assert np.isnan(mywcs.wcs.equinox)

    frame = Galactic()
    mywcs = celestial_frame_to_wcs(frame, projection="CAR")
    assert tuple(mywcs.wcs.ctype) == ("GLON-CAR", "GLAT-CAR")
    assert mywcs.wcs.radesys == ""
    assert np.isnan(mywcs.wcs.equinox)

    frame = Galactic()
    mywcs = celestial_frame_to_wcs(frame, projection="CAR")
    mywcs.wcs.crval = [100, -30]
    mywcs.wcs.set()
    assert_allclose((mywcs.wcs.lonpole, mywcs.wcs.latpole), (180, 60))

    frame = ITRS(obstime=Time("2017-08-17T12:41:04.43"))
    mywcs = celestial_frame_to_wcs(frame, projection="CAR")
    assert tuple(mywcs.wcs.ctype) == ("TLON-CAR", "TLAT-CAR")
    assert mywcs.wcs.radesys == "ITRS"
    assert mywcs.wcs.dateobs == "2017-08-17T12:41:04.430"

    frame = ITRS()
    mywcs = celestial_frame_to_wcs(frame, projection="CAR")
    assert tuple(mywcs.wcs.ctype) == ("TLON-CAR", "TLAT-CAR")
    assert mywcs.wcs.radesys == "ITRS"
    assert mywcs.wcs.dateobs == Time("J2000").utc.fits


def test_body_to_wcs_frame():
    class IAUMARS2000GeodeticRepresentation(BaseGeodeticRepresentation):
        _equatorial_radius = 3396190.0 * u.m
        _flattening = 0.5886007555512007 * u.percent

    class IAUMARS2000BodycentricRepresentation(BaseBodycentricRepresentation):
        _equatorial_radius = 3396190.0 * u.m
        _flattening = 0.5886007555512007 * u.percent

    class IAUMARS2000BodyFrame(BaseCoordinateFrame):
        name = "Mars"

    frame = IAUMARS2000BodyFrame()

    frame.representation_type = IAUMARS2000GeodeticRepresentation

    mywcs = celestial_frame_to_wcs(frame, projection="CAR")
    assert mywcs.wcs.ctype[0] == "MALN-CAR"
    assert mywcs.wcs.ctype[1] == "MALT-CAR"
    assert mywcs.wcs.name == "Planetographic Body-Fixed"
    assert mywcs.wcs.aux.a_radius == 3396190.0
    assert mywcs.wcs.aux.b_radius == 3396190.0
    assert_almost_equal(mywcs.wcs.aux.c_radius, 3376200.0)

    frame.representation_type = IAUMARS2000BodycentricRepresentation
    mywcs = celestial_frame_to_wcs(frame, projection="CAR")
    assert mywcs.wcs.ctype[0] == "MALN-CAR"
    assert mywcs.wcs.ctype[1] == "MALT-CAR"
    assert mywcs.wcs.name == "Bodycentric Body-Fixed"

    assert mywcs.wcs.aux.a_radius == 3396190.0
    assert mywcs.wcs.aux.b_radius == 3396190.0
    assert_almost_equal(mywcs.wcs.aux.c_radius, 3376200.0)

    class IAUMARSSphereFrame(BaseCoordinateFrame):
        name = "Mars"
        representation_type = SphericalRepresentation

    frame = IAUMARSSphereFrame()

    with pytest.raises(
        ValueError,
        match="Planetary coordinates in WCS require a geodetic or bodycentric",
    ):
        celestial_frame_to_wcs(frame, projection="CAR")


def test_celestial_frame_to_wcs_extend():
    class OffsetFrame:
        pass

    frame = OffsetFrame()

    with pytest.raises(ValueError):
        celestial_frame_to_wcs(frame)

    def identify_offset(frame, projection=None):
        if isinstance(frame, OffsetFrame):
            wcs = WCS(naxis=2)
            wcs.wcs.ctype = ["XOFFSET", "YOFFSET"]
            return wcs

    with custom_frame_to_wcs_mappings(identify_offset):
        mywcs = celestial_frame_to_wcs(frame)
    assert tuple(mywcs.wcs.ctype) == ("XOFFSET", "YOFFSET")

    # Check that things are back to normal after the context manager
    with pytest.raises(ValueError):
        celestial_frame_to_wcs(frame)


def test_pixscale_nodrop():
    mywcs = WCS(naxis=2)
    mywcs.wcs.cdelt = [0.1, 0.2]
    mywcs.wcs.ctype = ["RA---TAN", "DEC--TAN"]
    assert_almost_equal(proj_plane_pixel_scales(mywcs), (0.1, 0.2))

    mywcs.wcs.cdelt = [-0.1, 0.2]
    assert_almost_equal(proj_plane_pixel_scales(mywcs), (0.1, 0.2))


def test_pixscale_withdrop():
    mywcs = WCS(naxis=3)
    mywcs.wcs.cdelt = [0.1, 0.2, 1]
    mywcs.wcs.ctype = ["RA---TAN", "DEC--TAN", "VOPT"]
    assert_almost_equal(proj_plane_pixel_scales(mywcs.celestial), (0.1, 0.2))

    mywcs.wcs.cdelt = [-0.1, 0.2, 1]
    assert_almost_equal(proj_plane_pixel_scales(mywcs.celestial), (0.1, 0.2))


def test_pixscale_cd():
    mywcs = WCS(naxis=2)
    mywcs.wcs.cd = [[-0.1, 0], [0, 0.2]]
    mywcs.wcs.ctype = ["RA---TAN", "DEC--TAN"]
    assert_almost_equal(proj_plane_pixel_scales(mywcs), (0.1, 0.2))


@pytest.mark.parametrize("angle", (30, 45, 60, 75))
def test_pixscale_cd_rotated(angle):
    mywcs = WCS(naxis=2)
    rho = np.radians(angle)
    scale = 0.1
    mywcs.wcs.cd = [
        [scale * np.cos(rho), -scale * np.sin(rho)],
        [scale * np.sin(rho), scale * np.cos(rho)],
    ]
    mywcs.wcs.ctype = ["RA---TAN", "DEC--TAN"]
    assert_almost_equal(proj_plane_pixel_scales(mywcs), (0.1, 0.1))


@pytest.mark.parametrize("angle", (30, 45, 60, 75))
def test_pixscale_pc_rotated(angle):
    mywcs = WCS(naxis=2)
    rho = np.radians(angle)
    scale = 0.1
    mywcs.wcs.cdelt = [-scale, scale]
    mywcs.wcs.pc = [[np.cos(rho), -np.sin(rho)], [np.sin(rho), np.cos(rho)]]
    mywcs.wcs.ctype = ["RA---TAN", "DEC--TAN"]
    assert_almost_equal(proj_plane_pixel_scales(mywcs), (0.1, 0.1))


@pytest.mark.parametrize(
    ("cdelt", "pc", "pccd"),
    (
        ([0.1, 0.2], np.eye(2), np.diag([0.1, 0.2])),
        ([0.1, 0.2, 0.3], np.eye(3), np.diag([0.1, 0.2, 0.3])),
        ([1, 1, 1], np.diag([0.1, 0.2, 0.3]), np.diag([0.1, 0.2, 0.3])),
    ),
)
def test_pixel_scale_matrix(cdelt, pc, pccd):
    mywcs = WCS(naxis=(len(cdelt)))
    mywcs.wcs.cdelt = cdelt
    mywcs.wcs.pc = pc

    assert_almost_equal(mywcs.pixel_scale_matrix, pccd)


@pytest.mark.parametrize(
    ("ctype", "cel"),
    (
        (["RA---TAN", "DEC--TAN"], True),
        (["RA---TAN", "DEC--TAN", "FREQ"], False),
        (["RA---TAN", "FREQ"], False),
    ),
)
def test_is_celestial(ctype, cel):
    mywcs = WCS(naxis=len(ctype))
    mywcs.wcs.ctype = ctype

    assert mywcs.is_celestial == cel


@pytest.mark.parametrize(
    ("ctype", "cel"),
    (
        (["RA---TAN", "DEC--TAN"], True),
        (["RA---TAN", "DEC--TAN", "FREQ"], True),
        (["RA---TAN", "FREQ"], False),
    ),
)
def test_has_celestial(ctype, cel):
    mywcs = WCS(naxis=len(ctype))
    mywcs.wcs.ctype = ctype

    assert mywcs.has_celestial == cel


def test_has_celestial_correlated():
    # Regression test for astropy/astropy#8416 - has_celestial failed when
    # celestial axes were correlated with other axes.
    mywcs = WCS(naxis=3)
    mywcs.wcs.ctype = "RA---TAN", "DEC--TAN", "FREQ"
    mywcs.wcs.cd = np.ones((3, 3))
    mywcs.wcs.set()
    assert mywcs.has_celestial


@pytest.mark.parametrize(
    ("cdelt", "pc", "cd", "check_warning"),
    (
        (np.array([0.1, 0.2]), np.eye(2), np.eye(2), True),
        (np.array([1, 1]), np.diag([0.1, 0.2]), np.eye(2), True),
        (np.array([0.1, 0.2]), np.eye(2), None, False),
        (np.array([0.1, 0.2]), None, np.eye(2), True),
    ),
)
def test_noncelestial_scale(cdelt, pc, cd, check_warning):
    mywcs = WCS(naxis=2)
    if cd is not None:
        mywcs.wcs.cd = cd
    if pc is not None:
        mywcs.wcs.pc = pc

    # TODO: Some inputs emit RuntimeWarning from here onwards.
    #       Fix the test data. See @nden's comment in PR 9010.
    if check_warning:
        ctx = pytest.warns()
    else:
        ctx = nullcontext()
    with ctx as warning_lines:
        mywcs.wcs.cdelt = cdelt
    if check_warning:
        for w in warning_lines:
            assert issubclass(w.category, RuntimeWarning)
            assert "cdelt will be ignored since cd is present" in str(w.message)

    mywcs.wcs.ctype = ["RA---TAN", "FREQ"]

    ps = non_celestial_pixel_scales(mywcs)

    assert_almost_equal(ps.to_value(u.deg), np.array([0.1, 0.2]))


@pytest.mark.parametrize("mode", ["all", "wcs"])
def test_skycoord_to_pixel(mode):
    # Import astropy.coordinates here to avoid circular imports
    from astropy.coordinates import SkyCoord

    header = get_pkg_data_contents("data/maps/1904-66_TAN.hdr", encoding="binary")
    wcs = WCS(header)

    ref = SkyCoord(0.1 * u.deg, -89.0 * u.deg, frame="icrs")

    xp, yp = skycoord_to_pixel(ref, wcs, mode=mode)

    # WCS is in FK5 so we need to transform back to ICRS
    new = pixel_to_skycoord(xp, yp, wcs, mode=mode).transform_to("icrs")

    assert_allclose(new.ra.degree, ref.ra.degree)
    assert_allclose(new.dec.degree, ref.dec.degree)

    # Make sure you can specify a different class using ``cls`` keyword
    class SkyCoord2(SkyCoord):
        pass

    new2 = pixel_to_skycoord(xp, yp, wcs, mode=mode, cls=SkyCoord2).transform_to("icrs")

    assert new2.__class__ is SkyCoord2
    assert_allclose(new2.ra.degree, ref.ra.degree)
    assert_allclose(new2.dec.degree, ref.dec.degree)


def test_skycoord_to_pixel_swapped():
    # Regression test for a bug that caused skycoord_to_pixel and
    # pixel_to_skycoord to not work correctly if the axes were swapped in the
    # WCS.

    # Import astropy.coordinates here to avoid circular imports
    from astropy.coordinates import SkyCoord

    header = get_pkg_data_contents("data/maps/1904-66_TAN.hdr", encoding="binary")
    wcs = WCS(header)

    wcs_swapped = wcs.sub([WCSSUB_LATITUDE, WCSSUB_LONGITUDE])

    ref = SkyCoord(0.1 * u.deg, -89.0 * u.deg, frame="icrs")

    xp1, yp1 = skycoord_to_pixel(ref, wcs)
    xp2, yp2 = skycoord_to_pixel(ref, wcs_swapped)

    assert_allclose(xp1, xp2)
    assert_allclose(yp1, yp2)

    # WCS is in FK5 so we need to transform back to ICRS
    new1 = pixel_to_skycoord(xp1, yp1, wcs).transform_to("icrs")
    new2 = pixel_to_skycoord(xp1, yp1, wcs_swapped).transform_to("icrs")

    assert_allclose(new1.ra.degree, new2.ra.degree)
    assert_allclose(new1.dec.degree, new2.dec.degree)


def test_is_proj_plane_distorted():
    # non-orthogonal CD:
    wcs = WCS(naxis=2)
    wcs.wcs.cd = [[-0.1, 0], [0, 0.2]]
    wcs.wcs.ctype = ["RA---TAN", "DEC--TAN"]
    assert is_proj_plane_distorted(wcs)

    # almost orthogonal CD:
    wcs.wcs.cd = [[0.1 + 2.0e-7, 1.7e-7], [1.2e-7, 0.1 - 1.3e-7]]
    assert not is_proj_plane_distorted(wcs)

    # real case:
    header = get_pkg_data_filename("data/sip.fits")
    with pytest.warns(FITSFixedWarning):
        wcs = WCS(header)
    assert is_proj_plane_distorted(wcs)


@pytest.mark.parametrize("mode", ["all", "wcs"])
def test_skycoord_to_pixel_distortions(mode):
    # Import astropy.coordinates here to avoid circular imports
    from astropy.coordinates import SkyCoord

    header = get_pkg_data_filename("data/sip.fits")
    with pytest.warns(FITSFixedWarning):
        wcs = WCS(header)

    ref = SkyCoord(202.50 * u.deg, 47.19 * u.deg, frame="icrs")

    xp, yp = skycoord_to_pixel(ref, wcs, mode=mode)

    # WCS is in FK5 so we need to transform back to ICRS
    new = pixel_to_skycoord(xp, yp, wcs, mode=mode).transform_to("icrs")

    assert_allclose(new.ra.degree, ref.ra.degree)
    assert_allclose(new.dec.degree, ref.dec.degree)


@pytest.fixture
def spatial_wcs_2d_small_angle():
    """
    This WCS has an almost linear correlation between the pixel and world axes
    close to the reference pixel.
    """
    wcs = WCS(naxis=2)
    wcs.wcs.ctype = ["HPLN-TAN", "HPLT-TAN"]
    wcs.wcs.crpix = [3.0] * 2
    wcs.wcs.cdelt = [0.002] * 2
    wcs.wcs.crval = [0] * 2
    wcs.wcs.set()
    return wcs


def test_local_pixel_derivatives(spatial_wcs_2d_small_angle):
    not_diag = np.logical_not(np.diag([1, 1]))
    # At (or close to) the reference pixel this should equal the cdelt
    derivs = local_partial_pixel_derivatives(spatial_wcs_2d_small_angle, 3, 3)
    np.testing.assert_allclose(np.diag(derivs), spatial_wcs_2d_small_angle.wcs.cdelt)
    np.testing.assert_allclose(derivs[not_diag].flat, [0, 0], atol=1e-10)

    # Far away from the reference pixel this should not equal the cdelt
    derivs = local_partial_pixel_derivatives(spatial_wcs_2d_small_angle, 3e4, 3e4)
    assert not np.allclose(np.diag(derivs), spatial_wcs_2d_small_angle.wcs.cdelt)

    # At (or close to) the reference pixel this should equal the cdelt
    derivs = local_partial_pixel_derivatives(
        spatial_wcs_2d_small_angle, 3, 3, normalize_by_world=True
    )
    np.testing.assert_allclose(np.diag(derivs), [1, 1])
    np.testing.assert_allclose(derivs[not_diag].flat, [0, 0], atol=1e-8)


def test_local_pixel_derivatives_cube():
    cube_wcs = WCS(naxis=3)
    cube_wcs.wcs.ctype = "RA---TAN", "DEC--TAN", "FREQ"
    cube_wcs.wcs.crval = 10, 20, 30
    cube_wcs.wcs.cdelt = 0.0001, 0.0001, 0.01
    cube_wcs.wcs.cunit = "deg", "deg", "GHz"
    cube_wcs.wcs.set()

    derivs = local_partial_pixel_derivatives(cube_wcs, 0, 0, 0)
    np.testing.assert_allclose(
        derivs, [[0.0001, 0, 0], [0, 0.0001, 0], [0, 0, 1e7]], rtol=0.1, atol=1e-5
    )

    derivs = local_partial_pixel_derivatives(cube_wcs, 0, 0, 0, normalize_by_world=True)
    np.testing.assert_allclose(
        derivs, [[1, 0, 0], [0, 1, 0], [0, 0, 1]], rtol=0.1, atol=1e-5
    )

    # Slice WCS so that there are two pixel and three world coordinates
    sliced_wcs = cube_wcs[:, 0, :]

    derivs = local_partial_pixel_derivatives(sliced_wcs, 0, 0, 0)
    np.testing.assert_allclose(
        derivs, [[0.0001, 0], [0, 0], [0, 1e7]], rtol=0.1, atol=1e-5
    )

    derivs = local_partial_pixel_derivatives(
        sliced_wcs, 0, 0, 0, normalize_by_world=True
    )
    np.testing.assert_allclose(derivs, [[1, 0], [1, 0], [0, 1]], rtol=0.1, atol=1e-5)


def test_pixel_to_world_correlation_matrix_celestial():
    wcs = WCS(naxis=2)
    wcs.wcs.ctype = "RA---TAN", "DEC--TAN"
    wcs.wcs.set()

    assert_equal(wcs.axis_correlation_matrix, [[1, 1], [1, 1]])
    matrix, classes = _pixel_to_world_correlation_matrix(wcs)
    assert_equal(matrix, [[1, 1]])
    assert classes == [SkyCoord]


def test_pixel_to_world_correlation_matrix_spectral_cube_uncorrelated():
    wcs = WCS(naxis=3)
    wcs.wcs.ctype = "RA---TAN", "FREQ", "DEC--TAN"
    wcs.wcs.set()

    assert_equal(wcs.axis_correlation_matrix, [[1, 0, 1], [0, 1, 0], [1, 0, 1]])
    matrix, classes = _pixel_to_world_correlation_matrix(wcs)
    assert_equal(matrix, [[1, 0, 1], [0, 1, 0]])
    assert classes == [SkyCoord, Quantity]


def test_pixel_to_world_correlation_matrix_spectral_cube_correlated():
    wcs = WCS(naxis=3)
    wcs.wcs.ctype = "RA---TAN", "FREQ", "DEC--TAN"
    wcs.wcs.cd = np.ones((3, 3))
    wcs.wcs.set()

    assert_equal(wcs.axis_correlation_matrix, [[1, 1, 1], [1, 1, 1], [1, 1, 1]])
    matrix, classes = _pixel_to_world_correlation_matrix(wcs)
    assert_equal(matrix, [[1, 1, 1], [1, 1, 1]])
    assert classes == [SkyCoord, Quantity]


def test_pixel_to_pixel_correlation_matrix_celestial():
    wcs_in = WCS(naxis=2)
    wcs_in.wcs.ctype = "RA---TAN", "DEC--TAN"
    wcs_in.wcs.set()

    wcs_out = WCS(naxis=2)
    wcs_out.wcs.ctype = "DEC--TAN", "RA---TAN"
    wcs_out.wcs.set()

    matrix = _pixel_to_pixel_correlation_matrix(wcs_in, wcs_out)
    assert_equal(matrix, [[1, 1], [1, 1]])


def test_pixel_to_pixel_correlation_matrix_spectral_cube_uncorrelated():
    wcs_in = WCS(naxis=3)
    wcs_in.wcs.ctype = "RA---TAN", "DEC--TAN", "FREQ"
    wcs_in.wcs.set()

    wcs_out = WCS(naxis=3)
    wcs_out.wcs.ctype = "DEC--TAN", "FREQ", "RA---TAN"
    wcs_out.wcs.set()

    matrix = _pixel_to_pixel_correlation_matrix(wcs_in, wcs_out)
    assert_equal(matrix, [[1, 1, 0], [0, 0, 1], [1, 1, 0]])


def test_pixel_to_pixel_correlation_matrix_spectral_cube_correlated():
    # NOTE: only make one of the WCSes have correlated axes to really test this

    wcs_in = WCS(naxis=3)
    wcs_in.wcs.ctype = "RA---TAN", "DEC--TAN", "FREQ"
    wcs_in.wcs.set()

    wcs_out = WCS(naxis=3)
    wcs_out.wcs.ctype = "DEC--TAN", "FREQ", "RA---TAN"
    wcs_out.wcs.cd = np.ones((3, 3))
    wcs_out.wcs.set()

    matrix = _pixel_to_pixel_correlation_matrix(wcs_in, wcs_out)
    assert_equal(matrix, [[1, 1, 1], [1, 1, 1], [1, 1, 1]])


def test_pixel_to_pixel_correlation_matrix_mismatch():
    wcs_in = WCS(naxis=2)
    wcs_in.wcs.ctype = "RA---TAN", "DEC--TAN"
    wcs_in.wcs.set()

    wcs_out = WCS(naxis=3)
    wcs_out.wcs.ctype = "DEC--TAN", "FREQ", "RA---TAN"
    wcs_out.wcs.set()

    with pytest.raises(
        ValueError, match=r"The two WCS return a different number of world coordinates"
    ):
        _pixel_to_pixel_correlation_matrix(wcs_in, wcs_out)

    wcs3 = WCS(naxis=2)
    wcs3.wcs.ctype = "FREQ", "PIXEL"
    wcs3.wcs.set()

    with pytest.raises(
        ValueError, match=r"The world coordinate types of the two WCS do not match"
    ):
        _pixel_to_pixel_correlation_matrix(wcs_out, wcs3)

    wcs4 = WCS(naxis=4)
    wcs4.wcs.ctype = "RA---TAN", "DEC--TAN", "Q1", "Q2"
    wcs4.wcs.cunit = ["deg", "deg", "m/s", "m/s"]
    wcs4.wcs.set()

    wcs5 = WCS(naxis=4)
    wcs5.wcs.ctype = "Q1", "RA---TAN", "DEC--TAN", "Q2"
    wcs5.wcs.cunit = ["m/s", "deg", "deg", "m/s"]
    wcs5.wcs.set()

    with pytest.raises(
        ValueError,
        match=(
            "World coordinate order doesn't match and automatic matching is ambiguous"
        ),
    ):
        _pixel_to_pixel_correlation_matrix(wcs4, wcs5)


def test_pixel_to_pixel_correlation_matrix_nonsquare():
    # Here we set up an input WCS that maps 3 pixel coordinates to 4 world
    # coordinates - the idea is to make sure that things work fine in cases
    # where the number of input and output pixel coordinates do not match.

    class FakeWCS:
        pass

    wcs_in = FakeWCS()
    wcs_in.low_level_wcs = wcs_in
    wcs_in.pixel_n_dim = 3
    wcs_in.world_n_dim = 4
    wcs_in.axis_correlation_matrix = [
        [True, True, False],
        [True, True, False],
        [True, True, False],
        [False, False, True],
    ]
    wcs_in.world_axis_object_components = [
        ("spat", "ra", "ra.degree"),
        ("spat", "dec", "dec.degree"),
        ("spec", 0, "value"),
        ("time", 0, "utc.value"),
    ]
    wcs_in.world_axis_object_classes = {
        "spat": ("astropy.coordinates.SkyCoord", (), {"frame": "icrs"}),
        "spec": ("astropy.units.Wavelength", (None,), {}),
        "time": ("astropy.time.Time", (None,), {"format": "mjd", "scale": "utc"}),
    }

    wcs_out = FakeWCS()
    wcs_out.low_level_wcs = wcs_out
    wcs_out.pixel_n_dim = 4
    wcs_out.world_n_dim = 4
    wcs_out.axis_correlation_matrix = [
        [True, False, False, False],
        [False, True, True, False],
        [False, True, True, False],
        [False, False, False, True],
    ]
    wcs_out.world_axis_object_components = [
        ("spec", 0, "value"),
        ("spat", "ra", "ra.degree"),
        ("spat", "dec", "dec.degree"),
        ("time", 0, "utc.value"),
    ]
    wcs_out.world_axis_object_classes = wcs_in.world_axis_object_classes

    matrix = _pixel_to_pixel_correlation_matrix(wcs_in, wcs_out)

    matrix = matrix.astype(int)

    # The shape should be (n_pixel_out, n_pixel_in)
    assert matrix.shape == (4, 3)

    expected = np.array([[1, 1, 0], [1, 1, 0], [1, 1, 0], [0, 0, 1]])
    assert_equal(matrix, expected)


def test_split_matrix():
    assert _split_matrix(np.array([[1]])) == [([0], [0])]

    assert _split_matrix(
        np.array(
            [
                [1, 1],
                [1, 1],
            ]
        )
    ) == [([0, 1], [0, 1])]

    assert _split_matrix(
        np.array(
            [
                [1, 1, 0],
                [1, 1, 0],
                [0, 0, 1],
            ]
        )
    ) == [([0, 1], [0, 1]), ([2], [2])]

    assert _split_matrix(
        np.array(
            [
                [0, 1, 0],
                [1, 0, 0],
                [0, 0, 1],
            ]
        )
    ) == [([0], [1]), ([1], [0]), ([2], [2])]

    assert _split_matrix(
        np.array(
            [
                [0, 1, 1],
                [1, 0, 0],
                [1, 0, 1],
            ]
        )
    ) == [([0, 1, 2], [0, 1, 2])]


def test_pixel_to_pixel():
    wcs_in = WCS(naxis=3)
    wcs_in.wcs.ctype = "DEC--TAN", "FREQ", "RA---TAN"
    wcs_in.wcs.set()

    wcs_out = WCS(naxis=3)
    wcs_out.wcs.ctype = "GLON-CAR", "GLAT-CAR", "FREQ"
    wcs_out.wcs.set()

    # First try with scalars
    x, y, z = pixel_to_pixel(wcs_in, wcs_out, 1, 2, 3)
    assert x.shape == ()
    assert y.shape == ()
    assert z.shape == ()

    # Now try with broadcasted arrays
    x = np.linspace(10, 20, 10)
    y = np.linspace(10, 20, 20)
    z = np.linspace(10, 20, 30)
    Z1, Y1, X1 = np.meshgrid(z, y, x, indexing="ij", copy=False)
    X2, Y2, Z2 = pixel_to_pixel(wcs_in, wcs_out, X1, Y1, Z1)

    # The final arrays should have the correct shape
    assert X2.shape == (30, 20, 10)
    assert Y2.shape == (30, 20, 10)
    assert Z2.shape == (30, 20, 10)

    # But behind the scenes should also be broadcasted
    assert unbroadcast(X2).shape == (30, 1, 10)
    assert unbroadcast(Y2).shape == (30, 1, 10)
    assert unbroadcast(Z2).shape == (20, 1)

    # We can put the values back through the function to ensure round-tripping
    X3, Y3, Z3 = pixel_to_pixel(wcs_out, wcs_in, X2, Y2, Z2)

    # The final arrays should have the correct shape
    assert X2.shape == (30, 20, 10)
    assert Y2.shape == (30, 20, 10)
    assert Z2.shape == (30, 20, 10)

    # But behind the scenes should also be broadcasted
    assert unbroadcast(X3).shape == (30, 1, 10)
    assert unbroadcast(Y3).shape == (20, 1)
    assert unbroadcast(Z3).shape == (30, 1, 10)

    # And these arrays should match the input
    assert_allclose(X1, X3)
    assert_allclose(Y1, Y3)
    assert_allclose(Z1, Z3)


def test_pixel_to_pixel_correlated():
    wcs_in = WCS(naxis=2)
    wcs_in.wcs.ctype = "DEC--TAN", "RA---TAN"
    wcs_in.wcs.set()

    wcs_out = WCS(naxis=2)
    wcs_out.wcs.ctype = "GLON-CAR", "GLAT-CAR"
    wcs_out.wcs.set()

    # First try with scalars
    x, y = pixel_to_pixel(wcs_in, wcs_out, 1, 2)
    assert x.shape == ()
    assert y.shape == ()

    # Now try with broadcasted arrays
    x = np.linspace(10, 20, 10)
    y = np.linspace(10, 20, 20)
    Y1, X1 = np.meshgrid(y, x, indexing="ij", copy=False)
    Y2, X2 = pixel_to_pixel(wcs_in, wcs_out, X1, Y1)

    # The final arrays should have the correct shape
    assert X2.shape == (20, 10)
    assert Y2.shape == (20, 10)

    # and there are no efficiency gains here since the celestial axes are correlated
    assert unbroadcast(X2).shape == (20, 10)


def test_pixel_to_pixel_1d():
    # Simple test to make sure that when WCS only returns one world coordinate
    # this still works correctly (since this requires special treatment behind
    # the scenes).

    wcs_in = WCS(naxis=1)
    wcs_in.wcs.ctype = ("COORD1",)
    wcs_in.wcs.cunit = ("nm",)
    wcs_in.wcs.set()

    wcs_out = WCS(naxis=1)
    wcs_out.wcs.ctype = ("COORD2",)
    wcs_out.wcs.cunit = ("cm",)
    wcs_out.wcs.set()

    # First try with a scalar
    x = pixel_to_pixel(wcs_in, wcs_out, 1)
    assert x.shape == ()

    # Next with a regular array
    x = np.linspace(10, 20, 10)
    x = pixel_to_pixel(wcs_in, wcs_out, x)
    assert x.shape == (10,)

    # And now try with a broadcasted array
    x = np.broadcast_to(np.linspace(10, 20, 10), (4, 10))
    x = pixel_to_pixel(wcs_in, wcs_out, x)
    assert x.shape == (4, 10)

    # The broadcasting of the input should be retained
    assert unbroadcast(x).shape == (10,)


header_str_linear = """
XTENSION= 'IMAGE   '           / Image extension
BITPIX  =                  -32 / array data type
NAXIS   =                    2 / number of array dimensions
NAXIS1  =                   50
NAXIS2  =                   50
PCOUNT  =                    0 / number of parameters
GCOUNT  =                    1 / number of groups
RADESYS = 'ICRS    '
EQUINOX =               2000.0
WCSAXES =                    2
CTYPE1  = 'RA---TAN'
CTYPE2  = 'DEC--TAN'
CRVAL1  =    250.3497414839765
CRVAL2  =    2.280925599609063
CRPIX1  =               1045.0
CRPIX2  =               1001.0
CD1_1   =   -0.005564478186178
CD1_2   =   -0.001042099258152
CD2_1   =     0.00118144146585
CD2_2   =   -0.005590816683583
"""
header_str_sip = """
XTENSION= 'IMAGE   '           / Image extension
BITPIX  =                  -32 / array data type
NAXIS   =                    2 / number of array dimensions
NAXIS1  =                   50
NAXIS2  =                   50
PCOUNT  =                    0 / number of parameters
GCOUNT  =                    1 / number of groups
RADESYS = 'ICRS    '
EQUINOX =               2000.0
WCSAXES =                    2
CTYPE1  = 'RA---TAN-SIP'
CTYPE2  = 'DEC--TAN-SIP'
CRVAL1  =    250.3497414839765
CRVAL2  =    2.280925599609063
CRPIX1  =               1045.0
CRPIX2  =               1001.0
CD1_1   =   -0.005564478186178
CD1_2   =   -0.001042099258152
CD2_1   =     0.00118144146585
CD2_2   =   -0.005590816683583
A_ORDER =                    2
B_ORDER =                    2
A_2_0   =    2.02451189234E-05
A_0_2   =   3.317603337918E-06
A_1_1   = 1.73456334971071E-05
B_2_0   =   3.331330003472E-06
B_0_2   = 2.04247482482589E-05
B_1_1   = 1.71476710804143E-05
AP_ORDER=                    2
BP_ORDER=                    2
AP_1_0  = 0.000904700296389636
AP_0_1  = 0.000627660715584716
AP_2_0  =  -2.023482905861E-05
AP_0_2  =  -3.332285841011E-06
AP_1_1  =  -1.731636633824E-05
BP_1_0  = 0.000627960882053211
BP_0_1  = 0.000911222886084808
BP_2_0  =  -3.343918167224E-06
BP_0_2  =  -2.041598249021E-05
BP_1_1  =  -1.711876336719E-05
A_DMAX  =    44.72893589844534
B_DMAX  =    44.62692873032506
"""
header_str_prob = """
NAXIS   =                    2 / number of array dimensions
WCSAXES =                    2 / Number of coordinate axes
CRPIX1  =               1024.5 / Pixel coordinate of reference point
CRPIX2  =               1024.5 / Pixel coordinate of reference point
CD1_1   = -1.7445934400771E-05 / Coordinate transformation matrix element
CD1_2   = -4.9826985362578E-08 / Coordinate transformation matrix element
CD2_1   = -5.0068838822312E-08 / Coordinate transformation matrix element
CD2_2   =  1.7530614610951E-05 / Coordinate transformation matrix element
CTYPE1  = 'RA---TAN'           / Right ascension, gnomonic projection
CTYPE2  = 'DEC--TAN'           / Declination, gnomonic projection
CRVAL1  =      5.8689341666667 / [deg] Coordinate value at reference point
CRVAL2  =     -71.995508583333 / [deg] Coordinate value at reference point
"""


@pytest.mark.skipif(not HAS_SCIPY, reason="requires scipy")
@pytest.mark.parametrize(
    "header_str,crval,sip_degree,user_proj_point,exp_max_dist,exp_std_dist",
    [
        # simple testset no distortions
        (
            header_str_linear,
            250.3497414839765,
            None,
            False,
            7e-5 * u.deg,
            2.5e-5 * u.deg,
        ),
        # simple testset with distortions
        (header_str_sip, 250.3497414839765, 2, False, 7e-6 * u.deg, 2.5e-6 * u.deg),
        # testset with problematic WCS header that failed before
        (header_str_prob, 5.8689341666667, None, False, 7e-6 * u.deg, 2.5e-6 * u.deg),
        # simple testset no distortions, user defined center
        (
            header_str_linear,
            250.3497414839765,
            None,
            True,
            7e-5 * u.deg,
            2.5e-5 * u.deg,
        ),
        # 360->0 degree crossover, simple testset no distortions
        (
            header_str_linear,
            352.3497414839765,
            None,
            False,
            7e-5 * u.deg,
            2.5e-5 * u.deg,
        ),
        # 360->0 degree crossover, simple testset with distortions
        (header_str_sip, 352.3497414839765, 2, False, 7e-6 * u.deg, 2.5e-6 * u.deg),
        # 360->0 degree crossover, testset with problematic WCS header that failed before
        (header_str_prob, 352.3497414839765, None, False, 7e-6 * u.deg, 2.5e-6 * u.deg),
        # 360->0 degree crossover, simple testset no distortions, user defined center
        (
            header_str_linear,
            352.3497414839765,
            None,
            True,
            7e-5 * u.deg,
            2.5e-5 * u.deg,
        ),
    ],
)
def test_fit_wcs_from_points(
    header_str, crval, sip_degree, user_proj_point, exp_max_dist, exp_std_dist
):
    header = fits.Header.fromstring(header_str, sep="\n")
    header["CRVAL1"] = crval

    true_wcs = WCS(header, relax=True)

    # Getting the pixel coordinates
    x, y = np.meshgrid(list(range(10)), list(range(10)))
    x = x.flatten()
    y = y.flatten()

    # Calculating the true sky positions
    world_pix = true_wcs.pixel_to_world(x, y)

    # which projection point to use
    if user_proj_point:
        proj_point = world_pix[0]
        projlon = proj_point.data.lon.deg
        projlat = proj_point.data.lat.deg
    else:
        proj_point = "center"

    # Fitting the wcs
    fit_wcs = fit_wcs_from_points(
        (x, y), world_pix, proj_point=proj_point, sip_degree=sip_degree
    )

    # Validate that the true sky coordinates
    # match sky coordinates calculated from the wcs fit
    world_pix_new = fit_wcs.pixel_to_world(x, y)

    dists = world_pix.separation(world_pix_new)

    assert dists.max() < exp_max_dist
    assert np.std(dists) < exp_std_dist

    if user_proj_point:
        assert (fit_wcs.wcs.crval == [projlon, projlat]).all()


@pytest.mark.skipif(not HAS_SCIPY, reason="requires scipy")
def test_fit_wcs_from_points_CRPIX_bounds():
    # Test CRPIX bounds requirement
    wcs_str = """
WCSAXES =                    2 / Number of coordinate axes
CRPIX1  =               1045.0 / Pixel coordinate of reference point
CRPIX2  =               1001.0 / Pixel coordinate of reference point
PC1_1   =  0.00056205870415378 / Coordinate transformation matrix element
PC1_2   =    -0.00569181083243 / Coordinate transformation matrix element
PC2_1   =   0.0056776810932466 / Coordinate transformation matrix element
PC2_2   =   0.0004208048403273 / Coordinate transformation matrix element
CDELT1  =                  1.0 / [deg] Coordinate increment at reference point
CDELT2  =                  1.0 / [deg] Coordinate increment at reference point
CUNIT1  = 'deg'                / Units of coordinate increment and value
CUNIT2  = 'deg'                / Units of coordinate increment and value
CTYPE1  = 'RA---TAN'           / Right ascension, gnomonic projection
CTYPE2  = 'DEC--TAN'           / Declination, gnomonic projection
CRVAL1  =      104.57797893504 / [deg] Coordinate value at reference point
CRVAL2  =     -74.195502593322 / [deg] Coordinate value at reference point
LONPOLE =                180.0 / [deg] Native longitude of celestial pole
LATPOLE =     -74.195502593322 / [deg] Native latitude of celestial pole
TIMESYS = 'TDB'                / Time scale
TIMEUNIT= 'd'                  / Time units
DATEREF = '1858-11-17'         / ISO-8601 fiducial time
MJDREFI =                  0.0 / [d] MJD of fiducial time, integer part
MJDREFF =                  0.0 / [d] MJD of fiducial time, fractional part
DATE-OBS= '2019-03-27T03:30:13.832Z' / ISO-8601 time of observation
MJD-OBS =      58569.145993426 / [d] MJD of observation
MJD-OBS =      58569.145993426 / [d] MJD at start of observation
TSTART  =      1569.6467941661 / [d] Time elapsed since fiducial time at start
DATE-END= '2019-03-27T04:00:13.831Z' / ISO-8601 time at end of observation
MJD-END =      58569.166826748 / [d] MJD at end of observation
TSTOP   =      1569.6676274905 / [d] Time elapsed since fiducial time at end
TELAPSE =        0.02083332443 / [d] Elapsed time (start to stop)
TIMEDEL =    0.020833333333333 / [d] Time resolution
TIMEPIXR=                  0.5 / Reference position of timestamp in binned data
RADESYS = 'ICRS'               / Equatorial coordinate system
"""
    wcs_header = fits.Header.fromstring(wcs_str, sep="\n")
    ffi_wcs = WCS(wcs_header)

    yi, xi = (1000, 1000)
    y, x = (10, 200)

    center_coord = SkyCoord(
        ffi_wcs.all_pix2world([[xi + x // 2, yi + y // 2]], 0), unit="deg"
    )[0]
    ypix, xpix = (arr.flatten() for arr in np.mgrid[xi : xi + x, yi : yi + y])
    world_pix = SkyCoord(*ffi_wcs.all_pix2world(xpix, ypix, 0), unit="deg")

    fit_wcs = fit_wcs_from_points((ypix, xpix), world_pix, proj_point="center")

    assert (fit_wcs.wcs.crpix.astype(int) == [1100, 1005]).all()
    assert fit_wcs.pixel_shape == (1199, 1009)


@pytest.mark.skipif(not HAS_SCIPY, reason="requires scipy")
def test_issue10991():
    # test issue #10991 (it just needs to run and set the user defined crval)
    xy = np.array(
        [
            [1766.88276168, 662.96432257, 171.50212526, 120.70924648],
            [1706.69832901, 1788.85480559, 1216.98949653, 1307.41843381],
        ]
    )
    world_coords = SkyCoord(
        [
            (66.3542367, 22.20000162),
            (67.15416174, 19.18042906),
            (65.73375432, 17.54251555),
            (66.02400512, 17.44413253),
        ],
        frame="icrs",
        unit="deg",
    )
    proj_point = SkyCoord(64.67514918, 19.63389538, frame="icrs", unit="deg")

    fit_wcs = fit_wcs_from_points(
        xy=xy, world_coords=world_coords, proj_point=proj_point, projection="TAN"
    )
    projlon = proj_point.data.lon.deg
    projlat = proj_point.data.lat.deg
    assert (fit_wcs.wcs.crval == [projlon, projlat]).all()


@pytest.mark.skipif(not HAS_SCIPY, reason="requires scipy")
def test_fit_wcs_from_points_returned_object_attributes():
    xy = (
        np.array(
            [
                2810.156,
                650.236,
                1820.927,
                3425.779,
                2750.369,
            ]
        ),
        np.array(
            [
                1670.347,
                360.325,
                165.663,
                900.922,
                700.148,
            ]
        ),
    )
    ra, dec = (
        np.array(
            [
                246.75001315,
                246.72033646,
                246.72303144,
                246.74164072,
                246.73540614,
            ]
        ),
        np.array(
            [
                43.48690547,
                43.46792989,
                43.48075238,
                43.49560501,
                43.48903538,
            ]
        ),
    )
    radec = SkyCoord(ra, dec, unit=(u.deg, u.deg))

    placeholder_wcs = celestial_frame_to_wcs(frame=radec.frame, projection="TAN")
    estimated_wcs = fit_wcs_from_points(xy, radec, projection=placeholder_wcs)

    estimated_wcs_attributes = sorted(dir(estimated_wcs))
    placeholder_wcs_attributes = sorted(dir(placeholder_wcs))
    assert estimated_wcs_attributes == placeholder_wcs_attributes


@pytest.mark.remote_data
@pytest.mark.parametrize("x_in,y_in", [[0, 0], [np.arange(5), np.arange(5)]])
def test_pixel_to_world_itrs(x_in, y_in):
    """Regression test for https://github.com/astropy/astropy/pull/9609"""
    if Version(WCSLIB_VERSION) >= Version("7.4"):
        ctx = pytest.warns(
            FITSFixedWarning,
            match=(
                r"'datfix' made the change 'Set MJD-OBS to 57982\.528524 from"
                r" DATE-OBS'\."
            ),
        )
    else:
        ctx = nullcontext()

    with ctx:
        wcs = WCS(
            {
                "NAXIS": 2,
                "CTYPE1": "TLON-CAR",
                "CTYPE2": "TLAT-CAR",
                "RADESYS": "ITRS ",
                "DATE-OBS": "2017-08-17T12:41:04.444",
            }
        )

    # This shouldn't raise an exception.
    coord = wcs.pixel_to_world(x_in, y_in)

    # Check round trip transformation.
    x, y = wcs.world_to_pixel(coord)

    np.testing.assert_almost_equal(x, x_in)
    np.testing.assert_almost_equal(y, y_in)


@pytest.fixture
def dkist_location():
    return EarthLocation(
        *(-5466045.25695494, -2404388.73741278, 2242133.88769004) * u.m
    )


def test_obsgeo_cartesian(dkist_location):
    obstime = Time("2021-05-21T03:00:00")
    wcs = WCS(naxis=2)
    wcs.wcs.obsgeo = list(dkist_location.to_value(u.m).tolist()) + [0, 0, 0]
    wcs.wcs.dateobs = obstime.isot

    frame = obsgeo_to_frame(wcs.wcs.obsgeo, obstime)

    assert isinstance(frame, ITRS)
    assert frame.x == dkist_location.x
    assert frame.y == dkist_location.y
    assert frame.z == dkist_location.z


def test_obsgeo_spherical(dkist_location):
    obstime = Time("2021-05-21T03:00:00")
    dkist_location = dkist_location.get_itrs(obstime)
    loc_sph = dkist_location.spherical

    wcs = WCS(naxis=2)
    wcs.wcs.obsgeo = [0, 0, 0] + [
        loc_sph.lon.value,
        loc_sph.lat.value,
        loc_sph.distance.value,
    ]
    wcs.wcs.dateobs = obstime.isot

    frame = obsgeo_to_frame(wcs.wcs.obsgeo, obstime)

    assert isinstance(frame, ITRS)
    assert u.allclose(frame.x, dkist_location.x)
    assert u.allclose(frame.y, dkist_location.y)
    assert u.allclose(frame.z, dkist_location.z)


def test_obsgeo_infinite(dkist_location):
    obstime = Time("2021-05-21T03:00:00")
    dkist_location = dkist_location.get_itrs(obstime)
    loc_sph = dkist_location.spherical

    wcs = WCS(naxis=2)
    wcs.wcs.obsgeo = [1, 1, np.nan] + [
        loc_sph.lon.value,
        loc_sph.lat.value,
        loc_sph.distance.value,
    ]
    wcs.wcs.dateobs = obstime.isot
    wcs.wcs.set()

    frame = obsgeo_to_frame(wcs.wcs.obsgeo, obstime)

    assert isinstance(frame, ITRS)
    assert u.allclose(frame.x, dkist_location.x)
    assert u.allclose(frame.y, dkist_location.y)
    assert u.allclose(frame.z, dkist_location.z)


@pytest.mark.parametrize("obsgeo", ([np.nan] * 6, None, [0] * 6, [54] * 5))
def test_obsgeo_invalid(obsgeo):
    with pytest.raises(ValueError):
        obsgeo_to_frame(obsgeo, None)


def test_custom_wcs_to_from_frame():
    # See https://github.com/astropy/astropy/issues/15625
    # test from Sam van Kooten

    class CustomFrame(BaseCoordinateFrame):
        obstime = Time("2017-08-17T12:41:04.43")

    def custom_wcs_frame_mapping(wcs):
        ctypes = {c[:4] for c in wcs.wcs.ctype}
        if not ({"CSLN", "CSLT"} <= ctypes):
            return None

        dateobs = wcs.wcs.dateavg or wcs.wcs.dateobs or None
        custom_frame = CustomFrame()
        return custom_frame

    def custom_frame_wcs_mapping(frame, projection="TAN"):
        if not isinstance(frame, CustomFrame):
            return None
        wcs = WCS(naxis=2)
        wcs.wcs.ctype = [f"CSLN-{projection}", f"CSLT-{projection}"]
        return wcs

    WCS_FRAME_MAPPINGS.append([custom_wcs_frame_mapping])
    FRAME_WCS_MAPPINGS.append([custom_frame_wcs_mapping])

    mywcs = WCS(naxis=2)
    mywcs.wcs.ctype = ["CSLN-TAN", "CSLT-TAN"]
    custom_frame = custom_wcs_frame_mapping(mywcs)
    assert isinstance(custom_frame, CustomFrame)

    custom_wcs = custom_frame_wcs_mapping(custom_frame)
    print(custom_wcs.wcs.ctype)
    assert custom_wcs.wcs.ctype[0] == "CSLN-TAN"
    assert custom_wcs.wcs.ctype[1] == "CSLT-TAN"
