|
| 1 | +import numpy as np |
| 2 | +import xarray as xr |
| 3 | +import cartopy.crs as ccrs |
| 4 | + |
| 5 | + |
| 6 | +def discharge(ds, water_depth, rho, mu=None, surface_offset=0, utm_zone=10): |
| 7 | + """Calculate discharge (volume flux), power (kinetic energy flux), |
| 8 | + power density, and Reynolds number from a dataset containing a |
| 9 | + boat survey with a down-looking ADCP. This function is built to |
| 10 | + natively handle ADCP datasets read in using the `dolfyn` module. |
| 11 | +
|
| 12 | + Dataset velocity should already be corrected using ADCP-measured |
| 13 | + bottom track or GPS-measured velocity. The first velocity direction |
| 14 | + is assumed to be the primary flow axis. |
| 15 | +
|
| 16 | + This function linearly interpolates the lowest ADCP depth bin to |
| 17 | + the seafloor, and applies a constant extrapolation from the first |
| 18 | + ADCP bin to the surface. |
| 19 | +
|
| 20 | + Parameters |
| 21 | + ---------- |
| 22 | + ds: xarray.Dataset |
| 23 | + Dataset containing the following variables: |
| 24 | + - `vel`: (dir, range, time) motion-corrected velocity, in m/s |
| 25 | + - `latitude_gps`: (time_gps) latitude measured by GPS, in deg N |
| 26 | + - `longitude_gps`: (time_gps) longitude measured by GPS, in deg E |
| 27 | + water_depth: xarray.DataArray |
| 28 | + Total water depth measured by the ADCP or other input, in |
| 29 | + meters. If measured by the ADCP, add the ADCP's depth below |
| 30 | + the surface to this array. |
| 31 | + The "down" direction should be positive. |
| 32 | + rho: float |
| 33 | + Water density in kg/m^3 |
| 34 | + mu: float |
| 35 | + Dynamic visocity based on water temperature and salinity, in Ns/m^2. |
| 36 | + If not provided, Reynolds Number will not be calculated. |
| 37 | + Default: None. |
| 38 | + surface_offset: float |
| 39 | + Surface level offset due to changes in tidal level, in meters. |
| 40 | + Positive is down. Default: 0 m. |
| 41 | + utm_zone: int |
| 42 | + UTM zone for coordinate transformations (e.g., to compute cross-sectional |
| 43 | + distances from GPS lat/lon data). Map of UTM zones for the contiguous US: |
| 44 | + https://www.usgs.gov/media/images/mapping-utm-grid-conterminous-48-united-states. |
| 45 | + Default: 10 (the US west coast). |
| 46 | +
|
| 47 | + Returns |
| 48 | + ------- |
| 49 | + out: xarray.Dataset |
| 50 | + Dataset containing the following variables: |
| 51 | + - `discharge`: (1) volume flux, in m^3/s |
| 52 | + - `power`: (1) power, in W |
| 53 | + - `power_density`: (1) power density, in W/m^2 |
| 54 | + - `reynolds_number`: (1) Reynolds number, unitless |
| 55 | + """ |
| 56 | + |
| 57 | + def _extrapolate_to_bottom(vel, bottom, rng): |
| 58 | + """ |
| 59 | + Linearly extrapolate velocity values from the deepest valid bin down to zero at the seafloor. |
| 60 | +
|
| 61 | + This function sets velocity to zero at the seafloor and linearly interpolates |
| 62 | + between the last valid velocity bin and this zero-velocity boundary. If no valid |
| 63 | + velocity is found in a particular profile, no update is performed for that profile. |
| 64 | + This function assumes `rng` extends at least to (or below) the deepest seafloor depth |
| 65 | + specified in `bottom`. |
| 66 | +
|
| 67 | + Parameters |
| 68 | + ---------- |
| 69 | + vel : numpy.ndarray |
| 70 | + A velocity array of shape (dir, range, time), typically containing: |
| 71 | + - `dir` : velocity component dimension (e.g., 2 or 3 for 2D or 3D flow). |
| 72 | + - `range` : vertical/bin dimension (positive downward). |
| 73 | + - `time` : time dimension corresponding to each profile. |
| 74 | + The array is modified in-place (the updated values are also returned). |
| 75 | + bottom : array-like |
| 76 | + Array of length equal to the time dimension in `vel`, specifying the seafloor |
| 77 | + depth (in the same coordinate system as `rng`) at each time step. |
| 78 | + rng : array-like |
| 79 | + The vertical/bin positions corresponding to `vel` along the `range` dimension, |
| 80 | + sorted in ascending order (e.g., depth from the water surface downward). |
| 81 | +
|
| 82 | + Returns |
| 83 | + ------- |
| 84 | + vel : numpy.ndarray |
| 85 | + The same array passed in, with updated values below the last valid velocity bin |
| 86 | + for each time step (linear extrapolation to zero at the seafloor). |
| 87 | + """ |
| 88 | + |
| 89 | + for idx in range(vel.shape[-1]): |
| 90 | + z_bot = bottom[idx] |
| 91 | + # Fetch lowest range index |
| 92 | + ind_bot = np.nonzero(rng > z_bot)[0][0] |
| 93 | + for idim in range(vel.shape[0]): |
| 94 | + vnow = vel[idim, :, idx] |
| 95 | + # Check that data exists in slice |
| 96 | + gd = np.isfinite(vnow) & (vnow != 0) |
| 97 | + if not gd.sum(): |
| 98 | + continue |
| 99 | + else: |
| 100 | + ind = np.nonzero(gd)[0][-1] |
| 101 | + z_top = rng[ind] |
| 102 | + # linearly interpolate next lowest range bin based on 0 m/s at bottom |
| 103 | + vals = np.interp(rng[ind:ind_bot], [z_top, z_bot], [vnow[ind], 0]) |
| 104 | + vel[idim, ind:ind_bot, idx] = vals |
| 105 | + |
| 106 | + return vel |
| 107 | + |
| 108 | + def _convert_latlon_to_utm(ds, proj): |
| 109 | + """ |
| 110 | + Convert latitude/longitude coordinates to UTM coordinates. |
| 111 | +
|
| 112 | + This function uses the Cartopy `transform_point` and `transform_points` methods to |
| 113 | + project GPS latitude/longitude data into the specified UTM coordinate reference |
| 114 | + system. The resulting (x, y) coordinates are stored in an xarray DataArray that is |
| 115 | + interpolated onto the main time axis of `ds`. |
| 116 | +
|
| 117 | + The function sets `proj.x0` and `proj.y0` to the UTM coordinates of the mean |
| 118 | + longitude and latitude from `ds`. This can be used as a reference origin. |
| 119 | + Missing or NaN lat/lon values are handled via interpolation and extrapolation |
| 120 | + onto the `ds["time"]` axis. |
| 121 | + This function modifies the `proj` object by adding `x0` and `y0` attributes, |
| 122 | + which may be used for subsequent coordinate transformations or offsets. |
| 123 | +
|
| 124 | + Parameters |
| 125 | + ---------- |
| 126 | + ds : xarray.Dataset |
| 127 | + A dataset that must contain at least the following variables: |
| 128 | + - "latitude_gps" : (time_gps) latitude values in degrees North. |
| 129 | + - "longitude_gps" : (time_gps) longitude values in degrees East. |
| 130 | + - "time" : time axis onto which the projected coordinates will be |
| 131 | + interpolated. |
| 132 | + proj : cartopy.crs.Projection |
| 133 | + A Cartopy UTM projection or similar projection object. This is used both to |
| 134 | + store the reference origin (`x0`, `y0`) and to transform lat/lon coordinates |
| 135 | + into UTM. |
| 136 | +
|
| 137 | + Returns |
| 138 | + ------- |
| 139 | + xy : xarray.DataArray |
| 140 | + A DataArray of shape (gps=2, time), where: |
| 141 | + - The first dimension (indexed by "gps") corresponds to ["x", "y"] UTM |
| 142 | + coordinates. |
| 143 | + - The second dimension ("time") matches `ds["time"]`. |
| 144 | + The returned coordinates are interpolated in time using `ds["longitude_gps"]` |
| 145 | + and `ds["latitude_gps"]`, with values extrapolated if necessary. |
| 146 | +
|
| 147 | + """ |
| 148 | + |
| 149 | + plate_c = ccrs.PlateCarree() |
| 150 | + proj.x0, proj.y0 = proj.transform_point( |
| 151 | + ds["longitude_gps"].mean(), ds["latitude_gps"].mean(), plate_c |
| 152 | + ) |
| 153 | + xy = xr.DataArray( |
| 154 | + proj.transform_points(plate_c, ds["longitude_gps"], ds["latitude_gps"])[ |
| 155 | + :, :2 |
| 156 | + ].T, |
| 157 | + coords={"gps": ["x", "y"], "time_gps": ds["longitude_gps"]["time_gps"]}, |
| 158 | + ) |
| 159 | + |
| 160 | + # this seems to work for missing latlon |
| 161 | + xy = xy.interp( |
| 162 | + time_gps=ds["time"], kwargs={"fill_value": "extrapolate"} |
| 163 | + ).drop_vars("time_gps") |
| 164 | + return xy |
| 165 | + |
| 166 | + def _distance(proj, x, y): |
| 167 | + """ |
| 168 | + Compute the planar distance from the projection's reference origin. |
| 169 | +
|
| 170 | + Parameters |
| 171 | + ---------- |
| 172 | + proj : cartopy.crs.Projection |
| 173 | + A projection object with attributes `x0` and `y0`, which define the |
| 174 | + reference origin in the projected coordinate system. |
| 175 | + x : float or array-like |
| 176 | + One or more x-coordinates in the same units (m) as `proj.x0`. |
| 177 | + y : float or array-like |
| 178 | + One or more y-coordinates in the same units (m) as `proj.y0`. |
| 179 | +
|
| 180 | + Returns |
| 181 | + ------- |
| 182 | + dist : float or numpy.ndarray |
| 183 | + The distance(s) in m from the point(s) `(x, y)` to `(proj.x0, proj.y0)`. |
| 184 | + If `x` and `y` are arrays, the output is an array of the same shape. |
| 185 | + """ |
| 186 | + |
| 187 | + return np.sqrt((proj.x0 - x) ** 2 + (proj.y0 - y) ** 2) |
| 188 | + |
| 189 | + def _calc_discharge(vel, x, depth, surface_zoff=None): |
| 190 | + """ |
| 191 | + Calculate the integrated flux (e.g., discharge) by double integration of velocity |
| 192 | + over the cross-sectional area: depth and lateral distance. |
| 193 | +
|
| 194 | + Missing (NaN) velocities are treated as zero. |
| 195 | + Ensure `depth` and `surface_zoff` are both positive downward. |
| 196 | +
|
| 197 | + Parameters |
| 198 | + ---------- |
| 199 | + vel : numpy.ndarray or xarray.DataArray |
| 200 | + A 2D array of shape (nz, nx) corresponding to velocity values (m/s). |
| 201 | + - `nz` is the number of vertical bins (downward). |
| 202 | + - `nx` is the number of horizontal points. |
| 203 | + x : array-like |
| 204 | + Horizontal positions (m) of length `nx`. If `x` is in descending order |
| 205 | + (i.e., `x[0] > x[-1]`), the resulting flux is assigned a negative sign to |
| 206 | + indicate reverse orientation. |
| 207 | + depth : array-like |
| 208 | + Vertical positions (m) of length `nz`, positive downward. This is used |
| 209 | + for integration along the vertical dimension. |
| 210 | + surface_zoff : float, optional |
| 211 | + Surface level offset due to changes in tidal level, in meters. |
| 212 | + Positive is down. |
| 213 | +
|
| 214 | + Returns |
| 215 | + ------- |
| 216 | + Q : float |
| 217 | + The integrated flux (e.g., discharge) in units of m^3/s |
| 218 | +
|
| 219 | + """ |
| 220 | + vel = vel.copy() |
| 221 | + vel = vel.fillna(0) |
| 222 | + if surface_zoff is not None: |
| 223 | + # Add a copy of the top row of data |
| 224 | + vel = np.vstack((vel[0], vel)) |
| 225 | + depth = np.hstack((surface_zoff, depth)) |
| 226 | + if x[0] > x[-1]: |
| 227 | + sign = -1 |
| 228 | + else: |
| 229 | + sign = 1 |
| 230 | + return sign * np.trapz(np.trapz(vel, depth, axis=0), x) |
| 231 | + |
| 232 | + # Extrapolate to bed |
| 233 | + vel = ds["vel"].copy() |
| 234 | + vel.values = _extrapolate_to_bottom( |
| 235 | + ds["vel"].values, water_depth, ds["range"].values |
| 236 | + ) |
| 237 | + vel_x = vel[0] |
| 238 | + # Get position at each timestep in UTM grid |
| 239 | + proj = ccrs.UTM(utm_zone) |
| 240 | + xy = _convert_latlon_to_utm(ds, proj) |
| 241 | + # Distance from UTM grid origin (mean of GPS points) |
| 242 | + _x = _distance(proj, xy[0], xy[1]) |
| 243 | + # Set distance range for entire transect |
| 244 | + q_x_range = [_x.min(), _x.max()] # meters |
| 245 | + |
| 246 | + # Calculate discharge, power, kinetic energy, and reynolds number |
| 247 | + _xinds = (q_x_range[0] < _x) & (_x < q_x_range[1]) |
| 248 | + out = {} |
| 249 | + if _xinds.any(): |
| 250 | + speed = vel_x[:, _xinds] # m/s |
| 251 | + # Volume Flux, aka Discharge |
| 252 | + out["Q"] = _calc_discharge( |
| 253 | + speed, xy[0][_xinds], ds["range"], surface_offset |
| 254 | + ) # m/s * m * m = m^3/s |
| 255 | + # Kinetic Energy Flux, aka Power |
| 256 | + out["P"] = ( |
| 257 | + 0.5 |
| 258 | + * rho |
| 259 | + * _calc_discharge(speed**3, xy[0][_xinds], ds["range"], surface_offset) |
| 260 | + ) # kg/m^3 * m^3/s^3 * m * m = kg*m^2/s = W |
| 261 | + # Power Density |
| 262 | + out["J"] = ( |
| 263 | + (0.5 * rho * speed**3).mean().item() |
| 264 | + ) # kg/m^3 * m^3/s^3 = kg/s^3 = W/m^2 |
| 265 | + hydraulic_depth = abs( |
| 266 | + np.trapz((water_depth - surface_offset)[_xinds], xy[0][_xinds]) |
| 267 | + ) / ( |
| 268 | + xy[0][_xinds].max() - xy[0][_xinds].min() |
| 269 | + ) # area / surface-width |
| 270 | + # Reynolds Number |
| 271 | + out["Re"] = ((rho * ds.velds.U_mag.mean() * hydraulic_depth) / mu).item() |
| 272 | + else: |
| 273 | + out["Q"] = np.nan |
| 274 | + out["P"] = np.nan |
| 275 | + out["J"] = np.nan |
| 276 | + out["Re"] = np.nan |
| 277 | + |
| 278 | + ds["discharge"] = xr.DataArray( |
| 279 | + np.float32(out["Q"]), |
| 280 | + dims=[], |
| 281 | + attrs={ |
| 282 | + "units": "m3 s-1", |
| 283 | + "long_name": "Discharge", |
| 284 | + }, |
| 285 | + ) |
| 286 | + ds["power"] = xr.DataArray( |
| 287 | + np.float32(out["P"]), |
| 288 | + dims=[], |
| 289 | + attrs={ |
| 290 | + "units": "W", |
| 291 | + "long_name": "Power", |
| 292 | + }, |
| 293 | + ) |
| 294 | + ds["power_density"] = xr.DataArray( |
| 295 | + np.float32(out["J"]), |
| 296 | + dims=[], |
| 297 | + attrs={ |
| 298 | + "units": "W m-2", |
| 299 | + "long_name": "Power Density", |
| 300 | + }, |
| 301 | + ) |
| 302 | + ds["reynolds_number"] = xr.DataArray( |
| 303 | + np.float32(out["Re"]), |
| 304 | + dims=[], |
| 305 | + attrs={ |
| 306 | + "units": "1", |
| 307 | + "long_name": "Reynolds Number", |
| 308 | + }, |
| 309 | + ) |
| 310 | + |
| 311 | + return ds |
0 commit comments