Skip to content

Commit 90c1886

Browse files
Implement sphere_to_plane (#183)
* Draft implementation of sphere_to_plane * Make x_origin, y_origin optional and minor fixes * Complete docstrings and tests * Update clouddrift/sphere.py Co-authored-by: Philippe Miron <[email protected]> * Update raise error tests * lon and lat origin placeholder * Implement lon_origin and lat_origin * Bump feature version * Always use float64 for calculations; expand docstring --------- Co-authored-by: Philippe Miron <[email protected]>
1 parent ad2334c commit 90c1886

File tree

3 files changed

+158
-3
lines changed

3 files changed

+158
-3
lines changed

clouddrift/sphere.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
from clouddrift import haversine
12
import numpy as np
2-
from typing import Optional
3+
from typing import Optional, Tuple
34

45

56
def recast_lon(lon: np.ndarray, lon0: Optional[float] = -180) -> np.ndarray:
@@ -102,3 +103,78 @@ def recast_lon180(lon: np.ndarray) -> np.ndarray:
102103
:func:`recast_lon`, :func:`recast_lon360`
103104
"""
104105
return recast_lon(lon, -180)
106+
107+
108+
def sphere_to_plane(
109+
lon: np.ndarray, lat: np.ndarray, lon_origin: float = 0, lat_origin: float = 0
110+
) -> Tuple[np.ndarray, np.ndarray]:
111+
"""Convert spherical coordinates to a tangent (Cartesian) plane.
112+
113+
The arrays of input longitudes and latitudes are assumed to be following
114+
a contiguous trajectory. The Cartesian coordinate of each successive point
115+
is determined by following a great circle path from the previous point.
116+
The Cartesian coordinate of the first point is determined by following a
117+
great circle path from the origin, by default (0, 0).
118+
119+
This function uses 64-bit floats for all intermediate calculations,
120+
regardless of the type of input arrays, to avoid loss of precision.
121+
122+
If projecting multiple trajectories onto the same plane, use
123+
:func:`apply_ragged` for highest accuracy.
124+
125+
Parameters
126+
----------
127+
lon : np.ndarray
128+
An N-d array of longitudes in degrees
129+
lat : np.ndarray
130+
An N-d array of latitudes in degrees
131+
lon_origin : float, optional
132+
Origin longitude of the tangent plane in degrees, default 0
133+
lat_origin : float, optional
134+
Origin latitude of the tangent plane in degrees, default 0
135+
136+
Returns
137+
-------
138+
Tuple[np.ndarray, np.ndarray]
139+
x- and y-coordinates of the tangent plane
140+
141+
Examples
142+
--------
143+
>>> sphere_to_plane(np.array([0., 1.]), np.array([0., 0.]))
144+
(array([ 0. , 111318.84502145]), array([0., 0.]))
145+
146+
You can also specify an x and y origin:
147+
148+
>>> sphere_to_plane(np.array([0., 1.]), np.array([0., 0.]), lon_origin=1, lat_origin=0)
149+
(array([-111318.84502145, 0. ]),
150+
array([1.36326267e-11, 1.36326267e-11]))
151+
152+
Raises
153+
------
154+
TypeError
155+
If ``lon`` and ``lat`` are not NumPy arrays
156+
"""
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)
161+
162+
# Distance and bearing of the starting point relative to the origin
163+
distances[0] = haversine.distance(lat_origin, lon_origin, lat[..., 0], lon[..., 0])
164+
bearings[0] = haversine.bearing(lat_origin, lon_origin, lat[..., 0], lon[..., 0])
165+
166+
# Distance and bearing of the remaining points
167+
distances[1:] = haversine.distance(
168+
lat[..., :-1], lon[..., :-1], lat[..., 1:], lon[..., 1:]
169+
)
170+
bearings[1:] = haversine.bearing(
171+
lat[..., :-1], lon[..., :-1], lat[..., 1:], lon[..., 1:]
172+
)
173+
174+
dx = distances * np.cos(bearings)
175+
dy = distances * np.sin(bearings)
176+
177+
x[..., :] = np.cumsum(dx, axis=-1)
178+
y[..., :] = np.cumsum(dy, axis=-1)
179+
180+
return x, y

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "clouddrift"
7-
version = "0.13.0"
7+
version = "0.14.0"
88
authors = [
99
{ name="Shane Elipot", email="[email protected]" },
1010
{ name="Philippe Miron", email="[email protected]" },

tests/sphere_tests.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from clouddrift.sphere import recast_lon, recast_lon180, recast_lon360
1+
from clouddrift import haversine
2+
from clouddrift.sphere import recast_lon, recast_lon180, recast_lon360, sphere_to_plane
23
import unittest
34
import numpy as np
45

@@ -76,3 +77,81 @@ def test_decimals(self):
7677
recast_lon(np.array([200.3, -200.2])), np.array([-159.7, 159.8])
7778
)
7879
)
80+
81+
82+
class sphere_to_plane_tests(unittest.TestCase):
83+
def test_simple(self):
84+
x, y = sphere_to_plane(np.array([0.0, 1.0]), np.array([0.0, 0.0]))
85+
self.assertTrue(
86+
np.allclose(x, np.array([0.0, np.deg2rad(haversine.EARTH_RADIUS_METERS)]))
87+
)
88+
self.assertTrue(np.allclose(y, np.zeros((2))))
89+
90+
x, y = sphere_to_plane(np.array([0.0, 0.0]), np.array([0.0, 1.0]))
91+
self.assertTrue(
92+
np.allclose(y, np.array([0.0, np.deg2rad(haversine.EARTH_RADIUS_METERS)]))
93+
)
94+
self.assertTrue(np.allclose(x, np.zeros((2))))
95+
96+
def test_with_origin(self):
97+
lon_origin = 5
98+
lat_origin = 0
99+
100+
ONE_DEGREE_METERS = np.deg2rad(haversine.EARTH_RADIUS_METERS)
101+
102+
x, y = sphere_to_plane(
103+
np.array([0.0, 1.0]), np.array([0.0, 0.0]), lon_origin, lat_origin
104+
)
105+
self.assertTrue(
106+
np.allclose(
107+
x,
108+
np.array(
109+
[
110+
0 - lon_origin * ONE_DEGREE_METERS,
111+
ONE_DEGREE_METERS - lon_origin * ONE_DEGREE_METERS,
112+
]
113+
),
114+
)
115+
)
116+
self.assertTrue(
117+
np.allclose(
118+
y,
119+
np.array(
120+
[-lat_origin * ONE_DEGREE_METERS, -lat_origin * ONE_DEGREE_METERS]
121+
),
122+
)
123+
)
124+
125+
lon_origin = 0
126+
lat_origin = 5
127+
128+
x, y = sphere_to_plane(
129+
np.array([0.0, 0.0]), np.array([0.0, 1.0]), lon_origin, lat_origin
130+
)
131+
self.assertTrue(
132+
np.allclose(
133+
y,
134+
np.array(
135+
[
136+
0 - lat_origin * ONE_DEGREE_METERS,
137+
ONE_DEGREE_METERS - lat_origin * ONE_DEGREE_METERS,
138+
]
139+
),
140+
)
141+
)
142+
self.assertTrue(
143+
np.allclose(
144+
x,
145+
np.array(
146+
[-lon_origin * ONE_DEGREE_METERS, -lon_origin * ONE_DEGREE_METERS]
147+
),
148+
)
149+
)
150+
151+
def test_scalar_raises_error(self):
152+
with self.assertRaises(Exception):
153+
sphere_to_plane(0, 0)
154+
155+
def test_list_raises_error(self):
156+
with self.assertRaises(Exception):
157+
sphere_to_plane([0, 1], [0, 0])

0 commit comments

Comments
 (0)