Skip to content

Commit f361390

Browse files
jmcvey3ssolson
andauthored
Add discharge function to MHKiT (#385)
- discharge function - fluid power - Reynolds number. --------- Co-authored-by: ssolson <[email protected]>
1 parent c44e75d commit f361390

File tree

6 files changed

+2089
-1
lines changed

6 files changed

+2089
-1
lines changed

environment.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ dependencies:
2525
- matplotlib>=3.9.1
2626
- fatpack
2727
- nrel-rex
28+
- cartopy

examples/adcp_disharge_example.ipynb

Lines changed: 1773 additions & 0 deletions
Large diffs are not rendered by default.

mhkit/dolfyn/adp/api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
from . import clean
44
from ..velocity import VelBinner
55
from .turbulence import ADPBinner
6+
from .discharge import discharge

mhkit/dolfyn/adp/discharge.py

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ beautifulsoup4
1616
notebook
1717
numexpr>=2.10.0
1818
lxml
19-
bottleneck
19+
bottleneck
20+
cartopy

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"numexpr>=2.10.0",
3838
"lxml",
3939
"bottleneck",
40+
"cartopy",
4041
]
4142

4243
LONG_DESCRIPTION = """

0 commit comments

Comments
 (0)