Skip to content

Commit 8a33965

Browse files
selipotmilancurcic
andauthored
inertial extraction, take 2 (#301)
* restart * lint * after review from @philippemiron * tests * final? * more tests * error raisings * Don't raise on int time_step * Don't explicitly raise in residual_positions_from_displacements; NumPy will raise if arrays are incompatible and this allows the function to work with scalars * Tidy up * Bump minor version * bandwidth argument * lint * boundary condition --------- Co-authored-by: milancurcic <[email protected]>
1 parent f9fb29e commit 8a33965

File tree

3 files changed

+337
-4
lines changed

3 files changed

+337
-4
lines changed

clouddrift/kinematics.py

Lines changed: 234 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,241 @@
33
"""
44

55
import numpy as np
6-
from typing import Optional, Tuple
6+
from typing import Optional, Tuple, Union
77
import xarray as xr
8-
from clouddrift.sphere import distance, bearing, position_from_distance_and_bearing
8+
from clouddrift.sphere import (
9+
EARTH_RADIUS_METERS,
10+
bearing,
11+
cartesian_to_spherical,
12+
cartesian_to_tangentplane,
13+
coriolis_frequency,
14+
distance,
15+
position_from_distance_and_bearing,
16+
recast_lon360,
17+
spherical_to_cartesian,
18+
)
19+
from clouddrift.wavelet import morse_logspace_freq, morse_wavelet, wavelet_transform
20+
21+
22+
def inertial_oscillations_from_positions(
23+
longitude: np.ndarray,
24+
latitude: np.ndarray,
25+
relative_bandwidth: float,
26+
time_step: Optional[float] = 3600.0,
27+
relative_vorticity: Optional[Union[float, np.ndarray]] = 0.0,
28+
) -> np.ndarray:
29+
"""Extract inertial oscillations from consecutive geographical positions.
30+
31+
This function acts by performing a time-frequency analysis of horizontal displacements
32+
with analytic Morse wavelets. It extracts the portion of the wavelet transform signal
33+
that follows the inertial frequency (opposite of Coriolis frequency) as a function of time,
34+
potentially shifted in frequency by a measure of relative vorticity.
35+
36+
Parameters
37+
----------
38+
longitude : array-like
39+
Longitude sequence. Unidimensional array input.
40+
latitude : array-like
41+
Latitude sequence. Unidimensional array input.
42+
relative_bandwidth : float
43+
Bandwidth of the frequency-domain equivalent filter for the extraction of the inertial
44+
oscillations; a number less or equal to one which is a fraction of the inertial frequency.
45+
A value of 0.1 leads to a bandpass filter equivalent of +/- 10 percent of the inertial frequency.
46+
time_step : float
47+
The constant time interval between data points in seconds. Default is 3600.
48+
relative_vorticity: Optional, float or array-like
49+
Relative vorticity adding to the local Coriolis frequency. If "f" is the Coriolis
50+
frequency then "f" + `relative_vorticity` will be the effective Coriolis frequency as defined by Kunze (1985).
51+
Positive values correspond to cyclonic vorticity, irrespectively of the latitudes of the data
52+
points.
53+
54+
Returns
55+
-------
56+
xhat : array-like
57+
Zonal relative displacement in meters from inertial oscillations.
58+
yhat : array-like
59+
Meridional relative displacement in meters from inertial oscillations.
60+
61+
Examples
62+
--------
63+
To extract displacements from inertial oscillations from sequences of longitude
64+
and latitude values, equivalent to bandpass around 20 percent of the local inertial frequency:
65+
66+
>>> xhat, yhat = extract_inertial_from_position(longitude, latitude, 0.2)
67+
68+
Next, the residual positions from the inertial displacements can be obtained with another function:
69+
70+
>>> residual_longitudes, residual_latitudes = residual_positions_from_displacements(longitude, latitude, xhat, yhat)
71+
72+
Raises
73+
------
74+
ValueError
75+
If longitude and latitude arrays do not have the same shape.
76+
If relative_vorticity is an array and does not have the same shape as longitude and latitude.
77+
If time_step is not a float.
78+
If the absolute value of relative_bandwidth is not in the range (0,1].
79+
80+
See Also
81+
--------
82+
:func:`residual_positions_from_displacements`, `wavelet_transform`, `morse_wavelet`
83+
84+
"""
85+
if longitude.shape != latitude.shape:
86+
raise ValueError("longitude and latitude arrays must have the same shape.")
87+
88+
# length of data sequence
89+
data_length = longitude.shape[0]
90+
91+
if isinstance(relative_vorticity, float):
92+
relative_vorticity = np.full_like(longitude, relative_vorticity)
93+
elif isinstance(relative_vorticity, np.ndarray):
94+
if not relative_vorticity.shape == longitude.shape:
95+
raise ValueError(
96+
"relative_vorticity must be a float or the same shape as longitude and latitude."
97+
)
98+
99+
if not 0 < np.abs(relative_bandwidth) <= 1:
100+
raise ValueError("relative_bandwidth must be in the (0, 1]) range")
101+
102+
# wavelet parameters are gamma and beta
103+
gamma = 3 # symmetric wavelet
104+
density = 16 # results relative insensitive to this parameter
105+
wavelet_duration = 1 / np.abs(relative_bandwidth) # P parameter
106+
beta = wavelet_duration**2 / gamma
107+
108+
if isinstance(latitude, xr.DataArray):
109+
latitude = latitude.to_numpy()
110+
if isinstance(longitude, xr.DataArray):
111+
longitude = longitude.to_numpy()
112+
113+
# Instantaneous absolute frequency of oscillations along trajectory in radian per second
114+
cor_freq = np.abs(
115+
coriolis_frequency(latitude) + relative_vorticity * np.sign(latitude)
116+
)
117+
cor_freq_max = np.max(cor_freq * 1.05)
118+
cor_freq_min = np.max(
119+
[np.min(cor_freq * 0.95), 2 * np.pi / (time_step * data_length)]
120+
)
121+
122+
# logarithmically distributed frequencies for wavelet analysis
123+
radian_frequency = morse_logspace_freq(
124+
gamma,
125+
beta,
126+
data_length,
127+
(0.05, cor_freq_max * time_step),
128+
(5, cor_freq_min * time_step),
129+
density,
130+
) # frequencies in radian per unit time
131+
132+
# wavelet transform on a sphere
133+
# unwrap longitude recasted in [0,360)
134+
longitude_unwrapped = np.unwrap(recast_lon360(longitude), period=360)
135+
136+
# convert lat/lon to Cartesian coordinates x, y , z
137+
x, y, z = spherical_to_cartesian(longitude_unwrapped, latitude)
138+
139+
# wavelet transform of x, y, z
140+
wavelet, _ = morse_wavelet(data_length, gamma, beta, radian_frequency)
141+
wx = wavelet_transform(x, wavelet, boundary="mirror")
142+
wy = wavelet_transform(y, wavelet, boundary="mirror")
143+
wz = wavelet_transform(z, wavelet, boundary="mirror")
144+
145+
longitude_new, latitude_new = cartesian_to_spherical(
146+
x - np.real(wx), y - np.real(wy), z - np.real(wz)
147+
)
148+
149+
# convert transforms to horizontal displacements on tangent plane
150+
wxh, wyh = cartesian_to_tangentplane(wx, wy, wz, longitude_new, latitude_new)
151+
152+
# rotary wavelet transforms to select inertial component; need to divide by sqrt(2)
153+
wp = (wxh + 1j * wyh) / np.sqrt(2)
154+
wn = (wxh - 1j * wyh) / np.sqrt(2)
155+
156+
# find the values of radian_frequency/dt that most closely match cor_freq
157+
frequency_bins = [
158+
np.argmin(np.abs(cor_freq[i] - radian_frequency / time_step))
159+
for i in range(data_length)
160+
]
161+
162+
# get the transform at the inertial and "anti-inertial" frequencies
163+
# extract the values of wp and wn at the calculated index as a function of time
164+
# positive is anticyclonic (inertial) in the southern hemisphere
165+
# negative is anticyclonic (inertial) in the northern hemisphere
166+
wp = wp[frequency_bins, np.arange(0, data_length)]
167+
wn = wn[frequency_bins, np.arange(0, data_length)]
168+
169+
# indices of northern latitude points
170+
north = latitude >= 0
171+
172+
# initialize the zonal and meridional components of inertial displacements
173+
wxhat = np.zeros_like(latitude, dtype=np.complex64)
174+
wyhat = np.zeros_like(latitude, dtype=np.complex64)
175+
# equations are x+ = 0.5*(z+ + z-) and y+ = -0.5*1j*(z+ - z-)
176+
if any(north):
177+
wxhat[north] = wn[north] / np.sqrt(2)
178+
wyhat[north] = 1j * wn[north] / np.sqrt(2)
179+
if any(~north):
180+
wxhat[~north] = wp[~north] / np.sqrt(2)
181+
wyhat[~north] = -1j * wp[~north] / np.sqrt(2)
182+
183+
# inertial displacement in meters
184+
xhat = np.real(wxhat)
185+
yhat = np.real(wyhat)
186+
187+
return xhat, yhat
188+
189+
190+
def residual_positions_from_displacements(
191+
longitude: Union[float, np.ndarray],
192+
latitude: Union[float, np.ndarray],
193+
x: Union[float, np.ndarray],
194+
y: Union[float, np.ndarray],
195+
) -> Union[Tuple[float], Tuple[np.ndarray]]:
196+
"""
197+
Return residual longitudes and latitudes along a trajectory on the spherical Earth
198+
after correcting for zonal and meridional displacements x and y in meters.
199+
200+
This is applicable as an example when one seeks to correct a trajectory for
201+
horizontal oscillations due to inertial motions, tides, etc.
202+
203+
Parameters
204+
----------
205+
longitude : float or np.ndarray
206+
Longitude in degrees.
207+
latitude : float or np.ndarray
208+
Latitude in degrees.
209+
x : float or np.ndarray
210+
Zonal displacement in meters.
211+
y : float or np.ndarray
212+
Meridional displacement in meters.
213+
214+
Returns
215+
------
216+
residual_longitude : float or np.ndarray
217+
Residual longitude after correcting for zonal displacement, in degrees.
218+
residual_latitude : float or np.ndarray
219+
Residual latitude after correcting for meridional displacement, in degrees.
220+
221+
Examples
222+
--------
223+
Obtain the new geographical position for a displacement of 1/360-th of the
224+
circumference of the Earth from original position (longitude,latitude) = (1,0):
225+
226+
>>> from clouddrift.sphere import EARTH_RADIUS_METERS
227+
>>> residual_positions_from_displacements(1,0,2 * np.pi * EARTH_RADIUS_METERS / 360,0)
228+
(0.0, 0.0)
229+
"""
230+
latitudehat = 180 / np.pi * y / EARTH_RADIUS_METERS
231+
longitudehat = (
232+
180 / np.pi * x / (EARTH_RADIUS_METERS * np.cos(np.radians(latitude)))
233+
)
234+
235+
residual_latitude = latitude - latitudehat
236+
residual_longitude = recast_lon360(
237+
np.degrees(np.angle(np.exp(1j * np.radians(longitude - longitudehat))))
238+
)
239+
240+
return residual_longitude, residual_latitude
9241

10242

11243
def position_from_velocity(

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.24.0"
7+
version = "0.25.0"
88
authors = [
99
{ name="Shane Elipot", email="[email protected]" },
1010
{ name="Philippe Miron", email="[email protected]" },

tests/kinematics_tests.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from clouddrift.kinematics import (
2+
inertial_oscillations_from_positions,
23
position_from_velocity,
34
velocity_from_position,
5+
residual_positions_from_displacements,
46
)
5-
from clouddrift.sphere import EARTH_RADIUS_METERS
7+
from clouddrift.sphere import EARTH_RADIUS_METERS, coriolis_frequency
68
from clouddrift.raggedarray import RaggedArray
79
import unittest
810
import numpy as np
@@ -77,6 +79,105 @@ def sample_ragged_array() -> RaggedArray:
7779
return ra
7880

7981

82+
class inertial_oscillations_from_positions_tests(unittest.TestCase):
83+
def setUp(self):
84+
self.INPUT_SIZE = 1440
85+
self.longitude = np.linspace(0, 45, self.INPUT_SIZE)
86+
self.latitude = np.linspace(40, 45, self.INPUT_SIZE)
87+
self.relative_vorticity = 0.1 * coriolis_frequency(self.latitude)
88+
89+
def test_result_has_same_size_as_input(self):
90+
x, y = inertial_oscillations_from_positions(
91+
self.longitude, self.latitude, relative_bandwidth=0.10, time_step=3600
92+
)
93+
self.assertTrue(np.all(self.longitude.shape == x.shape))
94+
self.assertTrue(np.all(self.longitude.shape == y.shape))
95+
96+
def test_relative_vorticity(self):
97+
x, y = inertial_oscillations_from_positions(
98+
self.longitude,
99+
self.latitude,
100+
relative_bandwidth=0.10,
101+
time_step=3600,
102+
relative_vorticity=self.relative_vorticity,
103+
)
104+
self.assertTrue(np.all(self.longitude.shape == x.shape))
105+
self.assertTrue(np.all(self.longitude.shape == y.shape))
106+
107+
def test_time_step(self):
108+
x, y = inertial_oscillations_from_positions(
109+
self.longitude,
110+
self.latitude,
111+
relative_bandwidth=0.10,
112+
time_step=60 * 20,
113+
)
114+
self.assertTrue(np.all(self.longitude.shape == x.shape))
115+
self.assertTrue(np.all(self.longitude.shape == y.shape))
116+
117+
def test_simulation_case(self):
118+
lat0 = 30
119+
lon0 = 0
120+
t = np.arange(0, 60 - 1 / 24, 1 / 24)
121+
f = coriolis_frequency(lat0)
122+
uv = 0.5 * np.exp(-1j * (f * t * 86400 + 0.5 * np.pi))
123+
lon1, lat1 = position_from_velocity(
124+
np.real(uv),
125+
np.imag(uv),
126+
t * 86400,
127+
lon0,
128+
lat0,
129+
integration_scheme="forward",
130+
coord_system="spherical",
131+
)
132+
x_expected, y_expected = position_from_velocity(
133+
np.real(uv),
134+
np.imag(uv),
135+
t * 86400,
136+
0,
137+
0,
138+
integration_scheme="forward",
139+
coord_system="cartesian",
140+
)
141+
x_expected = x_expected - np.mean(x_expected)
142+
y_expected = y_expected - np.mean(y_expected)
143+
xhat, yhat = inertial_oscillations_from_positions(
144+
lon1, lat1, relative_bandwidth=0.10, time_step=3600
145+
)
146+
xhat = xhat - np.mean(xhat)
147+
yhat = yhat - np.mean(yhat)
148+
m = 10
149+
self.assertTrue(
150+
np.allclose(xhat[m * 24 : -m * 24], x_expected[m * 24 : -m * 24], atol=20)
151+
)
152+
self.assertTrue(
153+
np.allclose(yhat[m * 24 : -m * 24], y_expected[m * 24 : -m * 24], atol=20)
154+
)
155+
156+
157+
class residual_positions_from_displacements_tests(unittest.TestCase):
158+
def setUp(self):
159+
self.INPUT_SIZE = 100
160+
self.lon = np.rad2deg(
161+
np.linspace(-np.pi, np.pi, self.INPUT_SIZE, endpoint=False)
162+
)
163+
self.lat = np.linspace(0, 45, self.INPUT_SIZE)
164+
self.x = np.random.rand(self.INPUT_SIZE)
165+
self.y = np.random.rand(self.INPUT_SIZE)
166+
167+
def test_result_has_same_size_as_input(self):
168+
lon, lat = residual_positions_from_displacements(
169+
self.lon, self.lat, self.x, self.y
170+
)
171+
self.assertTrue(np.all(self.lon.shape == lon.shape))
172+
self.assertTrue(np.all(self.lon.shape == lat.shape))
173+
174+
def test_simple_case(self):
175+
lon, lat = residual_positions_from_displacements(
176+
1, 0, 2 * np.pi * EARTH_RADIUS_METERS / 360, 0
177+
)
178+
self.assertTrue(np.isclose((np.mod(lon, 360), lat), (0, 0)).all())
179+
180+
80181
class position_from_velocity_tests(unittest.TestCase):
81182
def setUp(self):
82183
self.INPUT_SIZE = 100

0 commit comments

Comments
 (0)