Skip to content

Commit 16fdb26

Browse files
authored
Merge pull request #60 from eghenson/master
Add optional parameters to walk data and a new function related to seeding
2 parents 4c9f638 + fa64f59 commit 16fdb26

6 files changed

Lines changed: 874 additions & 38 deletions

File tree

dorado/lagrangian_walker.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,7 @@ def steep_descent(probs):
467467
return idx
468468

469469

470-
def particle_stepper(Particles, current_inds, travel_times):
470+
def particle_stepper(Particles, current_inds, travel_times, start_optional):
471471
"""Step particles a single iteration.
472472
473473
**Inputs** :
@@ -481,15 +481,25 @@ def particle_stepper(Particles, current_inds, travel_times):
481481
travel_times : `list`
482482
List of initial travel times for the particles
483483
484+
start_optional: 'dict of lists'
485+
A dictionary of lists of optional user inputs for the particle stepper. Options are shown in the particles object documentation.
486+
487+
484488
**Outputs** :
485489
486490
new_inds : `list`
487491
List of the new particle locations after the single iteration
488492
489493
travel_times : `list`
490494
List of the travel times associated with the particle movements
495+
496+
updated_optional : `dict of lists`
497+
A dictionary of lists of optional outputs for the particle stepper. Options are shown in the particles object documentation.
491498
492499
"""
500+
# Use roi_grid from the particle object if it exists, otherwise use None
501+
ROI = getattr(Particles, 'roi_grid', None)
502+
493503
inds = current_inds # get indices as coordinates in the domain
494504
# split the indices into tuples
495505
inds_tuple = [(inds[i][0], inds[i][1]) for i in range(len(inds))]
@@ -522,4 +532,16 @@ def particle_stepper(Particles, current_inds, travel_times):
522532
for i in range(0, len(travel_times))]
523533
travel_times = list(travel_times)
524534

525-
return new_inds, travel_times
535+
# Optional variables
536+
updated_optional = {}
537+
538+
if ROI is not None:
539+
updated_optional['roi_flag'] = [1 if ROI[x, y] == 1 else 0 for x, y in new_inds]
540+
541+
for var, _ in start_optional.items():
542+
if var not in updated_optional:
543+
if var in Particles.available_particle_vars:
544+
grid = Particles.available_particle_vars[var]
545+
updated_optional[var] = [grid[x, y] for x, y in new_inds]
546+
547+
return new_inds, travel_times, updated_optional

dorado/parallel_routing.py

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ class to hold a bunch of attributes and do the particle generation and
3333
**Outputs** :
3434
3535
all_walk_data : `list`
36-
Nested list of all x and y locations and travel times, with
37-
details same as input previous_walk_data
36+
Nested list of all x and y locations, travel times, and
37+
any optional variables, with details same as input previous_walk_data
3838
3939
"""
4040
pobj.particles.generate_particles(pobj.Np_tracer, pobj.seed_xloc,
@@ -59,27 +59,18 @@ def combine_result(par_result):
5959
par_result : `list`
6060
List of length(num_cores) with a dictionary of the beg/end indices
6161
and travel times for each particle computed by that process/core
62-
62+
6363
**Outputs** :
64-
65-
single_result : `list`
66-
Nested list that matches 'all_walk_data'
67-
64+
single_result : `dict`
65+
Combined results dictionary containing all tracked variables.
6866
"""
6967
# initiate final results dictionary
70-
single_result = dict()
71-
single_result['x_inds'] = []
72-
single_result['y_inds'] = []
73-
single_result['travel_times'] = []
68+
single_result = {var: [] for var in par_result[0].keys()}
7469

7570
# populate the dictionary
76-
# loop through results for each core
77-
for i in range(0, len(par_result)):
78-
# append results for each category
79-
for j in range(0, len(par_result[i][0])):
80-
single_result['x_inds'].append(par_result[i][0][j])
81-
single_result['y_inds'].append(par_result[i][1][j])
82-
single_result['travel_times'].append(par_result[i][2][j])
71+
for result in par_result:
72+
for var in result.keys():
73+
single_result[var].extend(result[var])
8374

8475
return single_result
8576

@@ -139,7 +130,7 @@ def parallel_routing(particles, num_iter, Np_tracer, seed_xloc, seed_yloc,
139130
140131
"""
141132
# make parallel object to assign to function
142-
pobj = parallel_obj
133+
pobj = parallel_obj()
143134
pobj.particles = particles
144135
pobj.num_iter = num_iter
145136
pobj.Np_tracer = Np_tracer
@@ -154,4 +145,7 @@ def parallel_routing(particles, num_iter, Np_tracer, seed_xloc, seed_yloc,
154145
par_result = p.map(run_iter, p_list)
155146
p.terminate()
156147

157-
return par_result
148+
# combine results before returning to user
149+
combined = combine_result(par_result)
150+
151+
return combined

dorado/particle_track.py

Lines changed: 158 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,19 @@ def __init__(self, params):
388388
self.angles = np.array([[3*pi/4, pi/2, pi/4],
389389
[pi, 0, 0],
390390
[5*pi/4, 3*pi/2, 7*pi/4]])
391-
391+
392+
# Binary roi in the shape of model grid for particle flagging with each step
393+
if getattr(params, 'roi_grid', None) is not None:
394+
if params.roi_grid.shape != self.depth.shape:
395+
raise ValueError("roi_grid shape " + str(params.roi_grid.shape) +
396+
" does not match model grid shape " +
397+
str(self.depth.shape) +
398+
". roi_grid must be the same shape as the depth array.")
399+
self.roi_grid = params.roi_grid
400+
logger.info("roi provided and stored in the particle object.")
401+
else:
402+
self.roi_grid = None
403+
392404
# initialize number of particles as 0
393405
self.Np_tracer = 0
394406

@@ -398,6 +410,37 @@ def __init__(self, params):
398410
# initialize routing weights array
399411
lw.make_weight(self)
400412

413+
# -------------------------------------------------------
414+
# Define additional variables to track with particle movement (beyond xinds, yinds, and travel_times)
415+
# Usage: params = pt.modelParams(); params.particle_variables = ['depth', 'roi_flag']
416+
# -------------------------------------------------------
417+
# Base variables always stored
418+
self.base_walk_variables = ['xinds', 'yinds', 'travel_times']
419+
420+
# Available grid variables that can be sampled
421+
self.available_particle_vars = {
422+
'depth': self.depth,
423+
'stage': self.stage,
424+
'qx': self.qx,
425+
'qy': self.qy,
426+
'u': self.u,
427+
'v': self.v,
428+
}
429+
430+
# Read optional variables from params
431+
if getattr(params, 'particle_variables', None) is not None:
432+
self.particle_variables = params.particle_variables
433+
else:
434+
self.particle_variables = []
435+
436+
if 'roi_flag' not in self.particle_variables and self.roi_grid is not None:
437+
self.particle_variables.append('roi_flag')
438+
439+
# optional outputs follow particle variables
440+
self.optional_outputs = list(self.particle_variables)
441+
442+
# Combine base + optional variables
443+
self.tracked_variables = self.base_walk_variables + self.particle_variables
401444

402445
# function to clear walk data if you've made a mistake while generating it
403446
def clear_walk_data(self):
@@ -474,6 +517,8 @@ def generate_particles(self,
474517
method)
475518

476519
init_walk_data = dict() # create init_walk_data dictionary
520+
# define base variables again
521+
base_vars = self.base_walk_variables
477522

478523
# initialize new travel times list
479524
if (seed_time != 0) and (self.verbose is True):
@@ -512,6 +557,20 @@ def generate_particles(self,
512557
start_yindices = new_yinds
513558
start_times = new_times
514559

560+
# initialize optional variables from available_particle_vars
561+
optional_start_vars = dict()
562+
for var in self.tracked_variables:
563+
if var not in base_vars:
564+
if var == 'roi_flag' and self.roi_grid is not None:
565+
optional_start_vars[var] = [[1 if self.roi_grid[x, y] == 1 else 0]
566+
for x, y in zip(new_start_xindices, new_start_yindices)]
567+
elif var in self.available_particle_vars:
568+
grid_values = self.available_particle_vars[var]
569+
optional_start_vars[var] = [[grid_values[x, y]] for x, y in zip(new_start_xindices, new_start_yindices)]
570+
else:
571+
raise ValueError(f"Variable {var} is not a recognized variable to track. Available variables are: {list(self.available_particle_vars.keys())} and 'roi_flag' (if roi_grid is provided).")
572+
573+
515574
if self.walk_data is not None:
516575
# if there is walk_data from a previous call to the generator,
517576
# or from simulating particle transport previously, then we want
@@ -524,6 +583,12 @@ def generate_particles(self,
524583
start_xindices = internal_xinds + start_xindices
525584
start_yindices = internal_yinds + start_yindices
526585
start_times = internal_times + start_times
586+
587+
# merge optional variables with self.walk_data
588+
for var, new_var_list in optional_start_vars.items():
589+
if var in self.walk_data:
590+
internal_var = self.walk_data[var]
591+
optional_start_vars[var] = internal_var + new_var_list
527592

528593
if previous_walk_data is not None:
529594
# If the generator has been run before, or if new
@@ -539,13 +604,20 @@ def generate_particles(self,
539604
start_yindices = prev_yinds + start_yindices
540605
start_times = prev_times + start_times
541606

607+
# merge optional variables with previous_walk_data
608+
for var, new_var_list in optional_start_vars.items():
609+
if var in previous_walk_data:
610+
prev_var = previous_walk_data[var]
611+
optional_start_vars[var] = prev_var + new_var_list
612+
542613
# determine the new total number of particles we have now
543614
self.Np_tracer = len(start_xindices)
544615

545616
# store information in the init_walk_data dictionary and return it
546617
init_walk_data['xinds'] = start_xindices
547618
init_walk_data['yinds'] = start_yindices
548619
init_walk_data['travel_times'] = start_times
620+
init_walk_data.update(optional_start_vars)
549621

550622
# store the initialized walk data within self
551623
self.walk_data = init_walk_data
@@ -607,6 +679,17 @@ def run_iteration(self, target_time=None, max_iter=1e4):
607679
all_times = self.walk_data['travel_times']
608680
start_times = [all_times[i][-1] for i in
609681
list(range(self.Np_tracer))]
682+
# optional variables
683+
optional_vars = {}
684+
for var in self.tracked_variables:
685+
if var not in ['xinds', 'yinds', 'travel_times']:
686+
if var in self.walk_data:
687+
optional_vars[var] = self.walk_data[var]
688+
else:
689+
optional_vars[var] = [[None] for _ in range(self.Np_tracer)]
690+
691+
start_optional = {var: [optional_vars[var][i][-1] for i in range(self.Np_tracer)]
692+
for var in optional_vars}
610693

611694
# merge x and y indices into list of [x,y] pairs
612695
start_pairs = [[start_xindices[i], start_yindices[i]] for i in
@@ -615,8 +698,8 @@ def run_iteration(self, target_time=None, max_iter=1e4):
615698
# Do the particle movement
616699
if target_time is None:
617700
# If we're not aiming for a specific time, step the particles
618-
new_inds, travel_times = lw.particle_stepper(self, start_pairs,
619-
start_times)
701+
new_inds, travel_times, updated_optional = lw.particle_stepper(self, start_pairs,
702+
start_times, start_optional)
620703

621704
for ii in list(range(self.Np_tracer)):
622705
# Don't duplicate location
@@ -626,11 +709,15 @@ def run_iteration(self, target_time=None, max_iter=1e4):
626709
all_xinds[ii].append(new_inds[ii][0])
627710
all_yinds[ii].append(new_inds[ii][1])
628711
all_times[ii].append(travel_times[ii])
629-
712+
for var in optional_vars:
713+
optional_vars[var][ii].append(updated_optional[var][ii])
714+
630715
# Store travel information in all_walk_data
631716
all_walk_data['xinds'] = all_xinds
632717
all_walk_data['yinds'] = all_yinds
633718
all_walk_data['travel_times'] = all_times
719+
for var in optional_vars:
720+
all_walk_data[var] = optional_vars[var]
634721

635722
else:
636723
# If we ARE aiming for a specific time
@@ -657,17 +744,18 @@ def run_iteration(self, target_time=None, max_iter=1e4):
657744
while abs(all_times[ii][-1] - target_time) >= \
658745
abs(all_times[ii][-1] + est_next_dt - target_time):
659746
# for particle ii, take a step from most recent index
660-
new_inds, travel_times = lw.particle_stepper(
661-
self, [[all_xinds[ii][-1], all_yinds[ii][-1]]],
662-
[all_times[ii][-1]])
663-
747+
new_inds, travel_times, updated_optional = lw.particle_stepper(self, [[all_xinds[ii][-1], all_yinds[ii][-1]]],[all_times[ii][-1]],
748+
{var: [optional_vars[var][ii][-1]] for var in optional_vars})
749+
664750
# Don't duplicate location
665751
# if particle is standing still at a boundary
666752
if new_inds[0] != [all_xinds[ii][-1],
667753
all_yinds[ii][-1]]:
668754
all_xinds[ii].append(new_inds[0][0])
669755
all_yinds[ii].append(new_inds[0][1])
670756
all_times[ii].append(travel_times[0])
757+
for var in optional_vars:
758+
optional_vars[var][ii].append(updated_optional[var][0])
671759
else:
672760
break
673761

@@ -684,7 +772,9 @@ def run_iteration(self, target_time=None, max_iter=1e4):
684772
all_walk_data['xinds'] = all_xinds
685773
all_walk_data['yinds'] = all_yinds
686774
all_walk_data['travel_times'] = all_times
687-
775+
for var in optional_vars:
776+
all_walk_data[var] = optional_vars[var]
777+
688778
# write out warning if particles exceed step limit
689779
if (len(_iter_particles) > 0) and (self.verbose is True):
690780
warnings.warn(str(len(_iter_particles)) + "Particles"
@@ -917,7 +1007,7 @@ def exposure_time(walk_data,
9171007
Np_tracer = len(walk_data['xinds']) # Number of particles
9181008
# Array to be populated
9191009
exposure_times = np.zeros([Np_tracer], dtype='float')
920-
# list of particles that don't exit ROI
1010+
# list of particles that don't exit roi
9211011
_short_list = []
9221012

9231013
# Loop through particles to measure exposure time
@@ -1244,3 +1334,61 @@ def interp_func(data):
12441334
gridded_quantity = interp_func(quantity)
12451335

12461336
return interp_func, gridded_quantity
1337+
1338+
def flux_proportional_seeding(Q,
1339+
N_total,
1340+
num_steps):
1341+
""" Compute flux-proportional particle counts per timestep.
1342+
1343+
This function is useful if seeding particles under unsteady discharge conditions
1344+
by enforcing that the number of particles seeded at each timestep is proportional
1345+
to the magnitude of discharge at that timestep.
1346+
1347+
**Inputs** :
1348+
----------
1349+
Q : 'np.ndarray'
1350+
Discharge array where:
1351+
rows = timestep since model start where seeding is occurring
1352+
column = discharge values at those intervals
1353+
1354+
N_total : 'int'
1355+
Total number of particles to seed.
1356+
1357+
num_steps : 'int',
1358+
Number of timesteps where seeding occurs.
1359+
1360+
**Outputs** :
1361+
-------
1362+
1363+
particles_per_timestep : `numpy.ndarray`
1364+
Array of integer particle counts per seeding timestep.
1365+
1366+
"""
1367+
# Take absolute value for flow reversals
1368+
Q_abs = np.abs(Q)
1369+
1370+
# Ensure correct number of steps
1371+
if len(Q_abs) < num_steps:
1372+
raise ValueError("Not enough timesteps in the discharge array to match num_steps.")
1373+
1374+
# Ensure total discharge is nonzero
1375+
Q_sum = Q_abs.sum()
1376+
if Q_sum == 0:
1377+
raise ValueError("All discharge values are zero. Cannot distribute particles.")
1378+
1379+
# Compute ideal particle counts
1380+
N_float = Q_abs / Q_abs.sum() * N_total
1381+
N_int = np.floor(N_float).astype(int)
1382+
1383+
# Remainder distribution
1384+
remainders = N_float - N_int
1385+
N_remaining = N_total - N_int.sum()
1386+
1387+
if N_remaining > 0:
1388+
add_idx = np.argsort(remainders)[-N_remaining:]
1389+
N_int[add_idx] += 1
1390+
1391+
# Final check
1392+
assert N_int.sum() == N_total, "Particle rounding failed!"
1393+
1394+
return N_int

0 commit comments

Comments
 (0)