Purpose¶
Underworld needs to move field values between point sets that have no shared
mesh topology: particles to proxy mesh nodes, nodes to arbitrary evaluation
points, one mesh to another after adaptation. The tool for that is a local
interpolant — for each target point, take its nnn nearest source points from
a kd-tree and form a weighted average.
This is general-purpose numerical machinery, and the goal is straightforward: the interpolation should be accurate. The single question that determines that is which fields the weights reproduce exactly. A scheme that cannot reproduce a uniform gradient will smear every field that has one, everywhere it is used, and no amount of tuning elsewhere recovers what it threw away.
That is what order selects on
underworld3.ckdtree.KDTree.rbf_interpolator_local().
The two schemes¶
order=0 — inverse distance (Shepard)¶
\(d_j\) is the actual distance to the neighbour, and p defaults to 1 — so
the default really is inverse distance. (Until #427 the kd-tree’s squared
distances were used directly, making the decay \(r^{-2p}\): the documented
default of p=2 was in fact \(1/r^4\).)
Weights are positive and sum to one. Consequences, both of them important:
A constant field is reproduced exactly.
The result is a convex combination of the neighbouring values, so it can never overshoot them. The interpolant is intrinsically bounded.
A field with a gradient is smeared, and the error does not vanish as the stencil tightens. Adding neighbours does not help; refining the point spacing helps only at first order.
order=1 — polyharmonic RBF with an affine tail¶
For a target \(x^*\) with neighbours \(x_j\), solve the small saddle-point system
with \(A_{ij} = \varphi(|x_i - x_j|)\), the polyharmonic (thin-plate) kernel \(\varphi(r) = r^2 \log r\), and the affine tail \(P = [1,\; x_j]\).
The lower block is the reproduction constraint \(P^{T} w = [1,\; x^*]\), so
hold to round-off by construction: constants and linear fields are exact.
Weights are signed, so the interpolant can overshoot the neighbouring values — the price of giving up the convex combination.
Cost is one dense \((nnn + d + 1)^3\) solve per target point, and the result is
still sparse: nnn non-zeros per row. The weights depend only on geometry, so
one solve serves every data component.
Measured¶
Relative error of a swarm proxy variable against the analytic field, at the proxy’s own nodal coordinates. A linear field lies exactly inside both the P1 and P2 proxy spaces, so the discretisation contributes nothing and every bit of the measured error is particle-to-node transfer error.
2D simplex box, cellSize=1/8, fill_param=4, proxy degree 1:
field |
|
|
|---|---|---|
linear, max |
4.6e-3 |
5.1e-16 |
quadratic, max |
9.2e-3 |
1.0e-4 |
quadratic, rms |
2.1e-3 |
2.1e-5 |
3D simplex box, cellSize=1/8, fill_param=4, proxy degree 1:
field |
|
|
|---|---|---|
linear, max |
4.8e-3 |
5.5e-16 |
quadratic, max |
8.5e-3 |
2.4e-4 |
quadratic, rms |
2.0e-3 |
2.3e-5 |
Two things to read off. First, the linear-field column is the clean statement: one scheme is exact, the other is not. Second — and this is the part that makes it a general accuracy improvement rather than a special case — the gain carries over to a field with curvature, by roughly two orders of magnitude in rms.
Widening the order=0 stencil does not close the gap. At nnn=12 the
inverse-distance error is unchanged to two significant figures, because the
error is set by the asymmetry of the neighbourhood, not by how many points are
in it.
Getting the operator, not just the values¶
When the same transfer is applied more than once — or when the operator is the product, as for a multigrid prolongation — build it once:
T = kdt.interpolation_matrix(target_coords, order=1) # scipy CSR
values = T @ data # identical to the value API
The weights depend only on geometry, so one build serves every field and every
component; the value API re-solves each call. T @ data is asserted equal to
rbf_interpolator_local(...) in the test suite so the two cannot drift.
Warning
rbf_stencil.linear_exact_weights is the raw kernel underneath, and it returns
zeros for degenerate rows plus a mask. It has no kd-tree, so it can neither
widen a stencil nor fall back. A caller that ignores the mask silently
interpolates to zero. Use interpolation_matrix unless you specifically need
the bare weights — its rows are never empty.
Row-wise construction carries no column guarantee¶
Every row has nnn non-zeros. Nothing guarantees every column is non-empty.
A source point that is not among the nnn nearest neighbours of any target
produces an empty column, and a consumer forming a Galerkin coarse operator
\(P^{T} A P\) then gets a singular matrix — the failure mode of issue #424.
This is a property of any row-wise kNN construction, not of this kernel, and it is not fixed by the scheme being linear-exact. Consumers that need full column rank must check for and repair empty columns themselves.
Choosing nnn¶
order=1 requires nnn >= dim + 2 and raises ValueError below that.
The reason is worth knowing. At exactly nnn == dim + 1 the affine block \(P\)
is square, so the constraint \(P^{T} w = [1, x^*]\) alone determines \(w\): the RBF
block is inert and the scheme collapses to bare barycentric interpolation on
the neighbour simplex. That is singular whenever those dim + 1 points are
collinear (2D) or coplanar (3D) — common near boundaries and on graded meshes.
The swarm proxy default is nnn = 2 * (dim + 1) (6 in 2D, 8 in 3D), which
leaves enough slack that degenerate neighbourhoods are rare. That is also what
nnn=None resolves to at order=1; order=0 keeps its historical default of
4. The default has to depend on order and dimension — a fixed default of 4
is below the enforced minimum in 3D and would simply raise.
Things that fail silently if you get them wrong¶
Build kd-trees from
.coords_nd, never.coords.MeshVariable.coordsdimensionalises once the model has reference quantities set, while particle coordinates andMeshVariable._get_kdtreeare non-dimensional. Mixing them raises from_convert_coords_to_tree_units(issue #426).The output carries no units. Coordinates are converted into the tree’s frame, but
datapasses through untouched and the return is a plain array. Re-attach units at the boundary if the caller needs them.order=1costs about 5xorder=0— a dense \((nnn+d+1)^3\) solve per target point. Irrelevant if you build an operator once; think twice in a hot loop.Do not validate against
uw.function.evaluate. It returns wrong values for P1 fields at points lying exactly on cell edges in 3D (issue #432), and proxy nodes sit on cell boundaries routinely. Compare against an analytic field, or against an independent implementation, instead.
Determinism¶
The weights are a pure function of the geometry: no global state, no random
numbers, no accumulation across calls. Repeated calls, and separate KDTree
instances built from equal point sets, return bit-identical results.
Where several source points are exactly equidistant from a target, which of them the kNN search selects is unspecified — but the choice is deterministic, so results reproduce run to run. Both properties are pinned by tests.
Degenerate stencils¶
If the neighbours cannot support an affine fit, the saddle system is singular. This is not hypothetical: it happens at boundaries and in graded meshes.
The implementation (underworld3.utilities.rbf_stencil.linear_exact_weights):
Pre-screens the affine block by SVD — degenerate if \(\sigma_{\min} < 10^{-8}\,\sigma_{\max}\).
Solves only the healthy stencils, batched and chunked over targets.
Validates the answer rather than guessing a condition number: it checks the two reproduction identities and finiteness on the computed weights, and demotes anything that fails.
Falls back to the inverse-distance weights for those points — less accurate there, but finite and bounded. It never returns NaN.
Warns once with the count and fraction that fell back. A silent geometric fallback is the failure mode behind issue #424; a per-point warning would be unusable, a single counted one is not.
Limiting: monotone¶
order=1 weights are signed, so the interpolant can oscillate where the data
is rough. monotone=True (or "clamp") bounds that.
The important part is what it bounds. The obvious limiter — clip the result to the min/max of the stencil’s source values — is wrong for a linear-exact scheme, and wrong in a way that is easy to miss:
Warning
A target that lies outside the convex hull of its own nnn neighbours has a
value outside their range even for an exactly linear field. Proxy nodes are
routinely in that position, and not only at domain boundaries. A raw min/max
clip therefore cannot distinguish legitimate extrapolation from ringing, and
fires on the linear part — destroying the one guarantee the scheme exists to
provide.
So the limiter here follows the slope-limiter discipline instead: never limit the linear reconstruction, limit only the correction on top of it.
Fit an affine function to the stencil data by least squares.
Keep that trend at the target untouched.
Bound the remaining RBF correction to the range of the non-affine residual the stencil actually exhibits.
Consequences, both measured:
On any field the scheme already reproduces exactly, the limiter is a no-op (it moves the answer by ~1e-15). Linear reproduction survives it.
On a rough field it does bite — and, like any limiter, it trades accuracy for boundedness where the correction genuinely exceeds the observed residual. On a
sin(6x) + |x|²test field it moved the answer by 2e-3 (2D) and 5e-2 (3D); in 3D that made the max error slightly worse (9.0e-2 → 1.3e-1), still well below inverse distance (2.7e-1).
Note what this does and does not promise: it bounds new oscillation relative to the local trend, not absolute range. A quantity that must stay inside hard physical bounds (a fraction in \([0,1]\)) needs its own clip on top.
Material level sets stay on order=0 — variance, not bias¶
IndexSwarmVariable builds one level-set MeshVariable per material index and
keeps its own inverse-distance weighting. It was tested against order=1 and
deliberately not changed — but not for the reason one might expect.
The tempting argument, a material indicator is piecewise constant so linear exactness has nothing to gain, is wrong. The indicator is the particle data; the quantity estimated at a node is the local material fraction, which near an interface is a smooth ramp. Reproducing a ramp is exactly what a constants-only scheme cannot do.
The real reason is that a level-set node estimates that fraction from a handful of integer samples, so its error has a variance term as well as a bias term:
For weights summing to one, uniform weighting minimises \(\sum_j w_j^2\) at \(1/nnn\). Inverse-distance weights are positive and stay near that floor; linear-exact weights are signed and sit far above it:
dim |
|
\(\sum w^2\) inverse distance |
\(\sum w^2\) |
\(1/nnn\) |
amplification |
|---|---|---|---|---|---|
2 |
6 |
0.223 |
2.314 |
0.167 |
10.4x |
2 |
20 |
0.079 |
0.922 |
0.050 |
11.7x |
3 |
6 |
0.188 |
1.290 |
0.167 |
6.9x |
3 |
20 |
0.061 |
0.657 |
0.050 |
10.9x |
Note the second row of each pair: inverse distance averages the noise down as
the stencil grows, while order=1 barely moves. So the noise cannot be bought
off with more neighbours.
Measured end to end, with particles assigned material 1 with probability
\(p(x)=x\) so that the exact nodal fraction is a known linear function
(three seeds, cellSize 1/16 and 1/32):
scheme |
bias |
rms |
level-set range |
|---|---|---|---|
shipped |
~1e-3 |
0.09 – 0.11 |
|
shipped |
~1e-3 |
0.18 |
|
gather, |
~1e-3 |
0.18 – 0.20 |
|
The bias that linear exactness would remove is already ~1e-3 for every scheme,
because a roughly symmetric stencil reproduces a linear ramp in expectation
anyway. What is left is variance — and order=1 amplifies it and breaks the
[0, 1] bound that constitutive_models.py relies on.
Partition of unity survives regardless of weight sign (all indices share one denominator, and the per-particle indicator flags sum to one, so the level sets sum to \(\sum_j w_j = 1\)). It was never the objection.
Note
The two branches are different algorithms, and only one has a stencil to
re-weight. update_type=0, the default, is a scatter: it queries nnn
nodes but masks with is_nearest, so each particle accumulates \(1/d\) into its
nearest node only. update_type=1 is the gather form. That the scatter has the
lowest rms is consistent with the variance argument — a node aggregates every
particle that is nearest to it, typically many more than nnn.
So the lever for material-fraction accuracy is more samples per node, not
better polynomial reproduction. The swarm story splits accordingly: the plain
SwarmVariable proxy takes order=1 because it carries exact real values and
only bias matters; IndexSwarmVariable keeps inverse distance because it
carries quantised values and variance dominates.
Consumers that depend on absolute boundedness, and are therefore deliberately
left on order=0:
The RBF rung of the point-location fallback ladder in
uw.function.evaluate—docs/developer/design/point-location-capability.mdstates its contract as “bounded, topology-free, honest”.MeshVariable.rbf_interpolatetherefore keepsorder=0as its default.IndexSwarmVariablelevel sets, which carry material fractions in \([0,1]\) and are divided through by their sum inconstitutive_models.py. That path hand-rolls its own inverse-distance weighting and is untouched.
Where each is used¶
Caller |
Default |
Why |
|---|---|---|
|
|
Accuracy; the proxy feeds integration, derivatives and the Lagrangian time derivative |
|
|
Documented bounded contract (see above) |
|
|
Exact checkpoint round-trip, no averaging wanted |
|
its own IDW |
Level-set fractions must stay in \([0,1]\) |
A rank holding fewer particles than the affine tail needs drops to order=0
automatically rather than failing the refresh.
Parallel behaviour¶
Proxy refresh is rank-local: the kd-tree indexes only this rank’s
particles and there is no halo exchange (SWARM-15, see
docs/developer/design/SWARM_MODERNIZATION_DESIGN_2026-07.md §4). A proxy node
near a partition seam therefore gathers from a one-sided neighbourhood.
Linear exactness improves this but does not fix it, and the size of what is left has now been measured — SWARM-15’s migration plan asks for exactly this as its first step.
Max relative proxy error against the analytic field, same mesh and same
particles at each np (2D, cellSize 1/24, fill_param 4):
field |
scheme |
np=1 |
np=2 |
np=4 |
|---|---|---|---|---|
linear |
|
2.0e-3 |
2.4e-3 |
4.5e-3 |
linear |
|
5.1e-16 |
5.1e-16 |
5.5e-16 |
curved |
|
9.5e-3 |
1.1e-2 |
1.7e-2 |
curved |
|
1.5e-4 |
1.9e-4 |
2.3e-4 |
Two things to read off. A linear field is exactly np-independent under
order=1 — round-off at every np, so the seam contributes nothing (pinned by
tests/parallel/test_0776_linear_rbf_proxy_parallel.py). For a field with
curvature the np-dependence persists in both schemes, roughly doubling from
np=1 to np=4 — but the absolute error at np=4 is 73x smaller under order=1
(2.3e-4 against 1.7e-2).
So order=1 reduces the seam’s magnitude by about two orders of magnitude
without removing the np-dependence. The halo exchange in SWARM-15 is still the
real fix.
Note
Node counts grow slightly with np (728 / 758 / 790 here) because shared partition-boundary nodes are counted on more than one rank, so the np>1 maxima are taken over marginally more node instances. The effect is small next to the trends above but it is not zero.