Operators¶
This page provides an overview of the spatial operators available to
equation closures. Each registered domain carries an operator bank,
reached inside a closure as q.ops.
The operator bank¶
Operators are diff-soup's language for expressing local differential operators, the terms through which the spatial nodes of a field become coupled. Each operator carries the sparsity pattern of that coupling, which the lowering composes into the Jacobian's sparse structure.
An operator's contract:
- It takes a field's discrete values and returns the discretized term, shape-compatible with its input. On a grid the return is the term's pointwise value, one per cell or node. On a mesh it is the term's weak form, the term integrated against each finite-element basis function.
- It is pure and safe under
jitand automatic differentiation. - It is defined per domain: each registered grid or mesh carries its own bank bound to that geometry, and the discretization decides how each operator is realized.
Operators on grids¶
A grid carries its discretization from construction: method="fvm"
realizes each operator as a finite-volume flux divergence over the
cells, and method="fdm" as a finite-difference stencil at the
nodes, with the same call routing to the grid's realization. We
generally recommend FVM: for conservation laws it is the more
natural discretization.
The diffusion and transport terms, on cartesian grids in one, two, and three dimensions and on the curvilinear one-dimensional grids with their metric terms included:
laplacian(u): \(\nabla^2 u\).div_flux(D, u): \(\nabla \cdot (D \nabla u)\). A tuple(Dx, Dy, Dz)gives each axis its own diffusivity, a diagonal dispersion tensor, each entry a scalar or one value per cell.divergence(v): \(\nabla \cdot v\) for a vector of per-node values.advect_upwind(u, velocity): the first-order upwind \(\mathbf{v} \cdot \nabla u\), with the sign of the velocity choosing the upwind side (a backward difference for \(v \ge 0\), forward for \(v < 0\)). The velocity is a constant, or a tuple with one entry per axis, each a constant or one value per cell. The return is the operator itself, so a transport equation writes the minus sign:-q.ops.advect_upwind(q.u, v).div_advect(u, velocity, scheme="tvd"): the conservative form \(\nabla \cdot (\mathbf{v} u)\), with the same velocity forms. Each interior face carries the face velocity times a face value taken from the side the flow comes from, chosen byscheme(advection schemes). A boundary face carries only outflow, so an inlet is fed byspec.bc.inflow.curl(u): \(\nabla \times u\) of a vector field with one component per axis, in two and three dimensions. In two the curl is the scalar \(\partial u_y/\partial x - \partial u_x/\partial y\), in three the curl vector, pointwise at the cells.
On one-dimensional grids:
gradient(u): \(\nabla u\).face_flux(u, D): the diffusive flux \(-D\,\partial u/\partial x\) at the cell faces, one value per face. These are the raw fluxes thatdiv_fluxdifferences into the per-cell conservation term. A prescribed end flux is a boundary condition, declared withspec.bc.flux.
On any grid:
mass(u): applies the mass matrix, the identity on a grid and the consistent-mass integral on a mesh. It is primarily a helper for custom residuals that apply the mass weighting explicitly.lumped_mass(): the integration weights, the cell volumes on a grid.jnp.sum(q.ops.lumped_mass() * f)integrates a field over the domain, the typical use in an integral observable.
On a grid, operators compose with the pointwise interpretation in both directions: a linear operator applies to a nonlinear pointwise expression, and a nonlinearity applies to an operator's result:
q.ops.laplacian(q.u ** 2) # operator of a pointwise nonlinearity
jnp.tanh(q.ops.gradient(q.u)) # pointwise nonlinearity of an operator
Advection schemes¶
div_advect takes four face values. The cell Péclet number
\(u h / D\) compares transport with dispersion across one cell.
scheme |
Face value | Order | Use it for |
|---|---|---|---|
"tvd" (default) |
the upstream value plus a limited share of the change to the downstream one | 2 where smooth, 1 at extrema | any cell Péclet number: it creates no new extrema |
"weno3" |
the WENO blend of two two-cell reconstructions | 3 where smooth | smooth fields on coarse grids, at a costlier Jacobian |
"central" |
the mean of \(v u\) in the two cells | 2 | cell Péclet numbers below 2 everywhere; above 2 it oscillates |
"upwind" |
the upstream value | 1 | the most robust form; it adds a numerical dispersion \(u h / 2\) |
"tvd" is a MUSCL scheme with the flux limiter limiter="van_leer"
(the default), "koren" or "minmod" (van Leer, 1974, 1979; Koren,
1993). The van Leer limiter is smooth in the ratio of successive
differences. Koren's and minmod switch between linear pieces, which
slows Newton's method: on a 64 by 16 by 16 packed bed, Koren's took
twice the steps of van Leer's and five times the factorizations. "weno3" is the third-order WENO reconstruction of Jiang and
Shu (1996), with the smoothness guard \(10^{-10}\) where they used
\(10^{-6}\), so the weights also see jumps in small fields.
Both are upwind-biased by the sign of the face velocity and read two
cells upstream. A face next to a boundary cell lacks the second cell,
so both drop to the upstream value there, which is first order.
The wider stencil reaches the Jacobian: the build widens the sparse
pattern on its own, and a family member kernel
declares stencil_radius=2, which the build checks. On a 64 by 16 by 16 grid one sparse
factorization costs about four times central's on a CPU and a fifth
more on a GPU.
A primer on FEM and the weak form¶
Meshes are discretized with the finite element method (FEM). FEM represents a field through its nodal values,
where the basis functions \(N_j\) interpolate between the nodes, each nonzero only on the elements touching its node.
Consider a generic conservation equation in strong form, with advection at a velocity \(v\), a diffusive flux \(F\), and a volumetric reaction \(r(u)\):
with a surface reaction \(s(u)\) setting the total flux through the boundary:
The strong form holds pointwise. The weak form instead requires the equation to hold in an inner product with every test function: the equation is multiplied by a test function and integrated over the domain. The Galerkin choice takes the test functions to be the basis functions themselves, giving one equation per \(N_i\),
then integrate the divergence term by parts:
Integration by parts naturally exposes the flux boundary condition.
Substituting \(u = \sum_j u_j N_j\) gives the final semi-discrete system, one equation per basis function:
The integrals on the right are evaluated by quadrature, numerical integration at sampling points inside each element. The mesh operators below assemble exactly these integrals.
FEM nodal values and the weak form¶
A finite-element field can be represented by its nodal values
\(u_j\), the unknowns in the state vector, and by its quadrature
samples, its values at the quadrature points. In diff-soup, we
default to representing every field in its quadrature-native
representation (the quad option of
spec.field) in order to be friendly
to nonlinear compositions. To work with nodal values, read
q.<name>.nodal. Operators accept either form and consume the nodal
values upon lowering. With this approach, equations can be authored
in strong form and lowered to the weak form at build time.
For weak forms the built-in operators do not cover, three helpers assemble the primer's integrals directly:
q.grad(field): the gradient at the quadrature points.q.flux(expr): assembles the flux integral \(\int \text{expr} \cdot \nabla N_i\).q.source(expr): assembles the volumetric integral \(\int \text{expr}\, N_i\).
Operators on meshes¶
Mesh operators return the weak form's integrated terms where grid operators return pointwise values.
Core differential operators¶
The differential operators cover scalar, vector, and tensor fields:
laplacian(u): the weak form of \(\nabla^2 u\), integration by parts included.div_flux(D, u): the weak form of \(\nabla \cdot (D \nabla u)\).grad(u): the weak form of \(\nabla u\), for scalar and vector fields.div(T): the weak form of \(\nabla \cdot T\), for vector and tensor fields.curl(u): the weak form of \(\nabla \times u\), the scalar curl in two dimensions and the curl vector in three.
Pointwise helpers¶
Pointwise tensor algebra works at the quadrature samples, on every discretization:
sym(T)andskew(T): the symmetric and antisymmetric parts.double_dot(A, B): the double contraction \(A : B\), including the rank-four stiffness case \(C : \varepsilon\).trace(T),det(T),inv(T),transpose(T).outer(u, v)andtensor_contract(A, B, axes=1).scale(scalar_field, tensor): a per-point scalar times a constant tensor.
The differential operators and pointwise helpers together compose a mechanics residual:
eps = q.ops.sym(q.grad(q.u)) # strain
sigma = q.ops.double_dot(C4, eps) # stress
return q.ops.div(sigma) # momentum residual
Application-specific operators¶
- Solids:
strain(u), the small strain \(\operatorname{sym}(\nabla u)\), andlinear_elastic_stress(u, E=..., nu=...), its linear-elastic stress, plusdeformation_gradient(gradu)andcauchy_stress_from_S_F(S, F)for finite-strain terms. - Fluids:
strain_rate(u)andviscous_stress_newtonian(u, mu=...)for a Newtonian fluid of viscositymu. For rotation,curlis the one operator. On a mesh its pointwise reading is the weak curl divided by the lumped mass. - Porous media:
tortuosity_corrected(D, eps, beta=1.5), the Bruggeman-corrected diffusivity \(D\,\varepsilon^{\beta}\). - Quadrature machinery:
interp_to_quad(u)samples a nodal field at the quadrature points, andintegrate(expr)is the weak integral \(\int \text{expr} \, N_i\) of a nodal or quadrature expression.
Boundary operators¶
On tagged triangle and tetrahedral meshes,
boundary_integral(field, tag=...) integrates a field over a tagged
boundary, boundary_trace(field, tag=...) restricts it to one, and
surface_flux(field, tag=..., kinetics=...) assembles a
kinetics-driven surface flux. Boundary conditions themselves are
declared with spec.bc, covered in
boundary conditions.
Custom operators¶
Operators are methods of the domain's bank, the object a closure
reaches as q.ops. A bank holds its bound domain, reports a
discretization_type tag, and offers exactly the operators it
implements. provides reports that set, and calling an operator
outside it raises an error naming the operator and what requires it.
Every method is pure and safe under jit and automatic
differentiation, and returns per the bank's convention, pointwise
values on a grid and weak forms on a mesh.
If a required operator is not currently defined in diff-soup, a custom operator can be defined using two methods.
Extending the built-in bank¶
Subclass the concrete Operators, which keeps every built-in method,
add the new operator, and register the class when the domain is
declared with spec.domain(name, domain, operators=...).
For example, to implement the curl: for a two-component field \(u = (u_x, u_y)\) in two dimensions the curl is the scalar
On a grid the bank's convention is the pointwise value, differenced over the grid spacings:
class CurlOnGrid(Operators):
def scalar_curl(self, u):
nx, ny = self.domain.shape
dx = self.domain.axes[0].node_distances[0].astype(u.dtype)
dy = self.domain.axes[1].node_distances[0].astype(u.dtype)
ux = u[:, 0].reshape(nx, ny)
uy = u[:, 1].reshape(nx, ny)
duy_dx = jnp.gradient(uy, dx, axis=0)
dux_dy = jnp.gradient(ux, dy, axis=1)
return (duy_dx - dux_dy).reshape(-1)
An operator body is traced, so geometry such as node_distances arrives
as a traced float64 array. Compute on it with jnp arithmetic, never
with float(), int(), .item() or a Python if.
x.astype(u.dtype) keeps the arithmetic in float32 under
Precision.fp32().
On a mesh the convention is the integrated weak term
\(\int (\nabla \times u)\, N_i \, d\Omega\), assembled from the
quadrature-point gradient. List the operator in weak_form because
it returns an already-integrated term. A subclass adds only its own
names, and the sets union across the class hierarchy:
class CurlOnMesh(Operators):
weak_form = frozenset({"scalar_curl"})
def scalar_curl(self, u):
H = self.quad_gradient(u) # H[..., i, j] = du_i/dx_j
return self.integrate_shape(H[..., 1, 0] - H[..., 0, 1])
Registered on the domain, either bank's operator is called in a
closure as q.ops.scalar_curl(q.u).
A bank for a novel discretization¶
To extend the domain and discretization types, AbstractOperator
can be subclassed to build a custom domain and operator bank.
Implement the operators the bank offers, hold the bound domain,
and report a discretization_type tag.
For example, a Fourier spectral bank on a uniform periodic one-dimensional grid. The Laplacian is diagonal in Fourier space, a multiplication by \(-k^2\):
import jax.numpy as jnp
from diff_soup.domains import AbstractOperator
class FourierSpectral(AbstractOperator):
@property
def discretization_type(self):
return "fourier"
def laplacian(self, u, *, bcs=None, t=None):
dx = self.domain.node_distances[0]
k = 2 * jnp.pi * jnp.fft.fftfreq(u.shape[0], d=dx)
return jnp.fft.ifft(-(k**2) * jnp.fft.fft(u)).real
Sparsity needs no declaration when extending the built-in bank. On grids the lowering probes the assembled residual at build time and widens the stencil pattern to the coupling the operators exercise. On meshes the pattern is the element connectivity.
References¶
- G.-S. Jiang and C.-W. Shu. Efficient implementation of weighted ENO schemes. Journal of Computational Physics, 126(1):202–228, 1996. doi:10.1006/jcph.1996.0130.
- B. Koren. A robust upwind discretization method for advection, diffusion and source terms. In C. B. Vreugdenhil and B. Koren, editors, Numerical Methods for Advection-Diffusion Problems, pages 117–138. Vieweg, 1993.
- B. van Leer. Towards the ultimate conservative difference scheme. II. Monotonicity and conservation combined in a second-order scheme. Journal of Computational Physics, 14(4):361–370, 1974. doi:10.1016/0021-9991(74)90019-9.
- B. van Leer. Towards the ultimate conservative difference scheme. V. A second-order sequel to Godunov's method. Journal of Computational Physics, 32(1):101–136, 1979. doi:10.1016/0021-9991(79)90145-1.