Skip to content

Commit 4ab8cc7

Browse files
authored
Merge pull request #20 from wrightky/master
Add nourishment functions
2 parents df20373 + 21101cf commit 4ab8cc7

File tree

11 files changed

+586
-26
lines changed

11 files changed

+586
-26
lines changed

.github/workflows/build.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# This workflow installs and tests dorado on mulitple python versions and operating systems.
1+
# This workflow installs and tests dorado on multiple python versions and operating systems.
22

33
name: build
44

@@ -105,6 +105,7 @@ jobs:
105105
python examples/true_random_walk.py
106106
python examples/parallel_comparison.py
107107
python examples/traveltime_straight_channel.py
108+
python examples/nourishment_example.py
108109
cd examples
109110
python unsteady_example.py
110111

docs/source/examples/example14.rst

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
.. _example14:
2+
3+
Example 14 - Nourishment Area and Time Functions
4+
================================================
5+
6+
In this example, we analyze the travel history of a particle injection by computing the "nourishment area" and "nourishment time" for a given seed location.
7+
8+
Full example script available :download:`here <../../../examples/nourishment_example.py>`.
9+
10+
We will again make use of the DeltaRCM example data used in :ref:`example02`, but in this case we will slightly change the settings used in the random walk. In particular, this time we will include discharge fields, in order to obtain more useful travel time information.
11+
12+
.. doctest::
13+
14+
>>> import numpy as np
15+
>>> import dorado
16+
>>> data = np.load('ex_deltarcm_data.npz')
17+
18+
Now we will create the parameter class and assign attributes to it.
19+
20+
.. doctest::
21+
22+
>>> params = dorado.particle_track.modelParams()
23+
>>> params.stage = data['stage']
24+
>>> params.depth = data['depth']
25+
>>> params.qx = data['qx']
26+
>>> params.qy = data['qy']
27+
>>> params.dx = 50.
28+
>>> params.gamma = 0.5
29+
>>> params.model = 'DeltaRCM'
30+
31+
Now that the parameters have all been defined, we will define the particles class and generate a set of particles.
32+
33+
.. doctest::
34+
35+
>>> seed_xloc = list(range(15, 17))
36+
>>> seed_yloc = list(range(137, 140))
37+
>>> Np_tracer = 300
38+
>>> particles = dorado.particle_track.Particles(params)
39+
>>> particles.generate_particles(Np_tracer, seed_xloc, seed_yloc)
40+
41+
Now, we will route the particles for 120 iterations. Notice that we have used more particles and iterations than in :ref:`example02` -- this helps us obtain more statistically representative results later on.
42+
43+
.. doctest::
44+
45+
>>> walk_data = dorado.routines.steady_plots(particles, 120, 'nourishment_example')
46+
47+
Now that we have an existing `walk_data` dictionary, let's visualize some bulk information about the travel history. First, we can compute which regions of the domain were most visited by particles. This metric is often referred to as the `Nourishment Area` (e.g. Liang et al., 2016, doi.org/10.1002/2015JF003653) of a sample location, as it represents regions which are "nourished" by material from that location. We can compute how frequently cells were visited by particles using the `nourishment_area` function, and visualize the results using the `show_nourishment_area` routine.
48+
49+
.. doctest::
50+
51+
>>> visit_freq = dorado.particle_track.nourishment_area(walk_data, params.depth.shape)
52+
>>> ax = dorado.routines.show_nourishment_area(visit_freq, params.depth, walk_data)
53+
54+
.. image:: images/example14/NourishmentArea.png
55+
56+
The result is visualized in the figure above. Flow depth serves as the background in this figure, on top of which the Nourishment Area is shown, with regions colored by how frequently each cell was occupied by particles. Because we are often interested in where particles can go in general, rather than exactly where they have gone, by default this function will perform a small amount of Gaussian filtering, to smooth out some of the stochasticity in the travel paths. This smoothing can be turned off or ramped up depending on the application. Additionally, the plotting routine comes with many optional settings which can be used to change the aesthetics of the resulting figure.
57+
58+
Because the Nourishment Area is a time-integrated measure of where particles are going, we may also want to know how long particles tend to stay there once they get there. For this question, we have provided a second function, which we are calling the Nourishment Time, which computes how long on average particles spend in each cell they travel through. In steady model runs (such as this one), the result is trivially related to the flow velocity of each cell -- however, for unsteady runs, in which the underlying flow field might be changing in between particle iterations, the results of this function can be more interesting.
59+
60+
Similar to before, we compute this by calling on the `nourishment_time` function, and visualize the results using the `show_nourishment_time` routine.
61+
62+
.. doctest::
63+
64+
>>> mean_times = dorado.particle_track.nourishment_time(walk_data, params.depth.shape, clip=95)
65+
>>> ax = dorado.routines.show_nourishment_time(mean_times, params.depth, walk_data)
66+
67+
.. image:: images/example14/NourishmentTime.png
68+
69+
The result is visualized above. As with the previous function, some amount of spatial filtering has been applied. From comparing these two figures, some interesting trends emerge. For example, even though the main distributary channels are frequently visited by particles, the particles on average don't spend very much time there. Depending on the material or question of interest, perhaps these can provide useful insights!
76.4 KB
Loading
110 KB
Loading

docs/source/examples/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Examples of the high-level API functionality are provided here. These examples r
3232
example11
3333
example12
3434
example13
35+
example14
3536

3637

3738
External Examples

dorado/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
__version__ = "2.3.0"
1+
__version__ = "2.4.0"
2+
23

34
from . import lagrangian_walker
45
from . import parallel_routing

dorado/particle_track.py

Lines changed: 177 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
from builtins import range
1313
from math import pi
1414
import numpy as np
15-
import scipy
16-
from scipy import interpolate
1715
from tqdm import tqdm
1816
import dorado.lagrangian_walker as lw
1917

@@ -901,10 +899,153 @@ def exposure_time(walk_data,
901899
return exposure_times.tolist()
902900

903901

902+
def nourishment_area(walk_data, raster_size, sigma=0.7, clip=99.5):
903+
"""Determine the nourishment area of a particle injection
904+
905+
Function will measure the regions of the domain 'fed' by a seed location,
906+
as indicated by the history of particle travel locations in walk_data.
907+
Returns a heatmap raster, in which values indicate number of occasions
908+
each cell was occupied by a particle (after spatial filtering to reduce
909+
noise in the random walk, and normalizing by number of particles).
910+
911+
**Inputs** :
912+
913+
walk_data : `dict`
914+
Dictionary of all prior x locations, y locations, and travel
915+
times (the output of run_iteration)
916+
917+
raster_size : `tuple`
918+
Tuple (L,W) of the domain dimensions, i.e. the output of
919+
numpy.shape(raster).
920+
921+
sigma : `float`, optional
922+
Degree of spatial smoothing of the area, implemented using a
923+
Gaussian kernal of the same sigma, via scipy.ndimage.gaussian_filter
924+
Default is light smoothing with sigma = 0.7 (to turn off smoothing,
925+
set sigma = 0)
926+
927+
clip : `float`, optional
928+
Percentile at which to truncate the distribution. Particles which
929+
get stuck can lead to errors at the high-extreme, so this
930+
parameter is used to normalize by a "near-max". Default is the
931+
99.5th percentile. To use true max, specify clip = 100
932+
933+
**Outputs** :
934+
935+
visit_freq : `numpy.ndarray`
936+
Array of normalized particle visit frequency, with cells in the
937+
range [0, 1] representing the number of instances particles visited
938+
that cell. If sigma > 0, the array values include spatial filtering
939+
940+
"""
941+
if sigma > 0:
942+
from scipy.ndimage import gaussian_filter
943+
944+
# Measure visit frequency
945+
visit_freq = np.zeros(raster_size)
946+
for ii in list(range(len(walk_data['xinds']))):
947+
for jj in list(range(len(walk_data['xinds'][ii]))):
948+
visit_freq[walk_data['xinds'][ii][jj],
949+
walk_data['yinds'][ii][jj]] += 1
950+
951+
# Clip out highest percentile to correct possible outliers
952+
vmax = float(np.nanpercentile(visit_freq, clip))
953+
visit_freq = np.clip(visit_freq, 0, vmax)
954+
955+
# If applicable, do smoothing
956+
if sigma > 0:
957+
visit_freq = gaussian_filter(visit_freq, sigma=sigma)
958+
visit_freq[visit_freq==np.min(visit_freq)] = np.nan
959+
else:
960+
visit_freq[visit_freq==0] = np.nan
961+
962+
# Normalize to 0-1
963+
visit_freq = visit_freq/np.nanmax(visit_freq)
964+
965+
return visit_freq
966+
967+
968+
def nourishment_time(walk_data, raster_size, sigma=0.7, clip=99.5):
969+
"""Determine the nourishment time of a particle injection
970+
971+
Function will measure the average length of time particles spend in each
972+
area of the domain for a given seed location, as indicated by the history
973+
of particle travel times in walk_data. Returns a heatmap raster, in
974+
which values indicate the average length of time particles tend to remain
975+
in each cell (after spatial filtering to reduce noise in the random walk).
976+
977+
**Inputs** :
978+
979+
walk_data : `dict`
980+
Dictionary of all prior x locations, y locations, and travel
981+
times (the output of run_iteration)
982+
983+
raster_size : `tuple`
984+
Tuple (L,W) of the domain dimensions, i.e. the output of
985+
numpy.shape(raster).
986+
987+
sigma : `float`, optional
988+
Degree of spatial smoothing of the area, implemented using a
989+
Gaussian kernal of the same sigma, via scipy.ndimage.gaussian_filter
990+
Default is light smoothing with sigma = 0.7 (to turn off smoothing,
991+
set sigma = 0)
992+
993+
clip : `float`, optional
994+
Percentile at which to truncate the distribution. Particles which
995+
get stuck can lead to errors at the high-extreme, so this
996+
parameter is used to normalize by a "near-max". Default is the
997+
99.5th percentile. To use true max, specify clip = 100
998+
999+
**Outputs** :
1000+
1001+
mean_time : `numpy.ndarray`
1002+
Array of mean occupation time, with cell values representing the
1003+
mean time particles spent in that cell. If sigma > 0, the array
1004+
values include spatial filtering
1005+
1006+
"""
1007+
if sigma > 0:
1008+
from scipy.ndimage import gaussian_filter
1009+
1010+
# Measure visit frequency
1011+
visit_freq = np.zeros(raster_size)
1012+
time_total = visit_freq.copy()
1013+
for ii in list(range(len(walk_data['xinds']))):
1014+
for jj in list(range(1, len(walk_data['xinds'][ii])-1)):
1015+
# Running total of particles in this cell to find average
1016+
visit_freq[walk_data['xinds'][ii][jj],
1017+
walk_data['yinds'][ii][jj]] += 1
1018+
# Count the time in this cell as 0.5*last_dt + 0.5*next_dt
1019+
last_dt = (walk_data['travel_times'][ii][jj] - \
1020+
walk_data['travel_times'][ii][jj-1])
1021+
next_dt = (walk_data['travel_times'][ii][jj+1] - \
1022+
walk_data['travel_times'][ii][jj])
1023+
time_total[walk_data['xinds'][ii][jj],
1024+
walk_data['yinds'][ii][jj]] += 0.5*(last_dt + next_dt)
1025+
# Find mean time in each cell
1026+
with np.errstate(divide='ignore', invalid='ignore'):
1027+
mean_time = time_total / visit_freq
1028+
mean_time[visit_freq == 0] = 0
1029+
# Prone to numerical outliers, so clip out extremes
1030+
vmax = float(np.nanpercentile(mean_time, clip))
1031+
mean_time = np.clip(mean_time, 0, vmax)
1032+
1033+
# If applicable, do smoothing
1034+
if sigma > 0:
1035+
mean_time = gaussian_filter(mean_time, sigma=sigma)
1036+
mean_time[mean_time==np.min(mean_time)] = np.nan
1037+
else:
1038+
mean_time[mean_time==0] = np.nan
1039+
1040+
return mean_time
1041+
1042+
9041043
def unstruct2grid(coordinates,
9051044
quantity,
9061045
cellsize,
907-
k_nearest_neighbors=3):
1046+
k_nearest_neighbors=3,
1047+
boundary=None,
1048+
crop=True):
9081049
"""Convert unstructured model outputs into gridded arrays.
9091050
9101051
Interpolates model variables (e.g. depth, velocity) from an
@@ -929,10 +1070,19 @@ def unstruct2grid(coordinates,
9291070
cellsize : `float or int`
9301071
Length along one square cell face.
9311072
932-
k_nearest_neighbors : `int`
1073+
k_nearest_neighbors : `int`, optional
9331074
Number of nearest neighbors to use in the interpolation.
9341075
If k>1, inverse-distance-weighted interpolation is used.
9351076
1077+
boundary : `list`, optional
1078+
List [] of (x,y) coordinates used to delineate the boundary of
1079+
interpolation. Points outside the polygon will be assigned as nan.
1080+
Format needs to match requirements of matplotlib.path.Path()
1081+
1082+
crop : `bool`, optional
1083+
If a boundary is specified, setting crop to True will eliminate
1084+
any all-NaN borders from the interpolated rasters.
1085+
9361086
**Outputs** :
9371087
9381088
interp_func : `function`
@@ -947,6 +1097,10 @@ def unstruct2grid(coordinates,
9471097
Array of quantity after interpolation.
9481098
9491099
"""
1100+
import matplotlib
1101+
import scipy
1102+
from scipy import interpolate
1103+
9501104
cellsize = float(cellsize)
9511105

9521106
# Make sure all input values are floats
@@ -969,6 +1123,11 @@ def unstruct2grid(coordinates,
9691123
gridXY_array = np.array([np.concatenate(gridX),
9701124
np.concatenate(gridY)]).transpose()
9711125
gridXY_array = np.ascontiguousarray(gridXY_array)
1126+
1127+
# If a boundary has been specified, create array to index outside it
1128+
if boundary is not None:
1129+
path = matplotlib.path.Path(boundary)
1130+
outside = ~path.contains_points(gridXY_array)
9721131

9731132
# Create Interpolation function
9741133
if k_nearest_neighbors == 1: # Only use nearest neighbor
@@ -980,9 +1139,15 @@ def unstruct2grid(coordinates,
9801139
def interp_func(data):
9811140
if isinstance(data, list):
9821141
data = np.array(data)
983-
gridded_data = data[gridqInd]
1142+
gridded_data = data[gridqInd].astype(float)
1143+
if boundary is not None:
1144+
gridded_data[outside] = np.nan # Crop to bounds
9841145
gridded_data.shape = (len(yvect), len(xvect))
9851146
gridded_data = np.flipud(gridded_data)
1147+
if boundary is not None and crop is True:
1148+
mask = ~np.isnan(gridded_data) # Delete all-nan border
1149+
gridded_data = gridded_data[np.ix_(mask.any(1),
1150+
mask.any(0))]
9861151
return gridded_data
9871152
else:
9881153
# Inverse-distance interpolation
@@ -999,10 +1164,16 @@ def interp_func(data):
9991164
num = 0.
10001165
for i in list(range(k_nearest_neighbors)):
10011166
denom += nn_wts[:, i]
1002-
num += data[nn_inds[:, i]]*nn_wts[:, i]
1167+
num += data[nn_inds[:, i]].astype(float)*nn_wts[:, i]
10031168
gridded_data = (num/denom)
1169+
if boundary is not None:
1170+
gridded_data[outside] = np.nan # Crop to bounds
10041171
gridded_data.shape = (len(yvect), len(xvect))
10051172
gridded_data = np.flipud(gridded_data)
1173+
if boundary is not None and crop is True:
1174+
mask = ~np.isnan(gridded_data) # Delete all-nan border
1175+
gridded_data = gridded_data[np.ix_(mask.any(1),
1176+
mask.any(0))]
10061177
return gridded_data
10071178

10081179
# Finally, call the interpolation function to create array:

0 commit comments

Comments
 (0)