Skip to content

Commit 06a492b

Browse files
authored
Inertial modifications (#310)
* name changes * xarray conversion * add optional argument * expanded example
1 parent 8a33965 commit 06a492b

File tree

2 files changed

+79
-31
lines changed

2 files changed

+79
-31
lines changed

clouddrift/kinematics.py

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@
1919
from clouddrift.wavelet import morse_logspace_freq, morse_wavelet, wavelet_transform
2020

2121

22-
def inertial_oscillations_from_positions(
22+
def inertial_oscillation_from_position(
2323
longitude: np.ndarray,
2424
latitude: np.ndarray,
25-
relative_bandwidth: float,
25+
relative_bandwidth: Optional[float] = None,
26+
wavelet_duration: Optional[float] = None,
2627
time_step: Optional[float] = 3600.0,
2728
relative_vorticity: Optional[Union[float, np.ndarray]] = 0.0,
2829
) -> np.ndarray:
@@ -31,19 +32,27 @@ def inertial_oscillations_from_positions(
3132
This function acts by performing a time-frequency analysis of horizontal displacements
3233
with analytic Morse wavelets. It extracts the portion of the wavelet transform signal
3334
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+
potentially shifted in frequency by a measure of relative vorticity. The result is a pair
36+
of zonal and meridional relative displacements in meters.
37+
38+
This function is equivalent to a bandpass filtering of the horizontal displacements. The characteristics
39+
of the filter are defined by the relative bandwidth of the wavelet transform or by the duration of the wavelet,
40+
see the parameters below.
3541
3642
Parameters
3743
----------
3844
longitude : array-like
3945
Longitude sequence. Unidimensional array input.
4046
latitude : array-like
4147
Latitude sequence. Unidimensional array input.
42-
relative_bandwidth : float
48+
relative_bandwidth : float, optional
4349
Bandwidth of the frequency-domain equivalent filter for the extraction of the inertial
4450
oscillations; a number less or equal to one which is a fraction of the inertial frequency.
4551
A value of 0.1 leads to a bandpass filter equivalent of +/- 10 percent of the inertial frequency.
46-
time_step : float
52+
wavelet_duration : float, optional
53+
Duration of the wavelet, or inverse of the relative bandwidth, which can be passed instead of the
54+
relative bandwidth.
55+
time_step : float, optional
4756
The constant time interval between data points in seconds. Default is 3600.
4857
relative_vorticity: Optional, float or array-like
4958
Relative vorticity adding to the local Coriolis frequency. If "f" is the Coriolis
@@ -63,28 +72,44 @@ def inertial_oscillations_from_positions(
6372
To extract displacements from inertial oscillations from sequences of longitude
6473
and latitude values, equivalent to bandpass around 20 percent of the local inertial frequency:
6574
66-
>>> xhat, yhat = extract_inertial_from_position(longitude, latitude, 0.2)
75+
>>> xhat, yhat = inertial_oscillation_from_position(longitude, latitude, relative_bandwidth=0.2)
76+
77+
The same result can be obtained by specifying the wavelet duration instead of the relative bandwidth:
78+
79+
>>> xhat, yhat = inertial_oscillation_from_position(longitude, latitude, wavelet_duration=5)
6780
6881
Next, the residual positions from the inertial displacements can be obtained with another function:
6982
70-
>>> residual_longitudes, residual_latitudes = residual_positions_from_displacements(longitude, latitude, xhat, yhat)
83+
>>> residual_longitudes, residual_latitudes = residual_position_from_displacement(longitude, latitude, xhat, yhat)
7184
7285
Raises
7386
------
7487
ValueError
7588
If longitude and latitude arrays do not have the same shape.
7689
If relative_vorticity is an array and does not have the same shape as longitude and latitude.
7790
If time_step is not a float.
91+
If both relative_bandwidth and wavelet_duration are specified.
92+
If neither relative_bandwidth nor wavelet_duration are specified.
7893
If the absolute value of relative_bandwidth is not in the range (0,1].
94+
If the wavelet duration is not greater than or equal to 1.
7995
8096
See Also
8197
--------
82-
:func:`residual_positions_from_displacements`, `wavelet_transform`, `morse_wavelet`
98+
:func:`residual_position_from_displacement`, `wavelet_transform`, `morse_wavelet`
8399
84100
"""
85101
if longitude.shape != latitude.shape:
86102
raise ValueError("longitude and latitude arrays must have the same shape.")
87103

104+
if relative_bandwidth is not None and wavelet_duration is not None:
105+
raise ValueError(
106+
"Only one of 'relative_bandwidth' and 'wavelet_duration' can be specified"
107+
)
108+
elif relative_bandwidth is None and wavelet_duration is None:
109+
raise ValueError(
110+
"One of 'relative_bandwidth' and 'wavelet_duration' must be specified"
111+
)
112+
88113
# length of data sequence
89114
data_length = longitude.shape[0]
90115

@@ -95,14 +120,20 @@ def inertial_oscillations_from_positions(
95120
raise ValueError(
96121
"relative_vorticity must be a float or the same shape as longitude and latitude."
97122
)
123+
if relative_bandwidth is not None:
124+
if not 0 < np.abs(relative_bandwidth) <= 1:
125+
raise ValueError("relative_bandwidth must be in the (0, 1]) range")
98126

99-
if not 0 < np.abs(relative_bandwidth) <= 1:
100-
raise ValueError("relative_bandwidth must be in the (0, 1]) range")
127+
if wavelet_duration is not None:
128+
if not wavelet_duration >= 1:
129+
raise ValueError("wavelet_duration must be greater than or equal to 1")
101130

102131
# wavelet parameters are gamma and beta
103132
gamma = 3 # symmetric wavelet
104133
density = 16 # results relative insensitive to this parameter
105-
wavelet_duration = 1 / np.abs(relative_bandwidth) # P parameter
134+
# calculate beta from wavelet duration or from relative bandwidth
135+
if relative_bandwidth is not None:
136+
wavelet_duration = 1 / np.abs(relative_bandwidth) # P parameter
106137
beta = wavelet_duration**2 / gamma
107138

108139
if isinstance(latitude, xr.DataArray):
@@ -187,9 +218,9 @@ def inertial_oscillations_from_positions(
187218
return xhat, yhat
188219

189220

190-
def residual_positions_from_displacements(
191-
longitude: Union[float, np.ndarray],
192-
latitude: Union[float, np.ndarray],
221+
def residual_position_from_displacement(
222+
longitude: Union[float, np.ndarray, xr.DataArray],
223+
latitude: Union[float, np.ndarray, xr.DataArray],
193224
x: Union[float, np.ndarray],
194225
y: Union[float, np.ndarray],
195226
) -> Union[Tuple[float], Tuple[np.ndarray]]:
@@ -202,9 +233,9 @@ def residual_positions_from_displacements(
202233
203234
Parameters
204235
----------
205-
longitude : float or np.ndarray
236+
longitude : float or array-like
206237
Longitude in degrees.
207-
latitude : float or np.ndarray
238+
latitude : float or array-like
208239
Latitude in degrees.
209240
x : float or np.ndarray
210241
Zonal displacement in meters.
@@ -224,9 +255,15 @@ def residual_positions_from_displacements(
224255
circumference of the Earth from original position (longitude,latitude) = (1,0):
225256
226257
>>> from clouddrift.sphere import EARTH_RADIUS_METERS
227-
>>> residual_positions_from_displacements(1,0,2 * np.pi * EARTH_RADIUS_METERS / 360,0)
258+
>>> residual_position_from_displacement(1,0,2 * np.pi * EARTH_RADIUS_METERS / 360,0)
228259
(0.0, 0.0)
229260
"""
261+
# convert to numpy arrays to insure consistent outputs
262+
if isinstance(longitude, xr.DataArray):
263+
longitude = longitude.to_numpy()
264+
if isinstance(latitude, xr.DataArray):
265+
latitude = latitude.to_numpy()
266+
230267
latitudehat = 180 / np.pi * y / EARTH_RADIUS_METERS
231268
longitudehat = (
232269
180 / np.pi * x / (EARTH_RADIUS_METERS * np.cos(np.radians(latitude)))

tests/kinematics_tests.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from clouddrift.kinematics import (
2-
inertial_oscillations_from_positions,
2+
inertial_oscillation_from_position,
33
position_from_velocity,
44
velocity_from_position,
5-
residual_positions_from_displacements,
5+
residual_position_from_displacement,
66
)
77
from clouddrift.sphere import EARTH_RADIUS_METERS, coriolis_frequency
88
from clouddrift.raggedarray import RaggedArray
@@ -79,22 +79,22 @@ def sample_ragged_array() -> RaggedArray:
7979
return ra
8080

8181

82-
class inertial_oscillations_from_positions_tests(unittest.TestCase):
82+
class inertial_oscillation_from_position_tests(unittest.TestCase):
8383
def setUp(self):
8484
self.INPUT_SIZE = 1440
8585
self.longitude = np.linspace(0, 45, self.INPUT_SIZE)
8686
self.latitude = np.linspace(40, 45, self.INPUT_SIZE)
8787
self.relative_vorticity = 0.1 * coriolis_frequency(self.latitude)
8888

8989
def test_result_has_same_size_as_input(self):
90-
x, y = inertial_oscillations_from_positions(
90+
x, y = inertial_oscillation_from_position(
9191
self.longitude, self.latitude, relative_bandwidth=0.10, time_step=3600
9292
)
9393
self.assertTrue(np.all(self.longitude.shape == x.shape))
9494
self.assertTrue(np.all(self.longitude.shape == y.shape))
9595

9696
def test_relative_vorticity(self):
97-
x, y = inertial_oscillations_from_positions(
97+
x, y = inertial_oscillation_from_position(
9898
self.longitude,
9999
self.latitude,
100100
relative_bandwidth=0.10,
@@ -105,7 +105,7 @@ def test_relative_vorticity(self):
105105
self.assertTrue(np.all(self.longitude.shape == y.shape))
106106

107107
def test_time_step(self):
108-
x, y = inertial_oscillations_from_positions(
108+
x, y = inertial_oscillation_from_position(
109109
self.longitude,
110110
self.latitude,
111111
relative_bandwidth=0.10,
@@ -140,21 +140,32 @@ def test_simulation_case(self):
140140
)
141141
x_expected = x_expected - np.mean(x_expected)
142142
y_expected = y_expected - np.mean(y_expected)
143-
xhat, yhat = inertial_oscillations_from_positions(
143+
xhat1, yhat1 = inertial_oscillation_from_position(
144144
lon1, lat1, relative_bandwidth=0.10, time_step=3600
145145
)
146-
xhat = xhat - np.mean(xhat)
147-
yhat = yhat - np.mean(yhat)
146+
xhat1 = xhat1 - np.mean(xhat1)
147+
yhat1 = yhat1 - np.mean(yhat1)
148+
xhat2, yhat2 = inertial_oscillation_from_position(
149+
lon1, lat1, wavelet_duration=10, time_step=3600
150+
)
151+
xhat2 = xhat2 - np.mean(xhat2)
152+
yhat2 = yhat2 - np.mean(yhat2)
148153
m = 10
149154
self.assertTrue(
150-
np.allclose(xhat[m * 24 : -m * 24], x_expected[m * 24 : -m * 24], atol=20)
155+
np.allclose(xhat2[m * 24 : -m * 24], x_expected[m * 24 : -m * 24], atol=20)
156+
)
157+
self.assertTrue(
158+
np.allclose(yhat2[m * 24 : -m * 24], y_expected[m * 24 : -m * 24], atol=20)
159+
)
160+
self.assertTrue(
161+
np.allclose(xhat2[m * 24 : -m * 24], xhat1[m * 24 : -m * 24], atol=20)
151162
)
152163
self.assertTrue(
153-
np.allclose(yhat[m * 24 : -m * 24], y_expected[m * 24 : -m * 24], atol=20)
164+
np.allclose(yhat2[m * 24 : -m * 24], yhat1[m * 24 : -m * 24], atol=20)
154165
)
155166

156167

157-
class residual_positions_from_displacements_tests(unittest.TestCase):
168+
class residual_position_from_displacement_tests(unittest.TestCase):
158169
def setUp(self):
159170
self.INPUT_SIZE = 100
160171
self.lon = np.rad2deg(
@@ -165,14 +176,14 @@ def setUp(self):
165176
self.y = np.random.rand(self.INPUT_SIZE)
166177

167178
def test_result_has_same_size_as_input(self):
168-
lon, lat = residual_positions_from_displacements(
179+
lon, lat = residual_position_from_displacement(
169180
self.lon, self.lat, self.x, self.y
170181
)
171182
self.assertTrue(np.all(self.lon.shape == lon.shape))
172183
self.assertTrue(np.all(self.lon.shape == lat.shape))
173184

174185
def test_simple_case(self):
175-
lon, lat = residual_positions_from_displacements(
186+
lon, lat = residual_position_from_displacement(
176187
1, 0, 2 * np.pi * EARTH_RADIUS_METERS / 360, 0
177188
)
178189
self.assertTrue(np.isclose((np.mod(lon, 360), lat), (0, 0)).all())

0 commit comments

Comments
 (0)