Integral Operators
jno.numpy supports mesh-based numerical integration of symbolic expressions over boundaries and volumes. Integration is the companion to differentiation: where .d(x) computes derivatives pointwise, .integrate() collapses a field to a scalar by summing over the mesh.
Core API
Method syntax
# Volume integral ∫_Ω expr dV
vol = expr.integrate()
# Boundary integral ∫_∂Ω expr ds
bnd = expr_on_boundary.integrate()
Function syntax (alias)
integrate() returns an Integral placeholder — a scalar node in the expression graph that reduces to a Python float when evaluated.
Region Auto-Detection
The integration region (boundary vs volume) is determined automatically from the Variable tags inside the expression. You do not pass any region argument.
dom = jno.domain(constructor=jno.domain.rect(mesh_size=0.05))
x, y, t = dom.variable("interior") # volume region
x_b, y_b, t_b = dom.variable("boundary") # boundary region
x_w, y_w, t_w = dom.variable("wall") # any named boundary group
u.integrate() # interior tag → volume weights
u_b.integrate() # boundary tag → arc-length / surface weights
Integration Weights
| Region | Weight per node | Source |
|---|---|---|
| 1-D volume | ½ × adjacent segment lengths | mesh_connectivity["nodal_volumes"] |
| 2-D volume | ⅓ × incident triangle areas | mesh_connectivity["nodal_volumes"] |
| 3-D volume | ¼ × incident tetrahedron volumes | mesh_connectivity["nodal_volumes"] |
| 2-D boundary | ½ × adjacent boundary edge lengths | mesh_connectivity["nodal_ds"] |
| 3-D boundary | ⅓ × incident boundary face areas | mesh_connectivity["nodal_ds"] |
Weights are precomputed once at domain creation and cached; they are not recomputed inside the training loop.
Volume Integrals
dom = jno.domain(constructor=jno.domain.rect(mesh_size=0.05))
x, y, _ = dom.variable("interior")
u = net(jno.np.concat([x, y], axis=-1))
# ∫_Ω u dA
vol_mean = u.integrate()
Use as a loss term to enforce a prescribed volume average:
target_mean = 2.0 / 3.0
integral_loss = (u.integrate() - target_mean).square()
crux = jno.core([pde_loss, integral_loss])
Use as a tracker (logged, not differentiated):
from jno.numpy import tracker
vol_mean = tracker(u.integrate(), interval=500)
crux = jno.core([pde_loss, vol_mean])
Boundary Integrals
dom = jno.domain(constructor=jno.domain.rect(mesh_size=0.05))
x_b, y_b, _ = dom.variable("boundary")
u_b = net(jno.np.concat([x_b, y_b], axis=-1))
# ∫_∂Ω u ds — perimeter-weighted integral
boundary_mean = u_b.integrate()
Flux Integrals
For flux integrals of the form \(\int_{\partial\Omega} \mathbf{F} \cdot \mathbf{n} \, ds\), request boundary normals and compute the dot product explicitly before calling .integrate():
x_b, y_b, _, nx, ny = dom.variable("boundary", normals=True)
u_b = net(jno.np.concat([x_b, y_b], axis=-1))
# Outward flux ∫_∂Ω ∂u/∂n ds
flux = (u_b.d(x_b) * nx + u_b.d(y_b) * ny).integrate()
# Divergence theorem check: for F = (x, y), flux = 2·area
div_thm = (x_b * nx + y_b * ny).integrate() # ≈ 2.0 on unit square
jNO does not compute \(\mathbf{F} \cdot \mathbf{n}\) automatically — you specify the integrand explicitly, giving full control over what is integrated.
Named Boundary Groups
For domains with named boundary regions, request variables from each group separately:
dom = jno.domain(constructor=jno.domain.polygon(...))
x_left, y_left, _, nx_l, ny_l = dom.variable("left", normals=True)
x_right, y_right, _, nx_r, ny_r = dom.variable("right", normals=True)
flux_left = (u(x_left, y_left) * nx_l).integrate()
flux_right = (u(x_right, y_right) * nx_r).integrate()
JIT Compatibility
Integral nodes are fully compatible with jax.jit. Integration weights are precomputed at domain setup and embedded as JAX constants during the first trace. Gradients flow through the integral, making it suitable as a differentiable loss term:
Common Patterns
Boundary flux conservation
x_b, y_b, _, nx, ny = dom.variable("boundary", normals=True)
u_b = net(jno.np.concat([x_b, y_b], axis=-1))
# Enforce zero net outward flux
net_flux = (u_b.d(x_b) * nx + u_b.d(y_b) * ny).integrate()
losses = [pde.mse, net_flux.square()]