|
3 | 3 | """ |
4 | 4 |
|
5 | 5 | import numpy as np |
6 | | -from typing import Optional, Tuple |
| 6 | +from typing import Optional, Tuple, Union |
7 | 7 | 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 |
9 | 241 |
|
10 | 242 |
|
11 | 243 | def position_from_velocity( |
|
0 commit comments