@@ -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