Skip to content

Commit 5bedd0b

Browse files
authored
Position from velocity (#166)
* Get position from bearing and distance * Basic implementation works * Change wording in docstrings * Fix argument name * Fix argument name, again * Test simple roundtrip for three integration schemes; centered fails * Allow N-dim * Apply Haversine in centered differences over half-points * Loosen tolerance for centered scheme * Refactor for arbitrary time axis; test not passing yet * time_axis argument in test * Allow scalar in recast_lon * Recast to -180-180 in haversine position function * Update docstrings * Bump version to 0.12.0 * Don't recast longitude in haversine functions * Complete tests * Docstrings * Update clouddrift/analysis.py * Update clouddrift/analysis.py * Update clouddrift/analysis.py * Update clouddrift/analysis.py * Update clouddrift/analysis.py * Allow uppercase in `integration_scheme` values. * Allow uppercase in `coord_system` values.
1 parent 33c5a63 commit 5bedd0b

File tree

6 files changed

+491
-26
lines changed

6 files changed

+491
-26
lines changed

clouddrift/analysis.py

Lines changed: 245 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from concurrent import futures
66
from datetime import timedelta
77
import warnings
8-
from clouddrift.haversine import distance, bearing
8+
from clouddrift.haversine import distance, bearing, position_from_distance_and_bearing
99
from clouddrift.dataformat import unpack_ragged
1010

1111

@@ -375,6 +375,207 @@ def segment(
375375
return np.concatenate(segment_sizes)
376376

377377

378+
def position_from_velocity(
379+
u: np.ndarray,
380+
v: np.ndarray,
381+
time: np.ndarray,
382+
x_origin: float,
383+
y_origin: float,
384+
coord_system: Optional[str] = "spherical",
385+
integration_scheme: Optional[str] = "forward",
386+
time_axis: Optional[int] = -1,
387+
) -> Tuple[np.ndarray, np.ndarray]:
388+
"""Compute positions from arrays of velocities and time and a pair of origin
389+
coordinates.
390+
391+
The units of the result are degrees if ``coord_system == "spherical"`` (default).
392+
If ``coord_system == "cartesian"``, the units of the result are equal to the
393+
units of the input velocities multiplied by the units of the input time.
394+
For example, if the input velocities are in meters per second and the input
395+
time is in seconds, the units of the result will be meters.
396+
397+
Integration scheme can take one of three values:
398+
399+
1. "forward" (default): integration from x[i] to x[i+1] is performed
400+
using the velocity at x[i].
401+
2. "backward": integration from x[i] to x[i+1] is performed using the
402+
velocity at x[i+1].
403+
3. "centered": integration from x[i] to x[i+1] is performed using the
404+
arithmetic average of the velocities at x[i] and x[i+1]. Note that
405+
this method introduces some error due to the averaging.
406+
407+
u, v, and time can be multi-dimensional arrays. If the time axis, along
408+
which the finite differencing is performed, is not the last one (i.e.
409+
x.shape[-1]), use the ``time_axis`` optional argument to specify along which
410+
axis should the differencing be done. ``x``, ``y``, and ``time`` must have
411+
the same shape.
412+
413+
This function will not do any special handling of longitude ranges. If the
414+
integrated trajectory crosses the antimeridian (dateline) in either direction, the
415+
longitude values will not be adjusted to stay in any specific range such
416+
as [-180, 180] or [0, 360]. If you need your longitudes to be in a specific
417+
range, recast the resulting longitude from this function using the function
418+
:func:`clouddrift.sphere.recast_lon`.
419+
420+
Parameters
421+
----------
422+
u : np.ndarray
423+
An array of eastward velocities.
424+
v : np.ndarray
425+
An array of northward velocities.
426+
time : np.ndarray
427+
An array of time values.
428+
x_origin : float
429+
Origin x-coordinate or origin longitude.
430+
y_origin : float
431+
Origin y-coordinate or origin latitude.
432+
coord_system : str, optional
433+
The coordinate system of the input. Can be "spherical" or "cartesian".
434+
Default is "spherical".
435+
integration_scheme : str, optional
436+
The difference scheme to use for computing the position. Can be
437+
"forward" or "backward". Default is "forward".
438+
time_axis : int, optional
439+
The axis of the time array. Default is -1, which corresponds to the
440+
last axis.
441+
442+
Returns
443+
-------
444+
x : np.ndarray
445+
An array of zonal displacements or longitudes.
446+
y : np.ndarray
447+
An array of meridional displacements or latitudes.
448+
449+
Examples
450+
--------
451+
452+
Simple integration on a plane, using the forward scheme by default:
453+
454+
>>> import numpy as np
455+
>>> from clouddrift.analysis import position_from_velocity
456+
>>> u = np.array([1., 2., 3., 4.])
457+
>>> v = np.array([1., 1., 1., 1.])
458+
>>> time = np.array([0., 1., 2., 3.])
459+
>>> x, y = position_from_velocity(u, v, time, 0, 0, coord_system="cartesian")
460+
>>> x
461+
array([0., 1., 3., 6.])
462+
>>> y
463+
array([0., 1., 2., 3.])
464+
465+
As above, but using centered scheme:
466+
467+
>>> x, y = position_from_velocity(u, v, time, 0, 0, coord_system="cartesian", integration_scheme="centered")
468+
>>> x
469+
array([0., 1.5, 4., 7.5])
470+
>>> y
471+
array([0., 1., 2., 3.])
472+
473+
Simple integration on a sphere (default):
474+
475+
>>> u = np.array([1., 2., 3., 4.])
476+
>>> v = np.array([1., 1., 1., 1.])
477+
>>> time = np.array([0., 1., 2., 3.]) * 1e5
478+
>>> x, y = position_from_velocity(u, v, time, 0, 0)
479+
>>> x
480+
array([0. , 0.89839411, 2.69584476, 5.39367518])
481+
>>> y
482+
array([0. , 0.89828369, 1.79601515, 2.69201609])
483+
484+
Integrating across the antimeridian (dateline) by default does not
485+
recast the resulting longitude:
486+
487+
>>> u = np.array([1., 1.])
488+
>>> v = np.array([0., 0.])
489+
>>> time = np.array([0, 1e5])
490+
>>> x, y = position_from_velocity(u, v, time, 179.5, 0)
491+
>>> x
492+
array([179.5 , 180.3983205])
493+
>>> y
494+
array([0., 0.])
495+
496+
Use the ``clouddrift.sphere.recast_lon`` function to recast the longitudes
497+
to the desired range:
498+
499+
>>> from clouddrift.sphere import recast_lon
500+
>>> recast_lon(x, -180)
501+
array([ 179.5 , -179.6016795])
502+
503+
Raises
504+
------
505+
ValueError
506+
If the input arrays do not have the same shape.
507+
If the time axis is outside of the valid range ([-1, N-1]).
508+
If the input coordinate system is not "spherical" or "cartesian".
509+
If the input integration scheme is not "forward", "backward", or "centered"
510+
511+
See Also
512+
--------
513+
:func:`velocity_from_position`
514+
"""
515+
# Positions and time arrays must have the same shape.
516+
if not u.shape == v.shape == time.shape:
517+
raise ValueError("u, v, and time must have the same shape.")
518+
519+
# time_axis must be in valid range
520+
if time_axis < -1 or time_axis > len(u.shape) - 1:
521+
raise ValueError(
522+
f"time_axis ({time_axis}) is outside of the valid range ([-1,"
523+
f" {len(x.shape) - 1}])."
524+
)
525+
526+
# Nominal order of axes on input, i.e. (0, 1, 2, ..., N-1)
527+
target_axes = list(range(len(u.shape)))
528+
529+
# If time_axis is not the last one, transpose the inputs
530+
if time_axis != -1 and time_axis < len(u.shape) - 1:
531+
target_axes.append(target_axes.pop(target_axes.index(time_axis)))
532+
533+
# Reshape the inputs to ensure the time axis is last (fast-varying)
534+
u_ = np.transpose(u, target_axes)
535+
v_ = np.transpose(v, target_axes)
536+
time_ = np.transpose(time, target_axes)
537+
538+
x = np.zeros(u_.shape, dtype=u.dtype)
539+
y = np.zeros(v_.shape, dtype=v.dtype)
540+
541+
dt = np.diff(time_)
542+
543+
if integration_scheme.lower() == "forward":
544+
x[..., 1:] = np.cumsum(u_[..., :-1] * dt, axis=-1)
545+
y[..., 1:] = np.cumsum(v_[..., :-1] * dt, axis=-1)
546+
elif integration_scheme.lower() == "backward":
547+
x[..., 1:] = np.cumsum(u_[1:] * dt, axis=-1)
548+
y[..., 1:] = np.cumsum(v_[1:] * dt, axis=-1)
549+
elif integration_scheme.lower() == "centered":
550+
x[..., 1:] = np.cumsum(0.5 * (u_[..., :-1] + u_[..., 1:]) * dt, axis=-1)
551+
y[..., 1:] = np.cumsum(0.5 * (v_[..., :-1] + v_[..., 1:]) * dt, axis=-1)
552+
else:
553+
raise ValueError(
554+
'integration_scheme must be "forward", "backward", or "centered".'
555+
)
556+
557+
if coord_system.lower() == "cartesian":
558+
x += x_origin
559+
y += y_origin
560+
elif coord_system.lower() == "spherical":
561+
dx = np.diff(x)
562+
dy = np.diff(y)
563+
distances = np.sqrt(dx**2 + dy**2)
564+
bearings = np.arctan2(dy, dx)
565+
x[..., 0], y[..., 0] = x_origin, y_origin
566+
for n in range(distances.shape[-1]):
567+
y[..., n + 1], x[..., n + 1] = position_from_distance_and_bearing(
568+
y[..., n], x[..., n], distances[..., n], bearings[..., n]
569+
)
570+
else:
571+
raise ValueError('coord_system must be "spherical" or "cartesian".')
572+
573+
if target_axes == list(range(len(u.shape))):
574+
return x, y
575+
else:
576+
return np.transpose(x, target_axes), np.transpose(y, target_axes)
577+
578+
378579
def velocity_from_position(
379580
x: np.ndarray,
380581
y: np.ndarray,
@@ -394,9 +595,9 @@ def velocity_from_position(
394595
units of seconds, the resulting velocity is in the units of meters per
395596
second. Otherwise, if coord_system == "cartesian", the units of the
396597
resulting velocity correspond to the units of the input. For example,
397-
if Easting and Northing are in the units of kilometers and time is in
398-
the units of hours, the resulting velocity is in the units of kilometers
399-
per hour.
598+
if zonal and meridional displacements are in the units of kilometers and
599+
time is in the units of hours, the resulting velocity is in the units of
600+
kilometers per hour.
400601
401602
x, y, and time can be multi-dimensional arrays. If the time axis, along
402603
which the finite differencing is performed, is not the last one (i.e.
@@ -421,16 +622,39 @@ def velocity_from_position(
421622
case of a centered difference scheme, the start and end boundary points are
422623
evaluated using the forward and backward difference scheme, respectively.
423624
424-
Args:
425-
x (array_like): An N-d array of x-positions (longitude in degrees or easting in any unit)
426-
y (array_like): An N-d array of y-positions (latitude in degrees or northing in any unit)
427-
time (array_like): An N-d array of times as floating point values (in any unit)
428-
coord_system (str, optional): Coordinate system that x and y arrays are in; possible values are "spherical" (default) or "cartesian".
429-
difference_scheme (str, optional): Difference scheme to use; possible values are "forward", "backward", and "centered".
430-
time_axis (int, optional): Axis along which to differentiate (default is -1)
625+
Parameters
626+
----------
627+
x : array_like
628+
An N-d array of x-positions (longitude in degrees or zonal displacement in any unit)
629+
y : array_like
630+
An N-d array of y-positions (latitude in degrees or meridional displacement in any unit)
631+
time : array_like
632+
An N-d array of times as floating point values (in any unit)
633+
coord_system : str, optional
634+
Coordinate system that x and y arrays are in; possible values are "spherical" (default) or "cartesian".
635+
difference_scheme : str, optional
636+
Difference scheme to use; possible values are "forward", "backward", and "centered".
637+
time_axis : int, optional)
638+
Axis along which to differentiate (default is -1)
431639
432-
Returns:
433-
out (Tuple[xr.DataArray[float], xr.DataArray[float]]): Arrays of x- and y-velocities
640+
Returns
641+
-------
642+
u : np.ndarray
643+
Zonal velocity
644+
v : np.ndarray
645+
Meridional velocity
646+
647+
Raises
648+
------
649+
ValueError
650+
If x, y, and time do not have the same shape.
651+
If time_axis is outside of the valid range.
652+
If coord_system is not "spherical" or "cartesian".
653+
If difference_scheme is not "forward", "backward", or "centered".
654+
655+
See Also
656+
--------
657+
:function:`position_from_velocity`
434658
"""
435659

436660
# Positions and time arrays must have the same shape.
@@ -536,10 +760,14 @@ def velocity_from_position(
536760

537761
elif coord_system == "spherical":
538762
# Inner values
539-
distances = distance(y_[..., :-2], x_[..., :-2], y_[..., 2:], x_[..., 2:])
540-
bearings = bearing(y_[..., :-2], x_[..., :-2], y_[..., 2:], x_[..., 2:])
541-
dx[..., 1:-1] = distances * np.cos(bearings) / 2
542-
dy[..., 1:-1] = distances * np.sin(bearings) / 2
763+
y1 = (y_[..., :-2] + y_[..., 1:-1]) / 2
764+
x1 = (x_[..., :-2] + x_[..., 1:-1]) / 2
765+
y2 = (y_[..., 2:] + y_[..., 1:-1]) / 2
766+
x2 = (x_[..., 2:] + x_[..., 1:-1]) / 2
767+
distances = distance(y1, x1, y2, x2)
768+
bearings = bearing(y1, x1, y2, x2)
769+
dx[..., 1:-1] = distances * np.cos(bearings)
770+
dy[..., 1:-1] = distances * np.sin(bearings)
543771

544772
# Boundary values
545773
distance1 = distance(y_[..., 0], x_[..., 0], y_[..., 1], x_[..., 1])

clouddrift/haversine.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import numpy as np
2+
from typing import Tuple
23
import xarray as xr
34

45
EARTH_RADIUS_METERS = 6.3781e6
@@ -68,7 +69,7 @@ def bearing(
6869
θ = atan2(cos φ1 ⋅ sin φ2 - sin φ1 ⋅ cos φ2 ⋅ cos Δλ, sin Δλ ⋅ cos φ2)
6970
7071
where (φ, λ) is (lat, lon) and θ is bearing, all in radians.
71-
Bearing is defined as zero toward East and increasing counter-clockwise.
72+
Bearing is defined as zero toward East and positive counterclockwise.
7273
7374
Args:
7475
lat1 (array_like): Latitudes of the first set of points, in degrees
@@ -109,3 +110,53 @@ def bearing(
109110
)
110111

111112
return theta
113+
114+
115+
def position_from_distance_and_bearing(
116+
lat: float, lon: float, distance: float, bearing: float
117+
) -> Tuple[float, float]:
118+
"""Return elementwise new position in degrees from arrays of latitude and
119+
longitude in degrees, distance in meters, and bearing in radians, based on
120+
the spherical law of cosines.
121+
122+
The formula is:
123+
124+
φ2 = asin( sin φ1 ⋅ cos δ + cos φ1 ⋅ sin δ ⋅ cos θ )
125+
λ2 = λ1 + atan2( sin θ ⋅ sin δ ⋅ cos φ1, cos δ − sin φ1 ⋅ sin φ2 )
126+
127+
where (φ, λ) is (lat, lon) and θ is bearing, all in radians.
128+
Bearing is defined as zero toward East and positive counterclockwise.
129+
130+
Parameters
131+
----------
132+
lat : float
133+
Latitude of the first set of points, in degrees
134+
lon : float
135+
Longitude of the first set of points, in degrees
136+
distance : array_like
137+
Distance in meters
138+
bearing : array_like
139+
Bearing angles in radians
140+
141+
Returns
142+
-------
143+
lat2 : array_like
144+
Latitudes of the second set of points, in degrees, in the range [-90, 90]
145+
lon2 : array_like
146+
Longitudes of the second set of points, in degrees, in the range [-180, 180]
147+
"""
148+
lat_rad = np.deg2rad(lat)
149+
lon_rad = np.deg2rad(lon)
150+
151+
distance_rad = distance / EARTH_RADIUS_METERS
152+
153+
lat2_rad = np.arcsin(
154+
np.sin(lat_rad) * np.cos(distance_rad)
155+
+ np.cos(lat_rad) * np.sin(distance_rad) * np.sin(bearing)
156+
)
157+
lon2_rad = lon_rad + np.arctan2(
158+
np.cos(bearing) * np.sin(distance_rad) * np.cos(lat_rad),
159+
np.cos(distance_rad) - np.sin(lat_rad) * np.sin(lat2_rad),
160+
)
161+
162+
return np.rad2deg(lat2_rad), np.rad2deg(lon2_rad)

clouddrift/sphere.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ def recast_lon(lon: np.ndarray, lon0: Optional[float] = -180) -> np.ndarray:
77
88
Parameters
99
----------
10-
lon : np.ndarray
10+
lon : np.ndarray or float
1111
An N-d array of longitudes in degrees
1212
lon0 : float, optional
1313
Starting longitude of the recasted range (default -180).
1414
1515
Returns
1616
-------
17-
np.ndarray
17+
np.ndarray or float
1818
Converted longitudes in the range `[lon0, lon0+360]`
1919
2020
Examples
@@ -43,9 +43,6 @@ def recast_lon(lon: np.ndarray, lon0: Optional[float] = -180) -> np.ndarray:
4343
--------
4444
:func:`recast_lon360`, :func:`recast_lon180`
4545
"""
46-
if np.isscalar(lon):
47-
lon = np.array([lon])
48-
4946
return np.mod(lon - lon0, 360) + lon0
5047

5148

0 commit comments

Comments
 (0)