@@ -2995,6 +2995,10 @@ def __init__(self, mesh, recycle_rate=0, verbose=False, clip_to_mesh=True):
29952995 # a Lagrangian history registers its first sampling here so it sees
29962996 # the field at the launch positions, not at the landing ones.
29972997 self ._pre_advection_hooks = []
2998+ # Population control: a dict of repopulate() keyword arguments (or
2999+ # None). When set, advection() ends with repopulate(**population_control)
3000+ # so no cell is left starved before the next fit of a cells proxy.
3001+ self .population_control = None
29983002 self ._index = None
29993003 # Particle -> proxy-node transfer operators, keyed by geometry and
30003004 # stencil and shared by every proxied variable of this swarm. Entries
@@ -4952,6 +4956,183 @@ def _data_layout(self, i, j=None):
49524956 if self .vtype == uw .VarType .MATRIX :
49534957 return i + j * self .shape [0 ]
49544958
4959+ @timing .routine_timer_decorator
4960+ @uw .collective_operation
4961+ def repopulate (
4962+ self ,
4963+ min_per_cell = None ,
4964+ max_per_cell = None ,
4965+ values = None ,
4966+ nnn = None ,
4967+ order = 0 ,
4968+ verbose = False ,
4969+ ):
4970+ """Add particles to cells that hold too few, remove from cells that hold
4971+ too many, so every cell can support a well-posed fit of its particles.
4972+
4973+ The trigger is the per-cell census (owning cells from the strict
4974+ locator). A starved cell is filled from its own lattice, the points
4975+ ``populate`` uses (degree ``fill_param``, cell interior), choosing the
4976+ lattice points farthest from the particles already present. A new
4977+ particle takes, for every variable, the RBF reconstruction from the
4978+ nearest existing particles at its position: bounded Shepard weights by
4979+ default (``order=0``), since a starved cell is where the neighbours
4980+ are far and a linear-exact tail extrapolates (measured: values of 100
4981+ on a field bounded by 1 in the emptied corners of a rotating box);
4982+ ``order=1`` gives the linear-exact reconstruction. ``values`` overrides
4983+ a variable with a callable ``f(coords) -> (n, components)`` or a
4984+ constant, an inflow datum for instance. A cell above
4985+ ``max_per_cell`` loses its most redundant particles, those closest to
4986+ a neighbour in the same cell.
4987+
4988+ Rank-local placement (a cell is filled by the rank that owns it), but
4989+ collective: every rank must call it, the domain test reduces.
4990+
4991+ Parameters
4992+ ----------
4993+ min_per_cell : int, optional
4994+ Particles a cell must hold; default the lattice count of
4995+ ``fill_param`` (the density ``populate`` gave).
4996+ max_per_cell : int, optional
4997+ Cap above which particles are removed; default no removal.
4998+ values : dict, optional
4999+ ``{variable or name: callable or constant}`` for new particles.
5000+ nnn : int, optional
5001+ Neighbours in the RBF reconstruction (default ``2 (dim + 1)``).
5002+ order : {0, 1}, optional
5003+ RBF reconstruction order for new particles: 0 bounded (default),
5004+ 1 linear-exact.
5005+
5006+ Returns
5007+ -------
5008+ (added, removed) : the counts on this rank.
5009+ """
5010+ mesh = self .mesh
5011+ dim = self .cdim
5012+ fill = getattr (self , "fill_param" , None ) or 1
5013+ lattice = np .asarray (mesh ._get_coords_for_basis (fill , continuous = False ))
5014+ c0 , c1 = mesh .dm .getHeightStratum (0 )
5015+ ncells = c1 - c0
5016+ n_lat = lattice .shape [0 ] // max (ncells , 1 )
5017+ if min_per_cell is None :
5018+ min_per_cell = n_lat
5019+ if max_per_cell is not None :
5020+ min_per_cell = min (min_per_cell , max_per_cell ) # a cap below the lattice count wins
5021+
5022+ # Every rank must reach the (collective) domain test before any
5023+ # rank-local branch; the census itself is rank-local.
5024+ lat_owned = np .asarray (mesh .points_in_domain (lattice , strict_validation = True ), dtype = bool )
5025+ self ._flush_pending_petsc_sync ()
5026+ X = np .array (self ._particle_coordinates .data , copy = True ) if self .local_size > 0 \
5027+ else np .zeros ((0 , dim ))
5028+ cells = np .asarray (mesh ._robust_owning_cells (X ), dtype = np .int64 ) if X .shape [0 ] else np .zeros (0 , np .int64 )
5029+ npc = np .bincount (cells [cells >= 0 ], minlength = ncells )
5030+ lat_cells = np .asarray (mesh ._robust_owning_cells (lattice ), dtype = np .int64 )
5031+ owned = np .zeros (ncells , dtype = bool )
5032+ owned [lat_cells [lat_owned & (lat_cells >= 0 )]] = True
5033+
5034+ added = removed = 0
5035+
5036+ # ---- removal: the most redundant particles of over-full cells ----------
5037+ if max_per_cell is not None and X .shape [0 ] > 0 :
5038+ drop = []
5039+ for c in np .nonzero (owned & (npc > max_per_cell ))[0 ]:
5040+ idx = np .nonzero (cells == c )[0 ]
5041+ P = X [idx ]
5042+ d = np .linalg .norm (P [:, None , :] - P [None , :, :], axis = 2 )
5043+ np .fill_diagonal (d , np .inf )
5044+ nearest = d .min (axis = 1 )
5045+ surplus = int (npc [c ] - max_per_cell )
5046+ drop .extend (idx [np .argsort (nearest )[:surplus ]].tolist ())
5047+ if drop :
5048+ for index in sorted (drop , reverse = True ):
5049+ self .dm .removePointAtIndex (int (index ))
5050+ removed = len (drop )
5051+ keep = np .ones (X .shape [0 ], dtype = bool )
5052+ keep [drop ] = False
5053+ X , cells = X [keep ], cells [keep ]
5054+ npc = np .bincount (cells [cells >= 0 ], minlength = ncells )
5055+ self ._invalidate_canonical_data ()
5056+
5057+ # ---- addition: starved cells, lattice points farthest from particles -
5058+ need = np .where (owned , np .maximum (min_per_cell - npc , 0 ), 0 )
5059+ new_coords = []
5060+ if need .sum () > 0 :
5061+ cand_ok = lat_owned & (lat_cells >= 0 ) & (need [np .maximum (lat_cells , 0 )] > 0 )
5062+ cand = lattice [cand_ok ]
5063+ cand_cells = lat_cells [cand_ok ]
5064+ if X .shape [0 ] > 0 :
5065+ dist , _ = uw .kdtree .KDTree (X ).query (cand , k = 1 , sqr_dists = False )
5066+ dist = np .asarray (dist ).reshape (- 1 )
5067+ else :
5068+ dist = np .zeros (cand .shape [0 ])
5069+ sort_idx = np .lexsort ((- dist , cand_cells )) # by cell, farthest first
5070+ cand , cand_cells , dist = cand [sort_idx ], cand_cells [sort_idx ], dist [sort_idx ]
5071+ # rank within cell
5072+ start = np .searchsorted (cand_cells , np .arange (ncells ), side = "left" )
5073+ rank_in_cell = np .arange (cand .shape [0 ]) - start [cand_cells ]
5074+ take = rank_in_cell < need [cand_cells ]
5075+ new_coords = cand [take ]
5076+
5077+ n_new = int (len (new_coords ))
5078+ if n_new > 0 :
5079+ n_old = max (self .dm .getLocalSize (), 0 )
5080+ nnn = nnn or 2 * (dim + 1 )
5081+ nnn = min (nnn , max (n_old , 1 ))
5082+ rbf_order = order if nnn >= dim + 2 else 0
5083+ operator = None
5084+ if n_old > 0 :
5085+ operator = uw .kdtree .KDTree (X ).interpolation_matrix (
5086+ np .asarray (new_coords ), nnn = nnn , p = 2 , order = rbf_order )
5087+ # raw values of every variable at the old particles, BEFORE the add
5088+ raw_old = {}
5089+ for name , var in self ._vars .items ():
5090+ if var is self ._particle_coordinates or var .clean_name in (
5091+ "DMSwarmPIC_coor" , "DMSwarm_rank" , "DMSwarm_X0" ):
5092+ continue
5093+ raw_old [name ] = np .asarray (var .unpack_raw_data_from_petsc (squeeze = False )).reshape (n_old , - 1 )
5094+
5095+ self .dm .finalizeFieldRegister ()
5096+ self .dm .addNPoints (n_new )
5097+ coords = self .dm .getField ("DMSwarmPIC_coor" ).reshape ((- 1 , dim ))
5098+ coords [n_old :, :] = np .asarray (new_coords )
5099+ self .dm .restoreField ("DMSwarmPIC_coor" )
5100+ ranks = self .dm .getField ("DMSwarm_rank" )
5101+ ranks .reshape (- 1 )[n_old :] = uw .mpi .rank
5102+ self .dm .restoreField ("DMSwarm_rank" )
5103+ x0 = getattr (self , "_X0" , None )
5104+ if x0 is not None :
5105+ f = self .dm .getField (x0 .clean_name ).reshape ((- 1 , dim ))
5106+ f [n_old :, :] = np .asarray (new_coords )
5107+ self .dm .restoreField (x0 .clean_name )
5108+
5109+ values = values or {}
5110+ for name , var in self ._vars .items ():
5111+ if name not in raw_old :
5112+ continue
5113+ spec = values .get (var , values .get (name , values .get (var .clean_name )))
5114+ ncomp = raw_old [name ].shape [1 ] if n_old > 0 else var .num_components
5115+ if spec is not None :
5116+ vals = spec (np .asarray (new_coords )) if callable (spec ) else spec
5117+ vals = np .broadcast_to (np .asarray (vals , dtype = float ).reshape (n_new , - 1 ) if np .ndim (vals ) > 0 else vals , (n_new , ncomp ))
5118+ elif operator is not None :
5119+ vals = operator @ raw_old [name ]
5120+ else :
5121+ vals = np .zeros ((n_new , ncomp ))
5122+ f = self .dm .getField (var .clean_name ).reshape ((- 1 , ncomp ))
5123+ f [n_old :, :] = np .asarray (vals ).reshape (n_new , ncomp )
5124+ self .dm .restoreField (var .clean_name )
5125+ added = n_new
5126+ self ._invalidate_canonical_data ()
5127+
5128+ if added or removed :
5129+ self ._population_generation += 1
5130+ if verbose :
5131+ print (f"repopulate: rank { uw .mpi .rank } added { added } , removed { removed } "
5132+ f"(cells starved { int ((need > 0 ).sum ())} )" , flush = True )
5133+ return added , removed
5134+
5135+
49555136 @timing .routine_timer_decorator
49565137 def advection (
49575138 self ,
@@ -5182,6 +5363,9 @@ def advection(
51825363 delete_lost_points = True ,
51835364 )
51845365
5366+ if self .population_control is not None :
5367+ self .repopulate (** self .population_control )
5368+
51855369 return
51865370
51875371 @timing .routine_timer_decorator
0 commit comments