From 819c406cb65810f491d61fccfc6fd433678cbbaf Mon Sep 17 00:00:00 2001 From: fbourgey Date: Thu, 6 Aug 2026 13:52:58 -0400 Subject: [PATCH] ENH: add deg2rad and rad2deg --- docs/api-assorted.md | 2 + src/array_api_extra/__init__.py | 4 ++ src/array_api_extra/_delegation.py | 80 ++++++++++++++++++++++++++++++ src/array_api_extra/_lib/_funcs.py | 14 ++++++ tests/test_funcs.py | 63 +++++++++++++++++++++++ 5 files changed, 163 insertions(+) diff --git a/docs/api-assorted.md b/docs/api-assorted.md index 9a69ba23..438f3d8d 100644 --- a/docs/api-assorted.md +++ b/docs/api-assorted.md @@ -15,6 +15,7 @@ cov create_diagonal default_dtype + deg2rad diag_indices expand_dims isclose @@ -27,6 +28,7 @@ one_hot pad partition + rad2deg searchsorted setdiff1d sinc diff --git a/src/array_api_extra/__init__.py b/src/array_api_extra/__init__.py index 64adcbc8..d6d6b318 100644 --- a/src/array_api_extra/__init__.py +++ b/src/array_api_extra/__init__.py @@ -7,6 +7,7 @@ broadcast_shapes, cov, create_diagonal, + deg2rad, diag_indices, expand_dims, isclose, @@ -19,6 +20,7 @@ one_hot, pad, partition, + rad2deg, searchsorted, setdiff1d, sinc, @@ -49,6 +51,7 @@ "cov", "create_diagonal", "default_dtype", + "deg2rad", "diag_indices", "expand_dims", "isclose", @@ -62,6 +65,7 @@ "one_hot", "pad", "partition", + "rad2deg", "searchsorted", "setdiff1d", "sinc", diff --git a/src/array_api_extra/_delegation.py b/src/array_api_extra/_delegation.py index c5c16a37..2a8bde23 100644 --- a/src/array_api_extra/_delegation.py +++ b/src/array_api_extra/_delegation.py @@ -31,6 +31,7 @@ "broadcast_shapes", "cov", "create_diagonal", + "deg2rad", "diag_indices", "expand_dims", "isclose", @@ -43,6 +44,7 @@ "one_hot", "pad", "partition", + "rad2deg", "searchsorted", "setdiff1d", "sinc", @@ -323,6 +325,84 @@ def create_diagonal( return _funcs.create_diagonal(x, offset=offset, xp=xp) +def deg2rad(x: Array, /, *, xp: ArrayNamespace | None = None) -> Array: + """ + Convert angles from degrees to radians. + + Parameters + ---------- + x : array + Input array in degrees. Must have an integral or floating-point dtype. + xp : array_namespace, optional + The standard-compatible namespace for `x`. Default: infer. + + Returns + ------- + array + The corresponding angles in radians. Integral inputs are converted to the + default floating-point dtype. + """ + if xp is None: + xp = array_namespace(x) + if xp.isdtype(x.dtype, "integral"): + x = xp.astype(x, _funcs.default_dtype(xp, device=get_device(x))) + elif not xp.isdtype(x.dtype, ("real floating", "complex floating")): + msg = "`x` must have an integral, real floating, or complex floating dtype." + raise TypeError(msg) + + if is_jax_namespace(xp) or ( + not xp.isdtype(x.dtype, "complex floating") + and ( + is_numpy_namespace(xp) + or is_cupy_namespace(xp) + or is_torch_namespace(xp) + or is_dask_namespace(xp) + ) + ): + return xp.deg2rad(x) + + return _funcs.deg2rad(x, xp=xp) + + +def rad2deg(x: Array, /, *, xp: ArrayNamespace | None = None) -> Array: + """ + Convert angles from radians to degrees. + + Parameters + ---------- + x : array + Input array in radians. Must have an integral or floating-point dtype. + xp : array_namespace, optional + The standard-compatible namespace for `x`. Default: infer. + + Returns + ------- + array + The corresponding angles in degrees. Integral inputs are converted to the + default floating-point dtype. + """ + if xp is None: + xp = array_namespace(x) + if xp.isdtype(x.dtype, "integral"): + x = xp.astype(x, _funcs.default_dtype(xp, device=get_device(x))) + elif not xp.isdtype(x.dtype, ("real floating", "complex floating")): + msg = "`x` must have an integral, real floating, or complex floating dtype." + raise TypeError(msg) + + if is_jax_namespace(xp) or ( + not xp.isdtype(x.dtype, "complex floating") + and ( + is_numpy_namespace(xp) + or is_cupy_namespace(xp) + or is_torch_namespace(xp) + or is_dask_namespace(xp) + ) + ): + return xp.rad2deg(x) + + return _funcs.rad2deg(x, xp=xp) + + def diag_indices( n: int, /, *, ndim: int = 2, device: Device | None = None, xp: ArrayNamespace ) -> tuple[Array, ...]: diff --git a/src/array_api_extra/_lib/_funcs.py b/src/array_api_extra/_lib/_funcs.py index 3f1ef511..691335e8 100644 --- a/src/array_api_extra/_lib/_funcs.py +++ b/src/array_api_extra/_lib/_funcs.py @@ -31,6 +31,7 @@ "cov", "create_diagonal", "default_dtype", + "deg2rad", "diag_indices", "expand_dims", "isclose", @@ -43,6 +44,7 @@ "one_hot", "pad", "partition", + "rad2deg", "searchsorted", "setdiff1d", "sinc", @@ -792,6 +794,18 @@ def angle(z: Array, /, *, deg: bool = False, xp: ArrayNamespace | None = None) - return a +def deg2rad(x: Array, /, *, xp: ArrayNamespace) -> Array: + # numpydoc ignore=PR01,RT01 + """See docstring in `array_api_extra._delegation.py`.""" + return x * xp.pi / 180 + + +def rad2deg(x: Array, /, *, xp: ArrayNamespace) -> Array: + # numpydoc ignore=PR01,RT01 + """See docstring in `array_api_extra._delegation.py`.""" + return x * 180 / xp.pi + + def unravel_index(indices: Array, shape: tuple[int, ...], /) -> tuple[Array, ...]: # numpydoc ignore=PR01,RT01 """See docstring in `array_api_extra._delegation.py`.""" diff --git a/tests/test_funcs.py b/tests/test_funcs.py index 8b639564..8a1d9d06 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -23,6 +23,7 @@ cov, create_diagonal, default_dtype, + deg2rad, diag_indices, expand_dims, isclose, @@ -35,6 +36,7 @@ one_hot, pad, partition, + rad2deg, setdiff1d, sinc, tril_indices, @@ -64,6 +66,7 @@ lazy_xp_function(cov) lazy_xp_function(create_diagonal) lazy_xp_function(default_dtype) +lazy_xp_function(deg2rad) lazy_xp_function(diag_indices) lazy_xp_function(expand_dims) lazy_xp_function(isclose) @@ -74,6 +77,7 @@ lazy_xp_function(one_hot) lazy_xp_function(pad) lazy_xp_function(partition) +lazy_xp_function(rad2deg) # FIXME calls in1d which calls xp.unique_values without size lazy_xp_function(setdiff1d, jax_jit=False) lazy_xp_function(sinc) @@ -2172,6 +2176,65 @@ def test_device(self, xp: ArrayNamespace, device: Device): assert get_device(angle(a)) == device +class TestDeg2Rad: + def test_basic(self, xp: ArrayNamespace): + x = xp.asarray([0.0, 90.0, 180.0, 270.0, 360.0]) + expected = xp.asarray([0.0, xp.pi / 2, xp.pi, 3 * xp.pi / 2, 2 * xp.pi]) + assert_close(deg2rad(x, xp=xp), expected) + + @pytest.mark.parametrize("dtype_name", ["int32", "int64"]) + def test_integral(self, xp: ArrayNamespace, dtype_name: str): + x = xp.asarray([0, 90, 180], dtype=getattr(xp, dtype_name)) + actual = deg2rad(x, xp=xp) + expected = xp.asarray( + [0.0, xp.pi / 2, xp.pi], dtype=default_dtype(xp, device=get_device(x)) + ) + assert actual.dtype == expected.dtype + assert_close(actual, expected) + + def test_complex(self, xp: ArrayNamespace): + x = xp.asarray([180 + 90j], dtype=xp.complex64) + actual = deg2rad(x, xp=xp) + expected = xp.asarray([xp.pi + xp.pi / 2 * 1j], dtype=x.dtype) + assert actual.dtype == x.dtype + assert_close(actual, expected) + + def test_bool(self, xp: ArrayNamespace): + x = xp.asarray([True]) + with pytest.raises(TypeError, match="integral, real floating, or complex"): + deg2rad(x, xp=xp) + + +class TestRad2Deg: + def test_basic(self, xp: ArrayNamespace): + x = xp.asarray([0.0, xp.pi / 2, xp.pi, 3 * xp.pi / 2, 2 * xp.pi]) + expected = xp.asarray([0.0, 90.0, 180.0, 270.0, 360.0]) + assert_close(rad2deg(x, xp=xp), expected) + + @pytest.mark.parametrize("dtype_name", ["int32", "int64"]) + def test_integral(self, xp: ArrayNamespace, dtype_name: str): + x = xp.asarray([0, 1, 2], dtype=getattr(xp, dtype_name)) + actual = rad2deg(x, xp=xp) + expected = xp.asarray( + [0.0, 180 / xp.pi, 360 / xp.pi], + dtype=default_dtype(xp, device=get_device(x)), + ) + assert actual.dtype == expected.dtype + assert_close(actual, expected) + + def test_complex(self, xp: ArrayNamespace): + x = xp.asarray([xp.pi + xp.pi / 2 * 1j], dtype=xp.complex64) + actual = rad2deg(x, xp=xp) + expected = xp.asarray([180 + 90j], dtype=x.dtype) + assert actual.dtype == x.dtype + assert_close(actual, expected) + + def test_bool(self, xp: ArrayNamespace): + x = xp.asarray([True]) + with pytest.raises(TypeError, match="integral, real floating, or complex"): + rad2deg(x, xp=xp) + + class TestUnravelIndex: def test_simple(self, xp: ArrayNamespace): indices = xp.asarray([22, 41, 37])