Skip to content

Commit 80ce199

Browse files
lmoresiclaude
andcommitted
Swarm.repopulate and population control; the cells fit guards against ill-conditioned cells
Swarm.repopulate(min_per_cell, max_per_cell, values, order) takes the per-cell census and refills a starved cell from its own lattice at the points farthest from the particles present. A new particle takes the bounded Shepard reconstruction of every swarm variable from its nearest neighbours (order=1 for linear-exact; measured extrapolating to 100 on a field bounded by 1 in a starved corner), or a supplied value. swarm.population_control = dict(...) runs it at the end of every advection(), before the next fit. Collective (the domain test reduces). The cells fit routes a cell whose Gram matrix condition number exceeds cond_max (1e6) to the patch fit whatever its count, and a flat patch keeps its mean: particles the advection clamps onto a wall slide along it as a line, the P2 fit of a line is singular (condition 1e300 at 92 particles), and that garbage grew by 1e12 in ten steps through the PIC read-back. Rotating Gaussian, PIC, ten particles per cell, C = 0.25: population control takes the L2 error from 1.6e-2 to 8.8e-3, level with the integration-point history; the untapered box with wall in- and outflow gives the same answer clamped or with exiting particles deleted. The cap (max_per_cell, closest-pair removal) costs accuracy and is off by default. Tests: test_0068 (refill exact for linear fields with order=1, value override and cap, population control under rotation), a collinear-cell test in test_0067; the history-before-advection test made rank-safe. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSGAFeA7qYXgkuw9ud8F2G
1 parent 8709113 commit 80ce199

5 files changed

Lines changed: 389 additions & 3 deletions

File tree

docs/developer/subsystems/integration-point-variables.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,47 @@ size are `nmin` and `patch_nnn` on `CellPolynomialProjector.fit`.
331331
M = uw.swarm.SwarmVariable("M", swarm, 1, proxy_location="cells", proxy_degree=2)
332332
```
333333

334+
### Repopulation: keeping every cell fit-able
335+
336+
A flow that empties cells starves the fit, and the two particle read-back
337+
schemes both diverged on emptied corner cells before repopulation existed.
338+
`Swarm.repopulate()` takes the per-cell census (owning cells from the strict
339+
locator) and refills a starved cell from its own lattice, the points
340+
`populate` uses, choosing the lattice points farthest from the particles
341+
present. A new particle takes, for every swarm variable, the bounded Shepard
342+
reconstruction from its nearest neighbours (`order=1` for the linear-exact
343+
reconstruction; a starved cell is where neighbours are far, and the linear
344+
tail extrapolated to values of 100 on a field bounded by 1), or a supplied
345+
value (`values={var: constant or callable}`, an inflow datum for instance).
346+
`swarm.population_control = dict(...)` makes every `advection()` end with a
347+
repopulation, which is what the cells proxy wants: the refill runs before
348+
the next fit.
349+
350+
```python
351+
swarm.population_control = dict() # refill to the populate() density
352+
swarm.population_control = dict(min_per_cell=8, values={T: 0.0})
353+
```
354+
355+
Count is not the whole criterion. Particles the advection clamps back onto a
356+
wall (`mesh.return_coords_to_bounds`) slide along it as a line, and the P2
357+
fit of a collinear set is singular whatever its count (measured: condition
358+
number 1e300 at 92 particles in a wall cell, garbage that grew by 1e12 in
359+
ten steps through the read-back). The fit therefore routes a cell whose Gram
360+
matrix has condition number above `cond_max` (1e6) to the patch fit, and a
361+
patch that is itself flat keeps only its mean. With that guard and
362+
population control the untapered rotating box, where every wall has an
363+
inflow and an outflow segment, runs to the same answer whether exiting
364+
particles are clamped or deleted (`mesh.return_coords_to_bounds = None`,
365+
the right setting for a true outflow, which also keeps the particle count
366+
from growing).
367+
368+
Measured on the rotating Gaussian (h = 0.1, C = 0.25, 10 particles per cell,
369+
PIC, one revolution): population control takes the L2 error from 1.6e-2 to
370+
8.8e-3, level with the integration-point history at 9.1e-3, because no cell
371+
is ever left to the linear patch fit. A cap (`max_per_cell`) thins over-full
372+
cells by removing the particles closest to a neighbour; measured it costs
373+
accuracy (6.8e-2) and is off by default.
374+
334375
### Why a least-squares fit and not a conservative transfer
335376

336377
The conservative particle-to-mesh transfer solves the rule mass matrix

src/underworld3/swarm.py

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

src/underworld3/utilities/cell_polynomial_projection.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def locate(self, coords):
118118

119119
# -- the fit ------------------------------------------------------------
120120

121-
def fit(self, coords, values, nmin=None, patch_nnn=None, old=None):
121+
def fit(self, coords, values, nmin=None, patch_nnn=None, old=None, cond_max=1.0e6):
122122
"""Fit every cell; returns nodal values shaped like ``meshVar.data``.
123123
124124
Parameters
@@ -131,6 +131,12 @@ def fit(self, coords, values, nmin=None, patch_nnn=None, old=None):
131131
old : current proxy values, shaped like ``meshVar.data``; after the
132132
first fit a cell with no particles keeps them (on the first fit,
133133
or without ``old``, it takes the linear patch fit).
134+
cond_max : a cell whose Gram matrix has a condition number above this
135+
is treated as thin (patch fit) however many particles it holds.
136+
Count is not enough: particles clamped onto a wall by the
137+
advection lie on a line, and the P2 fit of a line is singular
138+
(measured: condition 1e300 at 92 particles, garbage that grew
139+
by 1e12 in ten steps through the read-back).
134140
"""
135141
coords = np.asarray(coords, dtype=np.float64).reshape(-1, self.dim)
136142
values = np.asarray(values, dtype=np.float64).reshape(coords.shape[0], -1)
@@ -149,6 +155,15 @@ def fit(self, coords, values, nmin=None, patch_nnn=None, old=None):
149155
U = np.zeros((self.ncells, self.Nb, nc))
150156
nmin = nmin or self.Nb + 2
151157
dense = npc >= nmin
158+
self.n_ill_conditioned = 0
159+
if dense.any():
160+
ev = np.linalg.eigvalsh(G[dense])
161+
cond = ev[:, -1] / np.maximum(ev[:, 0], 1e-300)
162+
ill = cond > cond_max
163+
if ill.any():
164+
self.n_ill_conditioned = int(ill.sum())
165+
idx = np.nonzero(dense)[0][ill]
166+
dense[idx] = False
152167
if dense.any():
153168
ridge = 1e-10 * np.trace(G[dense], axis1=1, axis2=2)[:, None, None] / self.Nb
154169
U[dense] = np.linalg.solve(G[dense] + ridge * np.eye(self.Nb)[None], R[dense])
@@ -175,6 +190,13 @@ def fit(self, coords, values, nmin=None, patch_nnn=None, old=None):
175190
Rt = np.einsum("cpa,cpk->cak", A, psi[idx])
176191
ridge = 1e-10 * np.trace(Gt, axis1=1, axis2=2)[:, None, None] / (self.dim + 1) + 1e-30
177192
coef = np.linalg.solve(Gt + ridge * np.eye(self.dim + 1)[None], Rt) # (nthin, dim+1, nc)
193+
# A patch whose particles are themselves (nearly) collinear cannot
194+
# carry a gradient: keep only the constant term (the patch mean).
195+
evt = np.linalg.eigvalsh(Gt)
196+
flat = evt[:, -1] / np.maximum(evt[:, 0], 1e-300) > cond_max
197+
if flat.any():
198+
coef[flat, 1:, :] = 0.0
199+
coef[flat, 0, :] = psi[idx[flat]].mean(axis=1)
178200
Adof = np.concatenate([np.ones((self.Nb, 1)), self.xi_dof], axis=1) # (Nb, dim+1)
179201
U[thin] = np.einsum("ba,cak->cbk", Adof, coef)
180202

tests/test_0067_integration_point_proxy.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,13 +299,42 @@ def test_lagrangian_swarm_history_is_sampled_before_the_first_move():
299299
swarm=swarm, psi_fn=T.sym, vtype=uw.VarType.SCALAR, degree=2, continuous=False,
300300
order=1, proxy_location="cells",
301301
)
302+
X0 = uw.swarm.SwarmVariable("X0", swarm, 2) # launch position, carried by the particle
302303
swarm.populate(fill_param=2)
303304
assert not lag._history_initialised
304-
X_before = np.array(swarm._particle_coordinates.data, copy=True)
305+
with uw.synchronised_array_update():
306+
X0.data[...] = np.asarray(swarm._particle_coordinates.data)
305307
swarm.advection(sympy.Matrix([[0.1, 0.0]]), 0.5, order=2) # every particle moves +0.05 in x
306-
X_after = np.asarray(swarm._particle_coordinates.data)
307308
assert lag._history_initialised
309+
# Particles may have changed rank: compare each against the launch
310+
# position it carries, not against a rank-local array from before.
311+
X_before = np.asarray(X0.data)
312+
X_after = np.asarray(swarm._particle_coordinates.data)
308313
kept = np.abs(X_after[:, 0] - X_before[:, 0] - 0.05) < 1e-12 # particles not returned to bounds
309314
vals = np.asarray(lag.psi_star[0].data[:, 0])
310315
assert np.allclose(vals[kept], X_before[kept, 0], atol=1e-10) # launch positions ...
311316
assert not np.allclose(vals[kept], X_after[kept, 0], atol=1e-3) # ... not landing positions
317+
318+
319+
def test_cells_proxy_collinear_particles_do_not_blow_up():
320+
"""Particles clamped onto a wall lie on a line; the P2 fit of a line is
321+
singular. The fit routes such cells to the patch on the Gram condition
322+
number, and a patch that is itself flat keeps only its mean."""
323+
mesh = uw.meshing.UnstructuredSimplexBox(cellSize=0.1, qdegree=2)
324+
swarm = uw.swarm.Swarm(mesh)
325+
M = uw.swarm.SwarmVariable("M", swarm, 1, proxy_location="cells", proxy_degree=2)
326+
swarm.populate(fill_param=3)
327+
X = np.array(swarm._particle_coordinates.data, copy=True)
328+
# Pile every particle of the bottom row of cells onto the wall y = 1e-9
329+
bottom = X[:, 1] < 0.1
330+
X[bottom, 1] = 1e-9
331+
with uw.synchronised_array_update():
332+
M.data[:, 0] = 1.0 + X[:, 0] # values first: the move below migrates
333+
with uw.synchronised_array_update():
334+
swarm._particle_coordinates.data[...] = X
335+
swarm.migrate()
336+
M._update_proxy_if_stale()
337+
pr = M._cell_projector
338+
assert pr.n_ill_conditioned > 0
339+
vals = np.asarray(M._meshVar.data[:, 0])
340+
assert np.isfinite(vals).all() and vals.min() > 0.5 and vals.max() < 2.5, (vals.min(), vals.max())

0 commit comments

Comments
 (0)