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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions changes/696.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Clean up internal code flow so that the entire GWCS Native API follows common code
paths. This enables the Native API to properly handle various forms of inputs in
a consistent manner so that evaluation and inverse have the same input handling
behavior. This means HLO and quantities should function properly as they are passed
into these portions of the Native API.

Also allows for quantities to be passed into the "low-level APE 14 API" without
raising errors.
2 changes: 0 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,6 @@
# Enable nitpicky mode - which ensures that all references in the docs resolve.
nitpicky = True
nitpick_ignore = [
("py:class", "gwcs.api.GWCSAPIMixin"),
("py:class", "gwcs.wcs._pipeline.Pipeline"),
("py:obj", "astropy.modeling.projections.projcodes"),
("py:attr", "gwcs.WCS.bounding_box"),
("py:meth", "gwcs.WCS.footprint"),
Expand Down
4 changes: 3 additions & 1 deletion docs/gwcs/points_to_wcs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,10 @@ easily work in both pixel and sky space, and transform between frames.
The GWCS object, which by default when called executes for forward transformation,
can be used to convert coordinates from pixel to world.

.. doctest-requires:: numpy>=2.0

>>> gwcs_obj(36.235,642.215) # doctest: +FLOAT_CMP
(246.72158004206716, 43.46075091731673)
(np.float64(246.72158004206716), np.float64(43.46075091731673))

Or using the common WCS API

Expand Down
3 changes: 3 additions & 0 deletions gwcs/coordinate_frames/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@
LowLevelInput,
WorldAxisObjectClasses,
WorldAxisObjectComponent,
_LegacyCoordinateFrameProtocol,
)
from ._celestial import CelestialFrame
from ._composite import CompositeFrame
Expand Down Expand Up @@ -247,5 +248,7 @@
"TemporalFrame",
"WorldAxisObjectClasses",
"WorldAxisObjectComponent",
# See comment in _base.py about why this is included in __all__.
"_LegacyCoordinateFrameProtocol",
"get_ctype_from_ucd",
]
97 changes: 85 additions & 12 deletions gwcs/coordinate_frames/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
values_to_high_level_objects,
)

from gwcs.utils import correct_1d_output

from ._axis import AxesType

__all__ = [
Expand All @@ -35,6 +37,11 @@
"WorldAxisObjectClass",
"WorldAxisObjectClassConverter",
"WorldAxisObjectComponent",
# Adding _LegacyCoordinateFrameProtocol to __all__ in order for it to be
# picked up by Sphinx for the API documentation. This should be removed
# when the deprecation period of coordinate frames missing is_high_level
# is over.
"_LegacyCoordinateFrameProtocol",
]
_DtypeGeneric = TypeVar("_DtypeGeneric", bound=np.generic)

Expand Down Expand Up @@ -142,9 +149,10 @@ def from_tuple(cls, tup: tuple[str, str | int, str]) -> Self:


@runtime_checkable
class CoordinateFrameProtocol(Protocol):
class _LegacyCoordinateFrameProtocol(Protocol):
"""
API Definition for a Coordinate frame
Original API definition for a coordinate frame. This is used for deprecation
warnings and to identify when to patch the ``is_high_level`` method to the frame.
"""

@property
Expand Down Expand Up @@ -282,7 +290,8 @@ def remove_units(
for array in self.add_units(arrays)
)

def to_high_level_coordinates(self, *values):
@correct_1d_output
def to_high_level_coordinates(self, *values, correct_1d=True):
"""
Convert "values" to high level coordinate objects described by this frame.

Expand All @@ -308,12 +317,10 @@ def to_high_level_coordinates(self, *values):
msg = "All values should be a scalar number or a numpy array."
raise TypeError(msg)

high_level = values_to_high_level_objects(*values, low_level_wcs=self)
if len(high_level) == 1:
high_level = high_level[0]
return high_level
return values_to_high_level_objects(*values, low_level_wcs=self)

def from_high_level_coordinates(self, *high_level_coords):
@correct_1d_output
def from_high_level_coordinates(self, *high_level_coords, correct_1d=True):
"""
Convert high level coordinate objects to "values" as described by this frame.

Expand All @@ -331,10 +338,76 @@ def from_high_level_coordinates(self, *high_level_coords):
values : `numbers.Number` or `numpy.ndarray`
``naxis`` number of coordinates as scalars or arrays.
"""
values = high_level_objects_to_values(*high_level_coords, low_level_wcs=self)
if len(values) == 1:
values = values[0]
return values
return high_level_objects_to_values(*high_level_coords, low_level_wcs=self)


def _is_high_level(self: CoordinateFrameProtocol, *args) -> bool:
"""
Return `True` if the input coordinates are already high level objects
described by this frame.

This is used by the low level WCS API in Astropy to determine whether
to call ``to_high_level_coordinates`` or not.
"""

if (world_axis_object_classes := self.world_axis_object_classes) is None or len(
args
) != len(world_axis_object_classes):
return False

type_match = []
for arg, world_axis_object_class in zip(
args, world_axis_object_classes.values(), strict=True
):
if isinstance(class_object := world_axis_object_class.class_object, str):
type_match.append(
type(arg).__name__ == class_object
and class_object != u.Quantity.__name__
)
else:
type_match.append(
isinstance(arg, class_object) and class_object is not u.Quantity
)

if all(type_match):
return True

if any(type_match):
types = [
(
type(arg).__name__,
c.class_object
if isinstance(c.class_object, str)
else c.class_object.__name__,
)
for arg, c in zip(args, world_axis_object_classes.values(), strict=True)
]
msg = (
"Invalid types were passed, got "
f"({', '.join(t[0] for t in types)}), but expected "
f"({', '.join(t[1] for t in types)})."
)
raise TypeError(msg)

return False


@runtime_checkable
class CoordinateFrameProtocol(_LegacyCoordinateFrameProtocol, Protocol):
"""
API Definition for a Coordinate frame
"""

def is_high_level(self, *args) -> bool:
"""
Return `True` if the input coordinates are already high level objects
described by this frame.

This is used by the low level WCS API in Astropy to determine whether
to call ``to_high_level_coordinates`` or not.
"""

return _is_high_level(self, *args)


class BaseCoordinateFrame(CoordinateFrameProtocol):
Expand Down
4 changes: 2 additions & 2 deletions gwcs/coordinate_frames/_empty.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,10 @@ def world_axis_object_components(self) -> list[WorldAxisObjectComponent]:
for i, at in enumerate(self.axes_type)
]

def to_high_level_coordinates(self, *values):
def to_high_level_coordinates(self, *values, correct_1d: bool = True):
self._raise_error()

def from_high_level_coordinates(self, *high_level_coords):
def from_high_level_coordinates(self, *high_level_coords, correct_1d: bool = True):
self._raise_error()

def add_units(
Expand Down
19 changes: 14 additions & 5 deletions gwcs/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,10 +326,15 @@ def test_high_level_wrapper(wcsobj, request):
wc1 = (wc1,)

pix_out1 = hlvl.world_to_pixel(*wc1)
pix_out2 = wcsobj.invert(*wc1)

pix_out2 = wcsobj.input_frame.remove_units(pix_out2)

if not isinstance(pix_out2, list | tuple):
pix_out2 = (pix_out2,)

np.testing.assert_allclose(pix_out1, pixel_input)
with pytest.raises(TypeError) as e:
_ = wcsobj.invert(*wc1)
assert "High Level objects are not supported with the native" in str(e)
np.testing.assert_allclose(pix_out2, pixel_input)


def test_stokes_wrapper(gwcs_stokes_lookup):
Expand Down Expand Up @@ -592,8 +597,12 @@ def test_coordinate_frame_api():
pixel = wcs.world_to_pixel(world)
assert isinstance(pixel, float)

with pytest.raises(TypeError):
_ = wcs.invert(world)
# invert (native API) must accept the high-level/quantity world value and
# round-trip back to the original pixel, not merely match a hardcoded zero.
input_pixel = 3.0
world_nonzero = wcs.pixel_to_world(input_pixel)
pixel2 = wcs.invert(world_nonzero)
assert u.allclose(pixel2, input_pixel * u.pix)


def test_world_axis_object_components_units(gwcs_3d_identity_units):
Expand Down
10 changes: 2 additions & 8 deletions gwcs/tests/test_api_consistent.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,7 @@ def test_no_units_nd(wcsobj):
sky = wcsobj.pixel_to_world(*inp)
if not np.iterable(sky):
sky = (sky,)
with pytest.raises(
TypeError, match=r"High Level objects are not supported with the native"
):
wcsobj.invert(*sky)
assert u.allclose(inpq, wcsobj.invert(*sky))


@wcs_with_unit_1d
Expand Down Expand Up @@ -191,10 +188,7 @@ def test_transform_with_units(wcsobj):
sky = wcsobj.pixel_to_world(*xxq)
if not np.iterable(sky):
sky = (sky,)
with pytest.raises(
TypeError, match=r"High Level objects are not supported with the native"
):
wcsobj.invert(*sky)
assert u.allclose(xxq, wcsobj.invert(*sky))


@wcs_no_unit_1d
Expand Down
44 changes: 42 additions & 2 deletions gwcs/tests/test_coordinate_systems.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from gwcs import WCS
from gwcs import coordinate_frames as cf
from gwcs.coordinate_frames._base import _LegacyCoordinateFrameProtocol
from gwcs.coordinate_frames._utils import (
_ALLOWED_UCD_DUPLICATES,
_ucd1_to_ctype_name_mapping,
Expand Down Expand Up @@ -646,14 +647,53 @@ def add_units(self, arrays):
def remove_units(self, arrays):
return arrays

def to_high_level_coordinates(self, *inputs):
def is_high_level(self, *inputs):
return False

def to_high_level_coordinates(self, *inputs, correct_1d=True):
if correct_1d and len(inputs) == 1:
return inputs[0]
return inputs

def from_high_level_coordinates(self, *inputs):
def from_high_level_coordinates(self, *inputs, correct_1d=True):
if correct_1d and len(inputs) == 1:
return inputs[0]
return inputs

frame = MyFrame()
assert isinstance(frame, cf.CoordinateFrameProtocol)
assert frame.to_high_level_coordinates(1, correct_1d=False) == (1,)
assert frame.from_high_level_coordinates(1, correct_1d=False) == (1,)


def test_LegacyCoordinateFrameProtocol():
class LegacyFrame:
naxes = 1
name = "legacy"
unit = (u.m,)
axes_names = ("x",)
axes_order = (0,)
reference_frame = None
axes_type = ("SPATIAL",)
axis_physical_types = ("custom:x",)
world_axis_object_classes = (u.Quantity,)
world_axis_object_components = ("custom:x",)

def add_units(self, arrays):
return arrays

def remove_units(self, arrays):
return arrays

def to_high_level_coordinates(self, *inputs):
return inputs

def from_high_level_coordinates(self, *inputs):
return inputs

frame = LegacyFrame()
assert isinstance(frame, _LegacyCoordinateFrameProtocol)
assert not isinstance(frame, cf.CoordinateFrameProtocol)


def test_BaseCoordinateFrame():
Expand Down
8 changes: 8 additions & 0 deletions gwcs/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,11 @@ def test_get_values():

res = gwutils.get_values(None, args)
assert res == [2]


def test_is_high_level_deprecated(gwcs_simple_2d):
"""Test that the deprecated `is_high_level` function works and issues a warning."""
with pytest.warns(
DeprecationWarning, match=r"The use of `is_high_level` is deprecated"
):
assert gwutils.is_high_level(1, 2, low_level_wcs=gwcs_simple_2d) is False
Loading
Loading