Skip to content

Commit 6acab3b

Browse files
Implement plane_to_sphere (#186)
* Implement plane_to_sphere * Add round-trip test * Expand docstrings; fix for N-d arrays * Update clouddrift/sphere.py Co-authored-by: Philippe Miron <philippemiron@gmail.com> * Update clouddrift/sphere.py Co-authored-by: Philippe Miron <philippemiron@gmail.com> * Fix tests; update docstrings --------- Co-authored-by: Philippe Miron <philippemiron@gmail.com>
1 parent 90c1886 commit 6acab3b

File tree

2 files changed

+155
-16
lines changed

2 files changed

+155
-16
lines changed

clouddrift/sphere.py

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,82 @@ def recast_lon180(lon: np.ndarray) -> np.ndarray:
105105
return recast_lon(lon, -180)
106106

107107

108+
def plane_to_sphere(
109+
x: np.ndarray, y: np.ndarray, lon_origin: float = 0, lat_origin: float = 0
110+
) -> Tuple[np.ndarray, np.ndarray]:
111+
"""Convert Cartesian coordinates on a plane to spherical coordinates.
112+
113+
The arrays of input zonal and meridional displacements ``x`` and ``y`` are
114+
assumed to follow a contiguous trajectory. The spherical coordinate of each
115+
successive point is determined by following a great circle path from the
116+
previous point. The spherical coordinate of the first point is determined by
117+
following a great circle path from the origin, by default (0, 0).
118+
119+
The output arrays have the same floating-point output type as the input.
120+
121+
If projecting multiple trajectories onto the same plane, use
122+
:func:`apply_ragged` for highest accuracy.
123+
124+
Parameters
125+
----------
126+
x : np.ndarray
127+
An N-d array of zonal displacements in meters
128+
y : np.ndarray
129+
An N-d array of meridional displacements in meters
130+
lon_origin : float, optional
131+
Origin longitude of the tangent plane in degrees, default 0
132+
lat_origin : float, optional
133+
Origin latitude of the tangent plane in degrees, default 0
134+
135+
Returns
136+
-------
137+
lon : np.ndarray
138+
Longitude in degrees
139+
lat : np.ndarray
140+
Latitude in degrees
141+
142+
Examples
143+
--------
144+
>>> plane_to_sphere(np.array([0., 0.]), np.array([0., 1000.]))
145+
(array([0.00000000e+00, 5.50062664e-19]), array([0. , 0.0089832]))
146+
147+
You can also specify an origin longitude and latitude:
148+
149+
>>> plane_to_sphere(np.array([0., 0.]), np.array([0., 1000.]), lon_origin=1, lat_origin=0)
150+
(array([1., 1.]), array([0. , 0.0089832]))
151+
152+
Raises
153+
------
154+
AttributeError
155+
If ``x`` and ``y`` are not NumPy arrays
156+
157+
See Also
158+
--------
159+
:func:`sphere_to_plane`
160+
"""
161+
lon = np.empty_like(x)
162+
lat = np.empty_like(y)
163+
164+
# Cartesian distances between each point
165+
dx = np.diff(x, prepend=0)
166+
dy = np.diff(y, prepend=0)
167+
168+
distances = np.sqrt(dx**2 + dy**2)
169+
bearings = np.arctan2(dy, dx)
170+
171+
# Compute spherical coordinates following great circles between each
172+
# successive point.
173+
lat[..., 0], lon[..., 0] = haversine.position_from_distance_and_bearing(
174+
lat_origin, lon_origin, distances[..., 0], bearings[..., 0]
175+
)
176+
for n in range(1, lon.shape[-1]):
177+
lat[..., n], lon[..., n] = haversine.position_from_distance_and_bearing(
178+
lat[..., n - 1], lon[..., n - 1], distances[..., n], bearings[..., n]
179+
)
180+
181+
return lon, lat
182+
183+
108184
def sphere_to_plane(
109185
lon: np.ndarray, lat: np.ndarray, lon_origin: float = 0, lat_origin: float = 0
110186
) -> Tuple[np.ndarray, np.ndarray]:
@@ -116,8 +192,7 @@ def sphere_to_plane(
116192
The Cartesian coordinate of the first point is determined by following a
117193
great circle path from the origin, by default (0, 0).
118194
119-
This function uses 64-bit floats for all intermediate calculations,
120-
regardless of the type of input arrays, to avoid loss of precision.
195+
The output arrays have the same floating-point output type as the input.
121196
122197
If projecting multiple trajectories onto the same plane, use
123198
:func:`apply_ragged` for highest accuracy.
@@ -135,29 +210,36 @@ def sphere_to_plane(
135210
136211
Returns
137212
-------
138-
Tuple[np.ndarray, np.ndarray]
139-
x- and y-coordinates of the tangent plane
213+
x : np.ndarray
214+
x-coordinates on the tangent plane
215+
y : np.ndarray
216+
y-coordinates on the tangent plane
140217
141218
Examples
142219
--------
143220
>>> sphere_to_plane(np.array([0., 1.]), np.array([0., 0.]))
144221
(array([ 0. , 111318.84502145]), array([0., 0.]))
145222
146-
You can also specify an x and y origin:
223+
You can also specify an origin longitude and latitude:
147224
148225
>>> sphere_to_plane(np.array([0., 1.]), np.array([0., 0.]), lon_origin=1, lat_origin=0)
149226
(array([-111318.84502145, 0. ]),
150227
array([1.36326267e-11, 1.36326267e-11]))
151228
152229
Raises
153230
------
154-
TypeError
231+
AttributeError
155232
If ``lon`` and ``lat`` are not NumPy arrays
233+
234+
See Also
235+
--------
236+
:func:`plane_to_sphere`
156237
"""
157-
x = np.empty(lon.shape, dtype=np.float64)
158-
y = np.empty(lat.shape, dtype=np.float64)
159-
distances = np.empty(lon.shape, dtype=np.float64)
160-
bearings = np.empty(lon.shape, dtype=np.float64)
238+
x = np.empty_like(lon)
239+
y = np.empty_like(lat)
240+
241+
distances = np.empty_like(x)
242+
bearings = np.empty_like(x)
161243

162244
# Distance and bearing of the starting point relative to the origin
163245
distances[0] = haversine.distance(lat_origin, lon_origin, lat[..., 0], lon[..., 0])

tests/sphere_tests.py

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
from clouddrift import haversine
2-
from clouddrift.sphere import recast_lon, recast_lon180, recast_lon360, sphere_to_plane
2+
from clouddrift.sphere import (
3+
recast_lon,
4+
recast_lon180,
5+
recast_lon360,
6+
plane_to_sphere,
7+
sphere_to_plane,
8+
)
39
import unittest
410
import numpy as np
511

12+
ONE_DEGREE_METERS = np.deg2rad(haversine.EARTH_RADIUS_METERS)
613

714
if __name__ == "__main__":
815
unittest.main()
@@ -79,6 +86,50 @@ def test_decimals(self):
7986
)
8087

8188

89+
class plane_to_sphere_tests(unittest.TestCase):
90+
def test_simple(self):
91+
lon, lat = plane_to_sphere(
92+
np.array([0.0, ONE_DEGREE_METERS]), np.array([0.0, 0.0])
93+
)
94+
self.assertTrue(np.allclose(lon, np.array([0.0, 1.0])))
95+
self.assertTrue(np.allclose(lat, np.array([0.0, 0.0])))
96+
97+
lon, lat = plane_to_sphere(
98+
np.array([0.0, 0.0]), np.array([0.0, ONE_DEGREE_METERS])
99+
)
100+
self.assertTrue(np.allclose(lon, np.array([0.0, 0.0])))
101+
self.assertTrue(np.allclose(lat, np.array([0.0, 1.0])))
102+
103+
def test_with_origin(self):
104+
lon_origin = 5
105+
lat_origin = 0
106+
107+
lon, lat = plane_to_sphere(
108+
np.array([0.0, ONE_DEGREE_METERS]),
109+
np.array([0.0, 0.0]),
110+
lon_origin,
111+
lat_origin,
112+
)
113+
self.assertTrue(np.allclose(lon, np.array([lon_origin, lon_origin + 1])))
114+
self.assertTrue(np.allclose(lat, np.array([lat_origin, lat_origin])))
115+
116+
lon_origin = 0
117+
lat_origin = 5
118+
119+
lon, lat = plane_to_sphere(
120+
np.array([0.0, 0.0]),
121+
np.array([0.0, ONE_DEGREE_METERS]),
122+
lon_origin,
123+
lat_origin,
124+
)
125+
self.assertTrue(np.allclose(lon, np.array([lon_origin, lon_origin])))
126+
self.assertTrue(np.allclose(lat, np.array([lat_origin, lat_origin + 1])))
127+
128+
def test_scalar_raises_error(self):
129+
with self.assertRaises(Exception):
130+
plane_to_sphere(0, 0)
131+
132+
82133
class sphere_to_plane_tests(unittest.TestCase):
83134
def test_simple(self):
84135
x, y = sphere_to_plane(np.array([0.0, 1.0]), np.array([0.0, 0.0]))
@@ -97,8 +148,6 @@ def test_with_origin(self):
97148
lon_origin = 5
98149
lat_origin = 0
99150

100-
ONE_DEGREE_METERS = np.deg2rad(haversine.EARTH_RADIUS_METERS)
101-
102151
x, y = sphere_to_plane(
103152
np.array([0.0, 1.0]), np.array([0.0, 0.0]), lon_origin, lat_origin
104153
)
@@ -152,6 +201,14 @@ def test_scalar_raises_error(self):
152201
with self.assertRaises(Exception):
153202
sphere_to_plane(0, 0)
154203

155-
def test_list_raises_error(self):
156-
with self.assertRaises(Exception):
157-
sphere_to_plane([0, 1], [0, 0])
204+
205+
class sphere_to_plane_roundtrip(unittest.TestCase):
206+
def test_roundtrip(self):
207+
expected_lon = 2 * np.cumsum(np.random.random((100)))
208+
expected_lat = np.cumsum(np.random.random((100)))
209+
210+
x, y = sphere_to_plane(expected_lon, expected_lat)
211+
lon, lat = plane_to_sphere(x, y)
212+
213+
self.assertTrue(np.allclose(lon, expected_lon))
214+
self.assertTrue(np.allclose(lat, expected_lat))

0 commit comments

Comments
 (0)