Geometry, regions, and moving meshes
Mesh sizing — one number, or a function of position
size= takes a float for a uniform mesh, or a callable for a graded one. A callable becomes a
gmsh mesh-size callback and composes with every other size control by min, so it is the general
"denser here" knob:
# THREE arguments, in 2-D as well as 3-D: gmsh calls a size function as f(x, y, z).
h_of = lambda x, y, z: H_FINE + (H_COARSE - H_FINE) * min(1.0, max(0.0, (LY - y) / BAND))
d = jno.shape.rect(0.0, 0.0, LX, LY, size=h_of).domain()
This is what makes a thin feature affordable. Measured on a 1.2 × 0.4 mm rectangle graded from 4 µm at the top surface to 30 µm below — the melt pool of the laser melt-pool tutorial, ~50 µm deep in a 400 µm domain:
| mesh | nodes | through the 50 µm feature |
|---|---|---|
| uniform 16 µm | 2,296 | 3 cells |
| uniform 4 µm | 35,226 | 12 cells |
| graded 4 → 30 µm | 2,035 | 12 cells |
Same near-surface resolution as the uniform 4 µm mesh for 17× fewer nodes — and fewer nodes than the 16 µm mesh that resolved nothing.
f(x, y) is the natural thing to write in 2-D, and it is wrong
The callback passes three coordinates whatever the dimension. A two-argument function raises
inside gmsh's C callback, where it surfaced as Wrong mesh element size lc = 0 (lcmin = 0,
lcmax = 1e+22) — naming neither the callable, nor its signature, nor the shape it came from. And
whether it surfaced at all depended on gmsh's global state: the same wrong function raised in a
fresh process and meshed silently in one that had already built other meshes. jNO now probes
the callable once before registering it and refuses by name.
Meshing is lazy
.domain() does not build a mesh; the first thing that asks for one does. So a size callable is
not called — and a wrong one not diagnosed — until then.
Curved (isoparametric) geometry — shape.curved()
By default jNO meshes straight-sided and synthesises higher-order nodes at the straight-edge
midpoints, so the domain stays a polygon however high the element order goes. That approximation
carries an O(h²) domain error at every basis order — it is what caps P2/P3 at second order on a
round boundary, no matter how good the basis is. curved() asks the CAD kernel to place those nodes on
the true surface instead:
d = jno.shape.disk(0, 0, 1, size=0.1).curved().domain()
u, v = d.fem_symbols(order=2) # the basis order must MATCH the geometry order
Unit disk, -Δu = 1, exact u = (1−r²)/4 — RMS nodal error
Measured on -Δu = 1 on the unit disk (exact u = (1−r²)/4), RMS nodal error:
| mesh size | straight-sided | curved |
|---|---|---|
| 0.4 | 6.73e-03 | 7.63e-05 |
| 0.2 | 1.66e-03 | 7.67e-06 |
| 0.1 | 4.23e-04 | 7.36e-07 |
| rate per halving | ≈4× (O(h²)) | ≈10× (O(h³)) |
Straight-sided is capped at second order by the geometry; curved recovers P2's own third order, and is 570× more accurate at the finest resolution.
Scope — what this does not cover. Order 2 and simplices only. An order mismatch is refused: isoparametric means geometry order == basis order, and a curved mesh under a P1 basis puts the midside DOF coordinates (on the arc) and the geometric map (from the chord) in disagreement. Non-nodal families keep affine geometry — Nédélec, RT, Argyris and Morley need Piola/curvature push-forwards that are a separate change, so curved EM does not benefit yet — and a 4th-order form is refused on a curved cell, because the physical-Hessian transform is derived for an affine map and would gain a curvature term it does not carry. Facet normals are still straight-facet, so the O(h) normal error that affects radiation view factors, flux BCs and RCWA's field decomposition is unchanged by this.
Note also that a curved map makes the integrand rational, so no quadrature rule is exact any more.
The default degree is raised by 2 on curved cells and jno.fem(quad_degree=...) still overrides;
measured on the study above, refining the rule moves the answer by less than 0.01 %.
Differentiable mesh geometry — trainable coordinates (.trainable())
Any placeholder promotes to a jno.np.parameter seeded at its current values with .trainable() —
an existing coefficient / data tag becomes an inverse unknown in one call:
Called on a spatial coordinate (domain.variable(region)), .trainable() makes that region's mesh
vertices a design variable — the map from node positions to the solution is differentiable, so a solve
can be optimized with respect to the mesh itself (mesh relocation / r-adaptivity / shape optimization),
all in one JAX graph:
xi, yi, _ = domain.variable("core", split=True) # a where= / predicate sub-region
Xx = xi.trainable() # ONLY the x-positions of the core vertices move
# differentiating a solve now yields the shape derivative ∂(solve)/∂X
.trainable() is per component — call it on every axis you want to move
The spelling is literal, per component (x.trainable() moves only x; call it per axis for full
motion — which also gives constrained relocation for free, e.g. promote only the tangential component on a
slip plane). Under the hood the coordinate parameter scatters into the assembly's P1 geometry before the
element Jacobian is formed, so J, JxW, the physical gradients, the quadrature-point coordinates and
the boundary-facet normals all become differentiable in the node positions. Scope: nodal-Lagrange volume
+ Neumann/Robin terms, 2D triangle / 3D tet, steady. The mesh connectivity is fixed — this is
relocation, not remeshing (h-remeshing stays the non-differentiable outer AFEM loop); it is differentiable
on valid meshes, with element inversion (tangling) the boundary of that regime.
Moving a slip surface (n·u = 0) with runtime coordinates
The exact slip elimination carries u = P ũ with P built from the surface's per-node normals
N_i = ∫ φ_i n ds. When the slip surface's own vertices are trainable, those normals move, so P is
rebuilt for each solve from the coordinates it is given: same sparsity (the build-time pivots and
facets), new values, pure JAX, differentiable in the coordinates. Checked against a fresh build of the
moved mesh (1e-16 on a tilted 2-D surface, 2e-11 m/s on a 3-D rolling model) and against the flux
Σ u_i·N_i through the moved surface, which stays at round-off. Scope: the steady nonlinear solve
(fem.solve(param=...), continuation=); a linear or transient form with a moving slip surface raises
at build. Before this, the build-time normals were used silently (3e-4 relative error on the rolling
model) — the reason one build could not serve a sequence of remeshed geometries.
Entries of P that the allowed motion cannot make nonzero (the z-part of a normal on a surface
y = f(x) when only z is trainable) are left out of its pattern — detected at build by evaluating P at
random perturbations of the trainable axes, and re-checked after every eager solve, which raises if one
is nonzero after all. Keeping them quadrupled the reduced tangent on the rolling model (the sparse
reduction expands each triplet over the square of P's entries per row) and made each factorization
5.5x slower; pruned, a moved-mesh solve costs what a fresh build's does (23.6 s vs 22.5 s for its
137-step continuation) and a re-solve after the mesh moves takes 1.1 s instead of a 7.6 s rebuild.
r-adaptivity in one call. Tagging coordinates .trainable() and driving the relocation yourself is the
low-level path; the packaged form reuses the same adapt= slot as h-refinement:
xm, ym, _ = domain.variable("core", where=interior, split=True)
xm.trainable(); ym.trainable() # BEFORE jno.fem(...)
u = fem.solve(adapt=jno.solve.relocate(max_iters=60))
How the descent works
jno.solve.relocate() descends the equidistribution defect of an arclength monitor through the
differentiable solve, with a backtracking det J line search — so the fixed node set concentrates at
solution features and the mesh never tangles (the validity constraint lives in the step control; a stock
optimiser or an energy barrier alone cannot guarantee it on a stiff problem — see run_adaptive_relocate).
It mutates the domain to the relocated mesh, returns the solution there, and raises if no coordinate was
tagged. Works across linear, nonlinear (Newton), transient (relocates for the whole trajectory via a
time-averaged objective), periodic, and complex problems, scalar or vector — the objective sums over every
solution block, so a complex field's real and imaginary parts both contribute. Only complex-transient is
not wired yet.
The nonlinear=/linear=/precond= slots configure the solve on the final mesh, the one returned
(linear=jno.solve.lu() on a saddle problem, say); the descent itself keeps its own differentiable
solves. x0= and time= are refused: a warm start and a time scheme do not survive the change of mesh.
A mesh objective that names the physics — objective=<expression>
The three built-in objectives ("equidistribution", "energy", "huang") are mesh-quality measures:
they read the solution only through a monitor, so they can ask for resolution but cannot state a goal
about the physics. objective= also accepts a weak-form expression, assembled exactly as
criterion= is and summed to a scalar, over a volume or a boundary region:
xs, ys, nx, ny = domain.variable("side", normals=True, split=True)
ys.trainable() # the wall may move along y only
us, vs = u.bind(x=xs, y=ys), v.bind(x=xs, y=ys)
fem.solve(adapt=jno.solve.relocate(objective=(us[0]*nx + us[1]*ny)**2 * vs[0]))
That is a free surface: the wall moves until the flow through it vanishes. The facet normals are
rebuilt from the moving vertices, so n is the current mesh's normal. The gradient runs through the
solve — matched to central differences at 7.5e-09 — and the through-flow falls 11.4x over 60 rounds
(12.5x at 120, so this is a descent, not a root-find).
Why the obvious benchmark measures nothing
The benchmark deserves a word, because the obvious version of it measures nothing. In a channel with a
symmetry bottom, uniform flow u = (1,0), p = 0 satisfies every equation and boundary condition
for any shape of the traction-free top: measured, the solution stayed uniform to 2.3e-14 and moved
by 9.8e-14 when the wall was displaced by 0.1. The objective is then purely geometric — it exercises
the normals and the facet measure but never the solve. A no-slip bottom couples them (max|du| =
7.2e-02 for the same displacement), and only then is d(objective)/d(vertex) a statement about the
physics rather than about the mesh.
Three things to know:
- The objective is a scalar, so it needs a scalar test function; on a velocity/pressure saddle the pressure test is chosen automatically.
- When the expression reaches its region only through a bound view —
u.bind(x=xs, y=ys)absorbs its coordinates — the test function cannot be auto-bound. Carry it yourself, as above (* vs[0]). That case raises with this instruction rather than a trace-level binding error. - A surface objective needs the form to carry a surface term, because the facet quadrature tables
are tabulated at build time only then. A traction-free wall (
0.0 * vs[0]) in the term list is enough.
A mesh condition, as an inequality. criterion= also takes a jno.le / jno.ge constraint, and
then it is its own trigger — there is no cadence or threshold argument, because the condition already
says both where and whether:
Every cell whose margin is positive is marked — all of them, not a Dörfler fraction — and the march
stops when none is. Measured on a deliberately stretched mesh: worst aspect 2.87 → 1.57 in one
round, 0 marked on the next. theta is refused with a constraint (there is no bulk fraction to
choose), and a bare comparison (q > 2.0) is refused too: it records which cells are bad but not by
how much, so marking would take a fraction of them and quietly leave the rest.
On a march the same condition is a trigger. With remesh(criterion=jno.le(...), every=k) on a
transient problem it is checked on the current mesh every k steps, and the mesh is rebuilt only when
some cell breaks it; fem.adapt_history records remeshed: False for the rounds it held, so a
condition that never breaks never remeshes. A ranking criterion (1 - phi**2, |grad u|) is evaluated
on the live state at each remesh -- and at that remesh's time, so it may read t (a moving source) -- and
the DOF budget (max_dofs, else the starting count) is held on both the isotropic and the anisotropic
path.
max_dofs counts DOFs — every unknown of the assembled system (fem.dofs), so a vector field counts
each component, a Taylor–Hood P2/P1 pair about nine per vertex in 2-D, and a complex field its real and
imaginary halves — not vertices. The mesher is steered by a vertex count and only approximates it, so
each remesh is counted before it is applied and redone with a corrected target if it misses: the DOF
count stays within 20 % of max_dofs, and a remesh that cannot be brought under 1.2 × max_dofs
(an hmax too small to coarsen that far, say) raises. On a steady loop the budget caps each round's
growth and stops the loop once reached. (It used to be handed to the mesher as a vertex count: a
Taylor–Hood march asked for max_dofs=4000 produced 23,793 DOFs.)
Two things to know. Set a threshold the mesher can actually reach — an unstructured 2-D mesh
bottoms out around 1.2–1.5, and a constraint below that never settles, so the march refines until
it runs out of rounds. And pass a callable for a geometry criterion: a geometry node captures the
cell table when it is constructed, so a single node keeps answering for the mesh it was born on and is
refused by name once the topology changes.
When moving nodes is not enough: relocate(...).remesh(...). Relocation moves a fixed node set,
so once the mesh has to stretch further than its elements allow it can do nothing — quality_floor is
a line search that rejects the step, and rejecting a step never adds a node. Chain an h-step onto it
and say what "too far" means:
fem.solve(adapt=jno.solve.relocate(objective=through, max_iters=200)
.remesh(criterion=lambda d: jno.le(d.cell_aspect(), 2.0), max_iters=4))
Where the quality floor is enforced
The condition is checked inside the relocation line search, on each candidate step, so an
inadmissible mesh is never accepted — the march honours the bound exactly rather than reporting a
breach after the fact. When no admissible step exists, relocation has run out of room and the cells
blocking it are refined; the movable vertices are then re-derived from the region each was tagged
on, since indices do not survive a remesh. Measured on a Poisson peak, bound 1.7: 44 → 80 vertices
over 3 remeshes, final worst aspect exactly 1.700, objective still falling 8.4e-02 → 2.0e-02.
The nested spec's max_iters caps how many remeshes the march may spend and max_dofs caps the size.
If the budget runs out while the mesh still breaks the condition, that is raised, not returned
quietly — refining does not repair every shape, and a bound below what the mesher can deliver would
otherwise refine without end (measured, before this: 44 → … → 15709 vertices and an out-of-memory
failure inside the solver).
Scope: the interleaved criterion must be a mesh-geometry condition (cell_aspect, cell_volume,
cell_angles), because it is evaluated on the moving vertices with no solve, and the bound must sit
directly on one node so it can be evaluated once per round rather than re-traced. A solution criterion
belongs on a standalone jno.solve.remesh(...).
Mesh quality as a term you can write. domain.cell_aspect() is the longest edge over the inradius,
scaled so a regular simplex reads exactly 1.0 and a stretched one reads more — per cell, 2-D and
3-D, and differentiable in the vertex positions (checked against central differences at 2.7e-10).
It is the companion to domain.cell_size, which is |det J|^(1/dim) — an isotropic size that cannot
see stretch at all, since a sliver and a regular element of the same area share it. domain.cell_angles()
also measures distortion but is 2-D only. Reference: Shewchuk, What Is a Good Linear Finite Element?
(2002), §2.
Tagging is literal and per-axis: xm.trainable() frees only the x column. On a boundary that is the
lever for sliding — free an edge's along-edge axis and its nodes redistribute within the wall, leave the
normal axis untagged and the domain shape is preserved exactly.
method="monge_ampere" — the alternative
method="monge_ampere" swaps the descent for a Monge–Ampère mesh solve, m·det(I + H(φ)) = θ with
x = ξ + ∇φ (McRae, Cotter & Budd, SIAM J. Sci. Comput. 40(2) 2018, arXiv:1612.08077 §3.1). The
displacement is a gradient, so the whole map cannot fold and no line search is needed, and it converges in
3–6 rounds against descent's 30. It is not the default, because on the Allen–Cahn front the suite
measures (h=0.06, eps=0.03, error on a common fine grid so the metric does not depend on where each mesh
puts its nodes) it loses on the answer:
| mesh | rel-L2 | vs uniform | min element quality |
|---|---|---|---|
| uniform | 1.096e-01 | 1.000 | 0.834 |
relocate() (descent) |
3.951e-02 | 0.361 | 0.503 |
relocate(method="monge_ampere") |
8.879e-02 | 0.811 | 0.160 |
The cause is structural rather than a tuning miss: with one global θ, concentrating elements at the front
forces the rest of the domain to stretch, which is why element quality collapses far from the feature. The
control is under-relaxation — relax_step=0.02 recovers quality to 0.318 and the ratio to 0.633.
A moving mesh is a term
A moving mesh is not a solve argument. Put coord.d(t) - velocity in the jno.fem([...]) list — a residual
like any other equation — and the mesh moves as it says:
jax.config.update("jax_enable_x64", True) # required; see the scope list below
xb, yb, tb = domain.variable("boundary", split=True)
fem = jno.fem([ui.t * vi + kappa * (ui.x * vi.x + ui.y * vi.y), # the physics
u(xb, yb) - 0.0, u(*ci) - 1.0,
yb.d(tb) - 0.5 * yb]) # dy/dt = y/2 — the mesh
traj = fem.solve() # one frame per moved mesh
How a moving-mesh term is recognised
It is recognised structurally, by containing d(spatial coordinate)/d(temporal variable), so there is no
new spelling: Variable.d and the term list already exist. Nothing about it is boundary-specific — an
interior region, a boundary and a where= predicate all resolve the same way — and tagging is per-axis,
so a term on yb alone moves the y column and holds x exactly. The velocity is ordinary traced math, so an
interface law may read the solved field (a Stefan front -(k/L)·∇T·n), the coordinates, the outward normals,
the time, or a jno.np.parameter that then becomes a design variable. The march is differentiable in all of
them, and in where the mesh started.
Each step: evaluate every geometry term's velocity, scatter it into the vertices and axes those terms name, extend harmonically over everything they do not, move, re-assemble on the moved vertices, and carry the state across.
The mesh velocity inside a weak form (ALE)
On a moving mesh a nodal value follows its vertex, so its rate is the ALE derivative
with \(w\) the mesh velocity. Transport written on the moving mesh therefore carries \(w\), and you write it as what it is — the rate of a coordinate:
w0, w1 = xi.d(ti), yi.d(ti) # the mesh velocity w
fem = jno.fem([ui.t * vi + ((c0 - w0) * ui.x + (c1 - w1) * ui.y) * vi # u_t + (c - w)·∇u
+ nu * (ui.x * vi.x + ui.y * vi.y),
xi.d(ti) - c0, yi.d(ti) - c1, # the mesh: it follows the flow
u(*ci) - u0])
When a weak form reads coord.d(t), the nodal values ride with their vertices and nothing is transferred
(transferring as well would count the mesh advection twice). Each step, w is the discrete motion of every
vertex, \((X^{n+1} - X^n)/\Delta t\), harmonically extended vertices included, in volume and surface terms
alike. This is the non-conservative ALE form, backward Euler, with mass and operator on the end-of-step
configuration. Measured (tests/test_fem_ale_mesh_velocity.py):
- a mesh translating rigidly with the material reproduces the fixed-mesh diffusion march to 1.1e-15;
- a surface term
(w·n) u vequals(c·n) u von that mesh to 3.6e-16; - a Gaussian advected at
cpast a mesh moving atc/2meets its closed form to 2.0 % (5.2 % on a fixed mesh with the samehanddt, since less relative motion means less numerical diffusion).
w lives on the mesh vertices and borrows a P1 Lagrange field's basis, so the problem needs one (a
Taylor–Hood pressure qualifies). Refused by name: coord.d(t) with no geometry term (it would be identically
zero), the rate of a normal or of cell_size, and the mesh acceleration xi.d(ti).d(ti).
The stabilised-flow tau works for a capillary drop as written
The SUPG/PSPG tau of the stabilised-flow tutorial needs no rescaling for a surface-tension driven
drop. Measured on an ellipse released from rest (tests/test_fem_drop_oscillation.py), the recipe as
written rings at ω within 0.4 % of Lamb's ω² = n(n²−1)σ/(ρR³), decays at 0.89× the viscous
rate 2n(n−1)ν/R², and leaves the n = 4 mode at its initial amplitude. Scaling tau by 1e-4
changes none of these.
Earlier versions of this page warned the opposite: that this tau over-damps the drop tenfold
(ω = 0.23) and feeds a spurious n = 4 mode (×9.7). That was an assembly defect, not physics. The
stabilisation products contain u_t inside the strong residual, and the transient assembler routed each
such product whole into the mass matrix. They are now split by temporal order. A spelling the assembler
cannot split, such as tau * inner(a, u.t + (u.grad)u), is refused with instructions to write the u.t
part as its own term.
Two further practicalities. Measure the shape as a Fourier mode of r(θ), not as a bounding-box aspect
ratio, and fit a damped sinusoid — extremum counting returned +7.4/s and −1.6/s for the same run over
different spans. And the capillary time step is tighter than √(ρh³/2πσ) suggests: at h = 0.03 that
estimate gives 8e-4 while 5e-4 already tangles, so shrink dt as you refine.
Bodies that merge — remesh(alpha=...)
Moving a mesh cannot change its topology: two droplets meshed as separate bodies in a void can approach
forever and stay two meshes. alpha= re-decides which nodes form elements, instead of meshing the geometry
afresh — a Delaunay triangulation of the nodes where the motion left them, keeping only the triangles whose
circumradius is below alpha × the local mean edge length (the alpha shape, the remeshing step of the
Particle Finite Element Method):
Every node stays put, so a P1 state carries across by identity — nothing is interpolated and nothing is lost. What changes is which nodes are neighbours, so a gap that closes gets bridged and the two bodies become one mesh.
Two disks (R = 0.5, h = 0.075, 357 nodes) driven together
| gap | 0.35 → 0.150 | 0.136 |
|---|---|---|
| bodies | 2 | 1 |
They merge at about 1.8 h, a little later than the flat-wall threshold 2·alpha·h = 0.18: two facing
arcs put fewer nodes near the closest point than two straight walls do. The node count is unchanged
(357), the cell count goes 630 → 638, and the field — two flat plateaus at ∓1 — starts to diffuse across
the new neck (minimum |u| there 0.479, against 0.972 when the bodies never merge). Only 2 of 12 steps
actually rebuilt anything: the reconnection is skipped when it reproduces the elements already there.
The march cost 5.0 s against 3.6 s without reconnection.
Merging is therefore a mesh-length contact model — bodies join when their gap is comparable to the
element size — and not film drainage. Scope: 2-D, P1 fields (a new edge bridging two bodies has its midpoint
in the void, where a P2 value cannot be read), a geometry term must be present (nothing else moves the
nodes), and alpha= does not combine with criterion= — they are different operations. A node left in no
triangle (a free particle) is refused rather than dropped, because dropping it would renumber the rest and
silently permute the state.
Scope — the rest raises rather than guessing:
- Operator-split ALE, explicit in the velocity, hence first order in the step — measured, against a
manufactured solution on a translating domain: observed rates 1.14 / 1.12 / 1.12 / 1.10 with the mesh
moving, 0.99 / 1.01 / 1.04 / 1.10 with it still. The motion multiplies the error constant by ~3× (that
is the state transfer) and leaves the order intact. Refining
hconverges too — 1.51 → 1.76 toward the expected 2 for P1 — and P2 is ~18× more accurate than P1 on the same moving mesh, so higher order still pays here.
If you repeat that measurement, compare against a fine-dt reference on the same mesh, not against
the exact solution: the temporal and spatial errors have opposite signs, so the direct comparison shows
rates of +1.4 then −0.4 as they cancel and separate again. That reads as a scheme that stops converging,
and is only a contaminated measurement.
??? note "Why the mesh motion is explicit, not an implicit coupled equation"
The term list reads like a coupled equation and this is not one: an implicit mesh would need the
coordinates as unknowns in the monolithic system and the ALE convective term.
* The state transfer is a conservative L2 projection onto the moved mesh, and it is still diffusive. On a
rigid translation carrying a marginally-resolved bump the peak falls ~9 % (the pointwise re-interpolation
this replaced fell ~33 %, and got worse as dt shrank). Conservation is algebraic — Σφ = 1 — so the
residual is quadrature error on an integrand with kinks: ~2e-4 relative against the pointwise route's
3e-3 to 9e-3. Removing the diffusion entirely means not transferring at all (Lagrangian DOFs plus an ALE
-w·∇u term), which is a different semidiscretisation — and what happens when a weak form reads the
mesh velocity (above).
* Requires jax_enable_x64. The transfer locates quadrature points in the previous mesh, and in float32
that carries ~4e-4 — enough for a mesh that never moves to drift 1.5e-3 over a march (2.6e-10 with x64).
* Backward Euler only: θ comes from the block, and time=jno.solve.theta(...) is a solver slot, which
a geometry term does not compose with.
* Connectivity-preserving, unless told to remesh: a move that would invert an element raises. With
fem.solve(adapt=jno.solve.remesh(criterion=lambda d: jno.le(d.cell_aspect(), 3.0), every=1)) the march
checks the condition on the moved mesh every every steps and, where it breaks, rebuilds the mesh
(mmg, at the starting DOF budget), re-assembles and carries the state across. Measured on a top edge
bulging as y' = 2y sin(πx): worst cell aspect 6.27 → 3.19 with one remesh, the surface where the plain
march puts it, a constant field exact to 4e-16. The laws may read only boundary and interior (any
other region is found by a position that does not follow a moved surface), a remesh is not
differentiable (refused under jax.grad), and each remesh drifts a curved boundary's enclosed area by
~6e-3 (mmg, measured on a disk).
* A Dirichlet BC on the moving surface must be tied to a whole-boundary or held tag, not to a spatial
sub-predicate — a predicate does not follow the motion.
* Any nodal-Lagrange field(s), real, non-periodic, 2D or 3D — scalar or vector, P1 or higher, and
mixed orders across a coupled system (a Taylor–Hood pair moves as one). Nonlinear problems work
too. Complex, periodic, a non-nodal family (RT / Nédélec / Hermite / Argyris / Morley), a custom
solve_fn and save_ts= each raise.
??? note "Why higher order costs almost nothing here"
Higher order costs almost nothing structurally, for two reasons worth knowing: the mesh geometry is
P1 whatever the field order — a moved simplex stays straight-sided — so the quadrature map and the
point location are shared by every field; and a topology-preserving move leaves the P{k} connectivity
unchanged, so the seed assembly's tables stay valid for the whole march and the moved DOF
coordinates are never needed at all. The quadrature degree follows the order (2k), because the mass
∫φᵢφⱼ must be integrated exactly or the solve is not a projection.
Reconnecting without recompiling — runtime connectivity
A Delaunay flip changes which nodes form a cell, but not how many cells there are, so no array changes shape. A march with a geometry term therefore assembles against a runtime connectivity: the cell array rides the same argument channel the moved vertices already use, and a reconnection that keeps every shape hands the compiled program a new triangulation instead of rebuilding it.
It is inferred: coord.d(t) - velocity already states that the mesh moves, and that is the only case
in which the connectivity can change under a fixed node set. Domain.dynamic_topology() is the explicit
spelling, and dynamic_topology(False) opts out.
Runtime connectivity, on a geometry-term march
| baked connectivity | runtime connectivity | |
|---|---|---|
| one reconnection | one XLA compilation, ~31 s | 0.13 s |
| two-drop coalescence, whole march | 1587 s | 69.8 s (22.7×), trajectory identical to 1.1e-16 |
relocate, no reconnection at all (41 frames) |
1.6 s | 1.0 s |
remesh(alpha=1.2, every=1) (41 frames) |
3.5 s | 0.9 s |
It is faster even when nothing reconnects, which is why it is the default: the gather goes through the index array the moved vertices travel on anyway.
Only same-shape changes become free. A reconnection that changes the cell count (two bodies merging,
node management inserting a node) still rebuilds. Affine simplices only: on a curved or tensor-product
mesh an explicit dynamic_topology() raises, and the inferred one falls back to the baked connectivity.
When the node set does change, the state is carried by a conservative L2 projection onto the new mesh rather than by pointwise interpolation. On a laser-heated drop, whose hot layer is a few cells thick, one remesh used to cost 540 K of peak temperature and 6 % of the thermal energy; it now costs 6 K and +0.5 %.
Adapting a moving mesh — relocate, escalate=, anisotropic remesh
All three adaptivity kinds that move or add nodes compose with a geometry-term march.
r-adaptivity is nearly free here. Vertex positions ride the scan carry, so a relocation hands a new
X to the same compiled program: no new nodes, no new connectivity, no rebuild.
600 W melt ball (R = 150 µm, h = 40 µm), 800 steps
| adaptivity | nodes | wall | p90 interpolation error | dent |
|---|---|---|---|---|
remesh(alpha=1.2, every=1) |
78 | 129 s | 0.1720 | −22.78 µm |
relocate(every=20) |
74 | 34 s | 0.1722 | −22.23 µm |
relocate(every=20, escalate=0.5) |
112 | 61 s | 0.1124 | −26.18 µm |
How often it relocates is free once the Monge–Ampère round is compiled: every=100/50/20/10 all
cost 34–35 s.
- Area is conserved; perimeter is not. A boundary vertex slides only along the chord joining its two boundary neighbours, which leaves the enclosed area unchanged to first order, and an iterated normal correction removes the rest (4.7e-3 → 4.2e-11 relative). A free surface stretches, so its perimeter is left free.
- A sliver is a connectivity defect. Moving nodes cannot repair it, so
relocate().remesh(alpha=...)reconnects first, at a fixed node set, and then relocates. - A relocation that would invert a cell is rejected, not applied. Relocation optimises the
discretisation, never the physics, so skipping a round is harmless and visible in the trajectory's
records (
relocate_skipped,relocate_would_invert).JNO_RELOCATE_STRICT=1raises instead. Before this guard, a 600 W run reported 10,201 plausible frames while 98 % of its cells had inverted, and its temperatures stayed in range throughout. Only the area and the orientation revealed it. escalate=toladds nodes only when moving them was not enough. After each relocation the march measures the interpolation error, and abovetol(relative to the field's range) it adds vertices along the anisotropicmmgpath. The error indicator pairs each curvature with the element's extent in its own direction (Alauzet & Loseille, J. Comput. Phys. 229 (2010) §2.2). An isotropic shape measure is deliberately not the trigger: on an anisotropically adapted mesh it flags the stretched cells that are doing their job. The loop is self-limiting. Above, it fired once, at the error peak.
Anisotropic h-adaptivity is remesh(anisotropic=True) with a criterion that says when to remesh,
and a Hessian metric that says how. On a squeezed disk carrying a sharp front, it produced elements with
aspect ratio 9.04 lying along the level sets (|long axis · ∇u| = 0.046, against 0.330 for an isotropic
remesh).
Not wired, and the reasons are substantive:
- p-adaptivity (
enrich()) needs aspace="cover"field, whose degrees of freedom are not nodal values. Plain P2 does march on a moving mesh. - r-adaptivity on two separate bodies still fails. On a two-ball problem, 399 of 400 relocation
rounds would invert cells and are rejected. Use
alpha, which is also the only adaptivity that can merge bodies.
A graded mesh reconnects on its own length scale
The alpha filter keeps a triangle when its circumradius is below alpha × a length scale, and that
scale is now each node's own mean edge length, not one number for the whole mesh. On a graded mesh a
single value fails at both ends: coarse cells all exceed it, and their nodes become free particles; in
the fine region, the coarse value fuses surfaces that are really apart. Two rod tips graded 50 → 500 µm:
the scalar mean (98 µm) stranded 50 of 363 nodes; the per-node scale triangulates all of them and keeps
the rods apart. An inserted midpoint inherits the mean of the two nodes it splits.
A march that survives being killed — checkpoint=
A march holds every frame and returns them only when solve() returns, so a run killed by an OOM, a
signal or a reboot used to return nothing, however far it had got. checkpoint= writes frames to disk as
they are produced:
traj = fem.solve(
nonlinear=jno.solve.newton(direct=True),
adapt=jno.solve.remesh(alpha=1.2, every=1),
checkpoint=jno.solve.checkpoint("runs/ball", every=500),
)
everyis the number of steps between writes. A write also happens at every rebuild, because that is where a restart has to begin anyway.keep="last"(the default) drops each written chunk from memory, and the returned trajectory loads its frames from disk on demand, so a march no longer has to fit in RAM.keep="all"keeps everything resident and checkpoints only for crash safety.resume=Truecontinues an unfinished run inpath. A run marked complete is never resumed; delete the directory to redo it.
The restart state is the one a rebuild already re-enters the march with, plus the domain the next
segment starts on. Trajectories are bit-identical with and without checkpointing, including across
rebuilds (600 and 1,400 steps on a melt ball; max |Δstate| = max |Δpoints| = 0).
The same checkpoint= writes down the fixed-mesh marches too: a transient (u.t) march with a built-in
scheme, and a load-path march (.i(k) with domain(tau=...)).
u = fem.solve(checkpoint=jno.solve.checkpoint("runs/heat", every=200)).fn() # u.t march
u = fem.solve(checkpoint=jno.solve.checkpoint("runs/plate", every=50)) # tau load path
Those marches are compiled loops, and checkpointing runs them as a host loop of chunks of every steps,
writing the frames to one frames.npy memory map and the loop carry to latest.npz after each chunk.
The transient trajectory is bit-identical to the unchecked march on CPU and within 1 ulp on GPU (the
chunks compile to other shapes than the single scan); the load path agrees to round-off (2.8e-17). With keep="last" the result is a numpy.memmap of the frames on disk. A store
records what identifies its march (grid, sizes, scheme, initial state, parameters, operators), so
resuming a different problem into it raises instead of splicing the two.
A checkpointed march is not differentiable
A chain of scans is not the single scan whose adjoint jNO builds, so checkpoint= raises under
jit / grad / jno.core. Checkpoint the forward run at named values
(fem.solve(k=2.0, checkpoint=...)) and differentiate without it.
Every solve that cannot write itself down refuses checkpoint= rather than run and leave the directory
empty: a steady solve, an adaptive time step, a sharded or solve_fn= integrator, adaptive / arc-length /
contact load stepping, and a moving mesh without adapt= (one compiled scan, with no boundary to write at).
Two memory leaks in the rebuild path were fixed with it:
- a closure kept each segment's sampled point pools alive;
- re-sampling a region wrote to a fresh alias instead of the region's own name, which also left the constraints reading the old points.
Per-rebuild memory growth fell from 0.361 GB to 0.027 GB, and device memory now stays flat.
JNO_MARCH_MEMDEBUG=1 prints the per-rebuild breakdown (RSS, live JAX buffers by shape, Python heap,
domain.context) that found both leaks.
Per-region (sub-domain) integration
A weak term integrates over the region of the coordinates it is written on — exactly the rule that
already routes boundary terms. Bind the trial/test to a sub-region's coordinates and the term
integrates over that sub-domain's cells only. Name a region with domain.tag(name, predicate) (or use
a multi-part mesh's geometry parts) and ask for its coordinates with domain.variable(name, split=True):
d.tag("core", lambda x, y: (x - 0.5)**2 + (y - 0.5)**2 < 0.2**2) # an interior sub-region
xc, yc, _ = d.variable("core", split=True)
uc, vc = u.bind(x=xc, y=yc), phi.bind(x=xc, y=yc)
fem = jno.fem([
ui.x * vi.x + ui.y * vi.y, # ∫_Ω ∇u·∇v (whole domain)
9.0 * (uc.x * vc.x + uc.y * vc.y), # ∫_core 9 ∇u·∇v (k = 1 outside, 10 inside `core`)
q * vc, # ∫_core q·v (a localized source)
u(xb, yb) - 0.0,
])
Multi-material conduction is then one term per material; a data-fit / QoI confined to a region is
(uc - u_data) * vc. A cell belongs to a region iff its centroid does (classified once at assembly
— exact when the mesh respects the region boundaries, e.g. gmsh meshing each part separately; O(h) at
an arbitrary-predicate interface). Region integration is a scalar mask on the integrand, so it
composes with everything: constant / jno.fn / .freeze() / trainable coefficients, and the
steady-linear, nonlinear, transient, coupled, and 3-D forms. A jno.np.parameter that multiplies a
sub-region term is recovered per sub-domain through crux.
Every field still spans the whole mesh
fem_symbols has no region argument, so a field restricted in its terms still carries DOFs
everywhere. If every term touching a field is region-restricted, that field's DOFs on the rest of
the mesh appear in no equation at all and the system is structurally singular — the case that shows
up the moment you write different physics per region (Navier–Stokes on fluid, elasticity on
solid).
jno.fem refuses that at build time, naming the field, the count and the region:
jno.fem: these DOFs appear in no term and carry no prescribed value, so the system is
structurally singular:
field 'u' (block 0): 87 DOFs, every term reaching it is restricted to 'lower'
The fix is to give the field a term over the region it is missing from; where it carries no physics
there, a cheap 1e-8 * u * phi on that region is enough. A Dirichlet pin on the region is not a
reliable substitute: an essential condition resolves its nodes through domain.tag_node_mask,
which for a volume region is a proximity test against sampled points rather than a containment
test, and can miss interior nodes (measured: it reached 32 of the 33 ungoverned DOFs on a two-block
domain). Reach itself is measured by cell centroid — the assembler's own resolution — so the check
cannot disagree with what was assembled.
Scope: this finds a DOF that appears in no term. It is not a rank test, and does not detect a missing pressure gauge or an unrestrained rigid-body mode.
domain.attach — many materials as one equation
For many regions, writing one term per region is noisy. Declare the property on each region with
attach, then read it back as d.<name>: a single coefficient whose value is chosen, per cell, by the
region the cell's centroid lies in — so the whole multi-material weak form is one equation over the
whole interior:
xi, yi, _ = d.variable("interior", split=True) # whole domain, bound once
ui, vi = u.bind(x=xi, y=yi), phi.bind(x=xi, y=yi)
d.attach("steel", k=16.0).attach("air", k=0.026).attach("core", k=25.0)
d.attach("core", Q=heat_source).attach(Q=0.0) # a bare attach = the default for the rest
fem = jno.fem([d.k * (ui.x*vi.x + ui.y*vi.y) - d.Q * vi, u(xb, yb) - 0.0])
Omit the target for a default. attach(Q=0.0) gives the value for every region that names none of
its own — the source that is zero outside one body. An explicit target always wins over a bare one,
whichever order they are written in: without that rule the two orderings would differ silently.
A default applies to volume regions only; boundary tags are not a partition of the boundary, so a
surface default has nothing to mean and is refused.
A value can be a scalar, a jno.fn field, or a trainable jno.np.parameter, so the same primitive
expresses conductivity, a source, a density, a reaction rate, an elastic modulus — and trainable
per-region values compose for free, calibrated through crux. Each target must be a geometry part
(from_regions), a domain.tag, or a mesh-file region. It desugars to sum_r RegionMask(r)·value_r,
inheriting the centroid classification.
Not yet wired: second-order-in-time (
u_tt) sub-region terms (fail loud). 3-D sub-regions are defined by a predicatewhere(x, y, z).
Axisymmetric (bodies of revolution)
jNO applies no ring measure for you — you write it in the weak form. A meridional (r, z) mesh is
an ordinary 2-D mesh; what makes a form axisymmetric is the measure you put in it.
For a scalar field that is exactly the Cartesian integrand times 2πr:
so nothing is missing — write the factor and you have it:
d = jno.domain(box(a, 0, b, h), mesh_size=0.05) # just a 2-D mesh
d.tag("inner", lambda x, y: jnp.abs(x - a) < 1e-9)
d.tag("outer", lambda x, y: jnp.abs(x - b) < 1e-9)
u, v = d.fem_symbols()
r, z, _ = d.variable("interior", split=True)
ui, vi = u.bind(x=r, y=z), v.bind(x=r, y=z)
ra, za, _ = d.variable("inner", split=True)
rb, zb, _ = d.variable("outer", split=True)
dV = 2 * jnp.pi * r # the ring measure, written once
fem = jno.fem([k * (ui.x * vi.x + ui.y * vi.y) * dV,
-g * v.bind(x=rb, y=zb) * (2 * jnp.pi * rb), # boundary terms carry it too
u(ra, za) - T_a])
T = fem.solve() # logarithmic in r, and Q = 2πk ΔT/ln(b/a) comes out right
Every term needs it — volume terms, source terms and Neumann/Robin terms alike. Miss one and the
answer is wrong by exactly that factor with no error raised, so bind dV once at the top and reuse it.
Vector fields need more than the measure
Axisymmetric elasticity carries a hoop strain
ε_θθ = u_r/r, and divergence picks up u_r/r. Neither can be produced by weighting the Cartesian
form by anything — they are extra terms you must write out. This is precisely why jNO does not offer
to apply the weighting automatically: it would be exact for scalars and quietly wrong for vectors.
The vector recipe, written out
A displacement u = (u_r, u_z) on the meridian strains four components, not three:
The last one is the whole difference, and it is ordinary arithmetic in the term list:
u, v = d.fem_symbols(value_shape=(2,), order=2) # (u_r, u_z)
r, z, _ = d.variable("interior", split=True)
ub, vb = u.bind(x=r, y=z), v.bind(x=r, y=z)
def strains(w):
return w.x[0], w.y[1], w[0] / r, 0.5 * (w.y[0] + w.x[1]) # rr, zz, THETA-THETA, rz
e_rr, e_zz, e_qq, e_rz = strains(ub)
f_rr, f_zz, f_qq, f_rz = strains(vb)
tr_u, tr_v = e_rr + e_zz + e_qq, f_rr + f_zz + f_qq # the trace carries it too
energy = lam * tr_u * tr_v + 2 * mu * (e_rr*f_rr + e_zz*f_zz + e_qq*f_qq + 2*e_rz*f_rz)
fem = jno.fem([energy * (2 * np.pi * r), ...]) # and then the ring measure
For flow the same four components appear, and the divergence is
\(\nabla\!\cdot\!\mathbf u=\partial_r u_r+u_r/r+\partial_z u_z\) — so div_u = e_rr + e_qq + e_zz, which is
what the continuity equation and the pressure term must both use.
Both halves are pinned by tests/test_fem_axisymmetric_vector.py
Lamé thick-walled cylinder under internal pressure (plane strain), exact
\(u_r = \frac{(1+\nu)pa^2}{E(b^2-a^2)}\left[(1-2\nu)r + b^2/r\right]\): the form above reproduces it to
under 1 %, with \(u_z\) vanishing. Drop ε_θθ from that same form and the error is over 10 %
and the cylinder measurably softer — with no error raised, which is what makes it worth a test.
Poiseuille pipe (\(u_z = G(R^2-r^2)/4\eta\)) on a meridian that includes the axis, Taylor–Hood
P2/P1: machine precision, since P2 represents the parabola exactly. Two details matter — a
traction-free pipe end is not what fully developed flow satisfies (the parabola carries a shear
traction \(\eta u_z'\) on a z-face), and on the axis u_r/r is harmless because quadrature points sit
strictly inside cells and \(u_r=0\) is imposed there.
This applies to vector EM too, and there is no guard
An axisymmetric (r, z) vector
Maxwell / eddy-current form — the natural geometry for a coil, a solenoid, a tokamak vessel — is
not the Cartesian curl-curl weighted by 2πr. In cylindrical coordinates the curl of a vector
field picks up its own 1/r terms — (∇×E)_z = (1/r)∂(rE_θ)/∂r — and for an axisymmetric (m=0)
problem the meridional (E_r, E_z) and azimuthal (E_θ) components decouple into two different
operators that must each be written out. The azimuthal one reduces to a scalar equation (in
E_θ, or in rA_θ for the vector-potential/eddy-current form), not a component of the Cartesian
form, and it needs care on the axis where 1/r is singular. Weighting an
"N1E" form by 2πr therefore produces a silently wrong answer, not an approximate one.
Nothing raises: multiplying by r is ordinary arithmetic, and the assembler cannot tell it apart
from a legitimate radial coefficient — so this limit is stated here, where you choose the geometry,
rather than enforced at assembly. Use a full 3-D mesh for vector Maxwell (3-D N1E is wired and
validated), or derive and write the cylindrical operator yourself as an ordinary scalar/coupled
form. jNO ships no axisymmetric H(curl)/H(div) element and no meridional/azimuthal split.
Enclosure radiation carries the same factor
domain.enclosure(tags, axisymmetric=True) gives ring areas 2πr̄·L and a
gap.load(q) that is per full revolution (W, not W/m). The weak form you add it to must carry
the same 2πr, or the two sides differ by exactly that factor. jNO cannot check this for you.
Enclosure radiation (nonlocal boundary flux)
Grey-body radiation between surfaces is nonlinear (T⁴) and nonlocal (every surface element
exchanges with every other via the view-factor matrix F), so it cannot be a local weak term. jNO
provides the geometric building block — the view matrix — and you write the radiosity as math in
jno.np; there is no jno.radiation() helper.
domain.enclosure(tags) discretises the radiating boundary surfaces into elements aligned to the
FEM mesh nodes and returns a handle:
gap = d.enclosure(["inner_gap", "outer_gap"]) # name the surfaces once
gap.check() # F-quality gate: closure (Σ_j F_ij→1) + reciprocity (A_i F_ij=A_j F_ji)
F = gap.view_factor # (m, m) element view factor — fully geometry-determined
eps = gap.emissivity({"inner_gap": 0.8, "outer_gap": 0.6}) # per-element ε from a {tag: ε} map
rho = 1.0 - eps
How the view factors are computed
F is computed purely from geometry (occlusion + orientation) by double-area Gauss quadrature of
the diffuse kernel — so a concave surface keeps its self-view. Tags only group elements (for
per-surface emissivity); they never block exchange. The enclosure inherits axisymmetric from the
domain (see below), so its ring areas and the FEM measure cannot disagree; passing a contradicting value
raises. By default the boundary normals point out of the mesh (radiation across an un-meshed gap); for
an oven/furnace cavity where the meshed fluid is inside, pass inward=True so the facing walls see
one another (see the Oven tutorial); for a meshed medium between solids use medium_tags.
What blocks a ray — interface mode
Every meshed region that is not listed in medium_tags
is opaque — including a solid that carries no radiating surface of its own. The occluder set is
resolved once from the region list, not inferred from element tags, and is shared by the visibility
test and the near-field refinement: a solid with no radiating surface still blocks, and a chord
through it is never counted as visible.
Axisymmetric near field, and when the graded rule runs
The ring kernel's azimuthal integrand peaks at φ = 0 with width d/r,
so a uniform n_phi rule overshoots every near-touching pair (two surfaces meeting in a wedge, and
every element's own ring self-view) by roughly dφ/(d/r). A graded azimuthal rule fixes it and
restores closure to ~1e-3, but it needs an occluder model to test its refined chords against, so it
runs when one is available: medium_tags=... (interface mode — the solid polygons), or
occlude=False (you asserting nothing blocks any ray, e.g. a convex cavity). Plain boundary mode
with occlusion on keeps the uniform rule plus the r_min floor and a closure error around 1e-1, and
logs a warning saying so. Its occlusion is also a meridian-only test reused at every azimuth, which
is wrong for a general solid of revolution — interface mode checks the true 3-D chord per azimuth.
Always call gap.check().
Write the full grey-body radiosity (reflections included) and couple it to the conduction FEM by adding the net flux as a consistent surface load to the residual:
SIGMA, KELVIN = 5.670374419e-8, 273.15
def q_rad(u): # net radiative flux per element: q = σ·G·T⁴
Ts = gap.field(u) # nonlocal gather: per-element temperature from the solution
J = jno.np.linalg.solve(jno.np.eye(gap.size) - rho[:, None] * F, eps * SIGMA * (Ts + KELVIN)**4)
return J - F @ J # (I − F)(I − diag(ρ)F)⁻¹ diag(ε) σ T⁴
# −k ∂T/∂n = q_rad enters the residual as a consistent load: A u = b − gap.load(q_rad(u))
gap.field(u) gathers the per-element temperature; gap.load(q) scatters a per-element flux back to
the FEM nodes as ∫_Γ q·v ds. The radiosity (I − ρF)⁻¹ solve is jno.np — it is traced, so a
trainable jno.np.parameter emissivity flows through it for inverse problems.
Solver note (BYO, jax-native). The Dirichlet conditions are penalty-enforced, so the conduction A
is ill-conditioned — use a direct linear solve (a matrix-free iterative solver such as the
built-in newton_krylov may stall). The whole coupled solve stays jax-native and differentiable
(so jax.grad/crux recover an emissivity through the radiation) with a short direct-solve Newton
wrapped in jax.lax.custom_root. Validated on two concentric cylinders against the closed-form
two-surface series to <1%, including jax.grad w.r.t. emissivity vs finite differences
(tests/test_fem_enclosure_radiation.py).
In-residual coupling (jno.Coupling) — implicit, trainable, transient
To instead solve conduction and radiation as one implicit system, pass the nonlocal residual in
the jno.fem([...]) list. A plain function f(u) -> (n_dofs,) there is taken as a nonlocal
coupling: jno.fem adds it to the assembled residual R(u) = R_local(u) + Σ_k coupling_k(u),
promoting a linear form to a nonlinear one, and fem.solve() drives the whole thing with the
matrix-free, custom_root-differentiable newton_krylov:
def radiation(u): # the same radiosity, now a residual contribution
Ts = gap.field(u)
J = jnp.linalg.solve(jnp.eye(gap.size) - rho[:, None] * F, eps * SIGMA * (Ts + KELVIN)**4)
return gap.load(J - F @ J) # net flux scattered to nodes
fem = jno.fem([conduction, radiation, u(xc, yc) - T_COOL]) # radiation is the bare function
Tsol = fem.solve(u0=T_guess) # conduction + radiation, one implicit solve
A jitted residual / callable object isn't a plain function — wrap it as jno.Coupling(fn), which is
also how you reach the options below:
-
Trainable coupling parameters. A coupling is opaque to the trace walk — a
jno.np.parameterinside it is never found, so declare it. The residual then takes a second argument, the{name: value}dict:eps = jno.np.parameter((1,), name="eps") # emissivity, calibrated from data eps.initialize(jax.nn.initializers.constant(0.8)) def radiation(w, p): # p -> {"eps": value} e = p["eps"].reshape(()) J = jnp.linalg.solve(eye - (1.0 - e) * F, e * SIGMA * gap.field(w) ** 4) return gap.load(s_row * J - F @ J) fem = jno.fem([conduction, jno.Coupling(radiation, params=[eps]), u(xc, yc) - T_COOL])The gradient reaches
epsthrough the radiosity solve, sojno.corerecovers it from temperature observations like any other parameter (checked against finite differences to 3% intests/test_fem_enclosure_radiation.py). - Multifield.jno.Coupling(fn, field_key=T_key)acts on one field's DOF block (e.g. radiation onTin a heat+flow / thermo-mechanical solve). - Transient. The coupling enters each implicit step, so enclosure radiation over a heating cycle solves in-residual. (Not combined with periodic ties.) - Essential rows. The contribution is zeroed on every Dirichlet row -- constant, time- or τ-dependent, or parameter/net-valued -- so it never moves a prescribed value. A coupling on a linear form promotes it to a residual operator that still declares those rows, sojno.solve.staggered(over_relax>1)leaves them alone.
All four (bare function, params, field_key, transient) are covered in
tests/test_fem_enclosure_radiation.py. Reference: M. F. Modest, Radiative Heat Transfer, 3rd ed.,
Ch. 4–5 (view factors; the net-radiation / radiosity method for diffuse-grey enclosures).
Nonlocal coupling, in general — jno.Coupling and jno.derived
Enclosure radiation is one instance of a shape that recurs: gather → operate → scatter, where the "operate" is not a local integrand. jNO has two mechanisms for it, and which one you want is decided by what the nonlocal quantity is, not by the physics.
jno.Coupling(fn) |
jno.derived(fn, inputs=[...], on=u) |
|
|---|---|---|
| produces | a residual vector R(u) += c(u) |
a nodal field d = f(u) |
| used as | a term in the jno.fem([...]) list |
a value inside any term |
| tangent | carries the coupling exactly | lagged (Picard); stays local and sparse |
| solver | matrix-free Newton–Krylov only | assembled tangent, so newton(direct=True) works |
| converges | quadratically | linearly, and can stall on a strong coupling |
Reach for derived when the nonlocal quantity is needed inside a term, which is the case a residual
vector simply cannot express. A laser's Beer–Lambert attenuation is the clean example: the depth
τ(x) = ∫α ds depends on the whole chord behind each point, and exp(−τ) multiplies the source:
from jno.utils.optics import beam_paths, optical_depth # the beam GEOMETRY (private; host-side, once)
nodes, w = beam_paths(pts, cells, direction, pts, n_samples=600)
alpha = lambda T: a0 * (1.0 + beta * T) # a hotter body absorbs more
tau = jno.derived(lambda T: optical_depth(alpha(T), nodes, w), inputs=[u], on=u)
Q = a0 * I0 * jno.np.exp(-tau) # tau reads as an ordinary field
fem = jno.fem([k * (ui.x * vi.x + ui.y * vi.y) - Q * vi, u(xb, yb) - 0.0])
The rule is pure JAX on the input fields' nodal values. Everything host-side — ray tables, view
factors, neighbour lists — is built once outside and closed over; a numpy/scipy rule is refused at
jno.fem build, with the fix named. Non-traceability, a wrong output length and an unknown on= field
all fail there rather than inside a Newton step.
What lagging does and does not change. The values are computed inside the residual from
stop_gradient(u), so the linearization sees them as data and the nonlinear solver is the fixed-point
loop — no extra solver slot, no outer driver.
- The converged root is exact:
R(u) = 0does not depend on gradient markers, so lagging changed the path, not the answer (the same argumentjno.lagmakes). Convergence is checked on the true coupled residual, so a fixed point that does not converge raises rather than returning a plausible field. - Convergence is linear, at a rate set by the coupling strength. Past a critical strength it does not
converge at all;
fem.solve(nonlinear=jno.solve.picard(damping=...))ornewton(line_search=True)is the remedy, andjno.Couplingis the alternative when you want the coupling in the tangent. - Gradients are the Picard adjoint — descent-worthy and trainable through
jno.core, but not exact; a finite-difference check will not agree to machine precision. Aparams=[...]value inside the rule is not lagged, so the direct sensitivity through it is exact.
Cadence. every="residual" (default) re-evaluates the rule at every residual evaluation, so a
transient step is fully implicit in the coupling. every="step" evaluates it once per step of a march
from the previous step's state — cheaper, but an operator splitting with its own O(dt) error that no
residual check can see, because the step converges, just to a slightly different problem.
Host geometry is frozen at build
Ray tables and view factors closed over by the rule are fixed for the life of the jno.fem. On a
moving mesh they go stale silently — a rebuild means a new jno.fem. This is the same caveat the
enclosure carries, and it is the open edge of droplet-plus-laser work.
The same enclosure, written as a derived field
gap.load(q) returns an integrated load, so it becomes a nodal field by dividing out the consistent
weights W_j = ∫φ_j ds — which is gap.load of a unit flux, no new API:
W = gap.load(jnp.ones(gap.size), size=nd) # the consistent nodal weight
qn = jno.derived(lambda T: gap.load(q_elem(T), size=nd) / jnp.where(W > 0, W, 1.0), inputs=[u], on=u)
fem = jno.fem([conduction, qn * v.bind(x=xg1, y=yg1), qn * v.bind(x=xg2, y=yg2), *bcs])
Because Σ_i (M_∂)_ij = W_j, the total radiative power is conserved exactly; the difference is one P1
boundary-mass smoothing of its distribution, consistent at O(h²) — measured at a thousandth of a kelvin
on the concentric-cylinder case in tests/test_fem_enclosure_radiation.py. What it buys is the tangent:
the derived form has an assembled sparse Jacobian, so newton(direct=True) solves it, where the
Coupling path is matrix-free only because its tangent couples every enclosure element to every other.
What it costs is robustness on a radiation-dominated enclosure, per above. Both spellings are supported;
the Coupling one remains the default in the examples.
What stays on its own path — contact
Contact is not written with either mechanism, and deliberately. The gap value is a weighted gather
inside the differentiated residual, so the secondary–main coupling is exact in the tangent; only the
pairing is lagged, by a host-side search between rounds. Routing it through derived would
stop_gradient the gap value itself, turning a quadratic Newton contact into a Picard iteration on a
penalty interface — the regime where Picard is worst. The gap also lives at face quadrature points rather
than on nodes, so there is nothing for on= to name.
What contact does share is the delivery contract: host-frozen shapes, per-round values threaded on
args. __gap_tables__, __loadpath__ (load-path fields, previous states, the mesh velocity) and
derived fields are all that one idea. Anything that writes to those channels must merge, never assign
— a march can carry a mesh velocity and a derived field at once.