|
| 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. |
| 14 | + This function linearly interpolates the lowest ADCP depth bin to |
| 15 | + the seafloor, and applies a constant extrapolation from the first |
| 16 | + ADCP bin to the surface. |
| 17 | +
|
| 18 | + Parameters |
| 19 | + ---------- |
| 20 | + ds: xarray.Dataset |
| 21 | + Dataset containing the following variables: |
| 22 | + - `vel`: (dir, range, time) motion-corrected velocity, in m/s |
| 23 | + - `latitude_gps`: (time_gps) latitude measured by GPS, in deg N |
| 24 | + - `longitude_gps`: (time_gps) longitude measured by GPS, in deg E |
| 25 | + water_depth: xarray.DataArray |
| 26 | + Total water depth measured by the ADCP or other input, in |
| 27 | + meters. If measured by the ADCP, add the ADCP's depth below |
| 28 | + the surface to this array. |
| 29 | + The "down" direction should be positive. |
| 30 | + rho: float |
| 31 | + Water density in kg/m^3 |
| 32 | + mu: float |
| 33 | + Kinematic visocity based on water temperature and salinity, in Ns/m^2. |
| 34 | + Required for Reynolds Number. |
| 35 | + surface_offset: float |
| 36 | + Surface level offset due to changes in tidal level, in meters. |
| 37 | + Default: 0 m. |
| 38 | + utm_zone: int |
| 39 | + UTM zone that measurements were acquired in. Map of UTM zones for the |
| 40 | + contiguous US: |
| 41 | + https://www.usgs.gov/media/images/mapping-utm-grid-conterminous-48-united-states. |
| 42 | + Default: 10 (the US west coast). |
| 43 | +
|
| 44 | + Returns |
| 45 | + ------- |
| 46 | + out: xarray.Dataset |
| 47 | + Dataset containing the following variables: |
| 48 | + - `discharge`: (1) volume flux, in m^3/s |
| 49 | + - `power`: (1) power, in W |
| 50 | + - `power_density`: (1) power density, in W/m^2 |
| 51 | + - `reynolds_number`: (1) Reynolds number, unitless |
| 52 | + """ |
| 53 | + |
| 54 | + def extrap2bottom(vel, bottom, rng): |
| 55 | + for idx in range(vel.shape[-1]): |
| 56 | + z_bot = bottom[idx] |
| 57 | + # Fetch lowest range index |
| 58 | + ind_bot = np.nonzero(rng > z_bot)[0][0] |
| 59 | + for idim in range(vel.shape[0]): |
| 60 | + vnow = vel[idim, :, idx] |
| 61 | + # Check that data exists in slice |
| 62 | + gd = np.isfinite(vnow) & (vnow != 0) |
| 63 | + if not gd.sum(): |
| 64 | + continue |
| 65 | + else: |
| 66 | + ind = np.nonzero(gd)[0][-1] |
| 67 | + z_top = rng[ind] |
| 68 | + # linearly interpolate next lowest range bin based on 0 m/s at bottom |
| 69 | + vals = np.interp(rng[ind:ind_bot], [z_top, z_bot], [vnow[ind], 0]) |
| 70 | + vel[idim, ind:ind_bot, idx] = vals |
| 71 | + |
| 72 | + return vel |
| 73 | + |
| 74 | + def latlon2utm(ds, proj): |
| 75 | + PlateC = ccrs.PlateCarree() |
| 76 | + proj.x0, proj.y0 = proj.transform_point( |
| 77 | + ds["longitude_gps"].mean(), ds["latitude_gps"].mean(), PlateC |
| 78 | + ) |
| 79 | + xy = xr.DataArray( |
| 80 | + proj.transform_points(PlateC, ds["longitude_gps"], ds["latitude_gps"])[ |
| 81 | + :, :2 |
| 82 | + ].T, |
| 83 | + coords={"gps": ["x", "y"], "time_gps": ds["longitude_gps"]["time_gps"]}, |
| 84 | + ) |
| 85 | + |
| 86 | + # this seems to work for missing latlon |
| 87 | + xy = xy.interp( |
| 88 | + time_gps=ds["time"], kwargs={"fill_value": "extrapolate"} |
| 89 | + ).drop_vars("time_gps") |
| 90 | + return xy |
| 91 | + |
| 92 | + def _distance(proj, x, y): |
| 93 | + # GPS distance traveled in meters |
| 94 | + return np.sqrt((proj.x0 - x) ** 2 + (proj.y0 - y) ** 2) |
| 95 | + |
| 96 | + def calc_Q(vel, x, depth, surface_zoff=None): |
| 97 | + # depth and surface_zoff should be positive in down direction |
| 98 | + vel = vel.copy() |
| 99 | + vel = vel.fillna(0) |
| 100 | + if surface_zoff is not None: |
| 101 | + # Add a copy of the top row of data |
| 102 | + vel = np.vstack((vel[0], vel)) |
| 103 | + depth = np.hstack((surface_zoff, depth)) |
| 104 | + if x[0] > x[-1]: |
| 105 | + sign = -1 |
| 106 | + else: |
| 107 | + sign = 1 |
| 108 | + return sign * np.trapz(np.trapz(vel, depth, axis=0), x) |
| 109 | + |
| 110 | + # Extrapolate to bed |
| 111 | + vel = ds["vel"].copy() |
| 112 | + vel.values = extrap2bottom(ds["vel"].values, water_depth, ds["range"].values) |
| 113 | + vel_x = vel[0] |
| 114 | + # Get position at each timestep in UTM grid |
| 115 | + proj = ccrs.UTM(utm_zone) |
| 116 | + xy = latlon2utm(ds, proj) |
| 117 | + # Distance from UTM grid origin (mean of GPS points) |
| 118 | + _x = _distance(proj, xy[0], xy[1]) |
| 119 | + # Set distance range for entire transect |
| 120 | + Q_x_range = [_x.min(), _x.max()] # meters |
| 121 | + |
| 122 | + # Calculate discharge, power, kinetic energy, and reynolds number |
| 123 | + _xinds = (Q_x_range[0] < _x) & (_x < Q_x_range[1]) |
| 124 | + out = {} |
| 125 | + if _xinds.any(): |
| 126 | + U = vel_x[:, _xinds] # m/s |
| 127 | + # Volume Flux, aka Discharge |
| 128 | + out["Q"] = calc_Q( |
| 129 | + U, xy[0][_xinds], ds["range"], surface_offset |
| 130 | + ) # m/s * m * m = m^3/s |
| 131 | + # Kinetic Energy Flux, aka Power |
| 132 | + out["P"] = ( |
| 133 | + 0.5 * rho * calc_Q(U**3, xy[0][_xinds], ds["range"], surface_offset) |
| 134 | + ) # kg/m^3 * m^3/s^3 * m * m = kg*m^2/s = W |
| 135 | + # Power Density |
| 136 | + out["J"] = (0.5 * rho * U**3).mean().item() # kg/m^3 * m^3/s^3 = kg/s^3 = W/m^2 |
| 137 | + # Hydraulic Depth |
| 138 | + L = abs(np.trapz((water_depth - surface_offset)[_xinds], xy[0][_xinds])) / ( |
| 139 | + xy[0][_xinds].max() - xy[0][_xinds].min() |
| 140 | + ) # area / surface-width |
| 141 | + # Reynolds Number |
| 142 | + out["Re"] = ((rho * ds.velds.U_mag.mean() * L) / mu).item() |
| 143 | + else: |
| 144 | + out["Q"] = np.nan |
| 145 | + out["P"] = np.nan |
| 146 | + out["J"] = np.nan |
| 147 | + out["Re"] = np.nan |
| 148 | + |
| 149 | + ds["discharge"] = xr.DataArray( |
| 150 | + np.float32(out["Q"]), |
| 151 | + dims=[], |
| 152 | + attrs={ |
| 153 | + "units": "m3 s-1", |
| 154 | + "long_name": "Discharge", |
| 155 | + }, |
| 156 | + ) |
| 157 | + ds["power"] = xr.DataArray( |
| 158 | + np.float32(out["P"]), |
| 159 | + dims=[], |
| 160 | + attrs={ |
| 161 | + "units": "W", |
| 162 | + "long_name": "Power", |
| 163 | + }, |
| 164 | + ) |
| 165 | + ds["power_density"] = xr.DataArray( |
| 166 | + np.float32(out["J"]), |
| 167 | + dims=[], |
| 168 | + attrs={ |
| 169 | + "units": "W m-2", |
| 170 | + "long_name": "Power Density", |
| 171 | + }, |
| 172 | + ) |
| 173 | + ds["reynolds_number"] = xr.DataArray( |
| 174 | + np.float32(out["Re"]), |
| 175 | + dims=[], |
| 176 | + attrs={ |
| 177 | + "units": "1", |
| 178 | + "long_name": "Reynolds Number", |
| 179 | + }, |
| 180 | + ) |
| 181 | + |
| 182 | + return ds |
0 commit comments