Lattice normalization calibration
A correction to the SPH kernel's own normalization — unrelated to
gradient renormalization beyond both being, in a loose sense,
"normalizations" around the kernel. OperationProperties carries
calibrateNormalization: bool = False and n_h: Optional[float] = None.
Why: the quadrature offset
The SPH density
is a midpoint quadrature of sampled on the particle lattice. The kernel normalizes its integral, not its lattice sum — so a defect-free cubic lattice of spacing carrying the nominal mass does not read ; it reads , with
The from the mass and the from the kernel cancel exactly, which is why depends on the ratio alone and never on the resolution: the offset does not vanish as , only as . It is a resolution-independent systematic error, separate from the O(h²) quadrature error.
Enabling calibrateNormalization scales every kernel value and
derivative (all ops, all schemes) by , so the ideal lattice
reads exactly and every derivative is corrected by the same
factor (everything is linear in ). See the
Kernel library for where the scaling is applied and what it
is not applied to.
The math, three ways — util/latticeDensity.py
latticeDensity(kernel, n_h, dim, method='shells') returns (cached
on (kernel, n_h, dim, method)); latticeDensityFactor(...) returns
. Three evaluation methods:
shells (default — exact, dependency-free).
is an integer, so the -fold integer loop collapses to a single sum
over shells weighted by the multiplicity :
In 2D the multiplicity is closed form — Jacobi's two-square theorem,
(jacobiR2, asserted equal to the direct shellCounts reference).
Exact for any positive real , at the cost of kernel
evaluations instead of .
fourier (exact identity — the one that explains the behaviour).
Poisson summation turns the real-space lattice sum into a
reciprocal-space one: the offset is the kernel's own Fourier
transform sampled on the reciprocal lattice,
with the -dimensional radial transform of the normalized kernel (). This is pure aliasing, and it makes two things immediate:
- decays at the rate of the kernel's spectral tail;
- for a positive-definite kernel every term is positive (Bochner), so strictly.
closed (no summation at all — Wendland family only). Only the
odd powers of 's Taylor expansion at produce an algebraic tail
in , and each shell sum is an
Epstein zeta constant, so
with , (Dirichlet
beta), and for the Wendland kernels
(WENDLAND_TAIL_COEFFICIENTS). The residual is the oscillatory part
of , one half-power below the term kept; measured over
, the closed form recovers 94.6–99.8 % of the
offset in 2D and 72.8–99.5 % in 3D (1D much worse — Wendland2 1D
has a single odd Taylor coefficient and sits at a flat 66.7 %). Use
shells when you want the number and closed when you want the
scaling law.
latticeDensityClosed raises KeyError for the B-splines rather
than returning a number dominated by the oscillatory knot term it
omits: a spline's interior knots generate an oscillatory term that
decays slower than the algebraic tail, so a spline's offset changes
sign with (CubicSpline 2D reads 1.00344 at but 0.99996
at ) and has no smooth closed form. Use shells there.
Wendland: no root in
latticeDensityIsStrictlyAbove1(kernel) — the Wendland2/4/6 functions
are positive definite by construction, so everywhere
and a perfect lattice reads over at any support radius:
no solves . A Newton/bisection search on cannot
converge — the mass (or an explicit kernel correction like this one)
is the only lever. The B-splines are not positive definite in
2D/3D, crosses 1, and roots in do exist.
How it is wired
- Opt-in, loud failure.
OperationPropertiesis a frozen dataclass whose__post_init__raisescalibrateNormalization=True requires a positive n_h— the earliest point the invalid combination can be caught, covering every backend at once. Deliberately loud: silently treating a missing as "off" makes "calibration requested, never computed" indistinguishable from "calibration off". - Resolution.
extractStateInfo(inautograd/arg_extract.py) computeslatticeDensityFactor(kernel, n_h, dim)(anlru_cachehit after the first call — negligible against everything else that function does per call) intonormalizationCoefficient, and raises if the factor is not finite and : anything failing there is a bad(kernel, n_h, dim)combination that would otherwise reach a kernel as a NaN. The flag and the coefficient are carried into the WarpkernelStatestruct; the boolean flag guards against Warp's zero-initialization of the scalar (a bare0.0coefficient would be indistinguishable from "off" without it). - Application point. Every public kernel function
(
sphKernel,sphKernelGradient, …) multiplies by the factor at its boundary — see the Kernel library. It is not applied to the packing propertiessphKernelScale/sphKernel_xi/sphKernelN_H(those are properties of the kernel, not evaluations), and the CRK density diagnostic deliberately does not carry the flag (applying on top would double-correct). - Scope. is the nominal support-to-spacing ratio the case was configured with, not a measured one, and the correction is meaningful only for a uniform-resolution lattice — with adaptive support is per particle and a single scalar cannot represent it.
The design document is LATTICE_DENSITY_PLAN.md in the sibling
warpSPH repo; the CLI over this module is
scripts/lattice_density_offset.py (there), and the consumer that
matters is cases/weaklyCompressible.calibrateRestDensityMasses
(warpSPH), which needs to separate the ideal-lattice quadrature
offset from the sampler's block-fit error — two independent problems
that both show up as "the initial state does not read ".
Historical calibration reports remain in docs/regression/. New raw
measurements use the external development archive.
Tests
- The lattice math itself (
util/latticeDensity.py) is exercised in the warpSPH repo, not by this test suite.
See also
Renormalization · Kernel library — where the scaling is applied · Autodiff machinery — where it's resolved