Vector, coupled, and nonlinear formulations
Vector, coupled, and higher-order problems
- Vector / elasticity —
u, phi = d.fem_symbols(value_shape=(2,)); write the elasticity bilinear formλ (∇·u)(∇·φ) + 2μ ε(u):ε(φ)withjno.np.symgradandjno.np.inner(..., n_contract=2)— or component-wise:u[i]andu[i].x/u[i].d(var)are first-class on a vector Lagrange field, and the two spellings mix freely in one term (the shape conventions are shared). A boundary traction on one component is-t * phi_b[i]. - Finite-strain (hyperelastic) mechanics — the component spelling is what makes it expressible:
build
F = I + ∇ufromu[i].d(x_j), thendet F,F⁻ᵀandlogare ordinary term algebra, and a form nonlinear in∇uroutes to the default Newton automatically (usenonlinear=jno.solve.newton(line_search=True)for large steps). Compressible Neo-HookeanP = μ(F − F⁻ᵀ) + λ ln(J) F⁻ᵀis verified intests/test_fem_vector_components.py: it matches the coupled-scalar spelling to machine precision and linear elasticity in the small-load limit. Ramp a hard load with a warm-startedsequenceaxis:space.sequence("load", ramp, keep="last")thencrux.sweep(space)— measured on the cantilever: cold default Newton fails atload = 0.1while the four-step warm-started ramp reaches it. - Coupled / mixed (Stokes) — call
fem_symbols(...)once per field and add one momentum and one continuity term; an inf-sup-stable Taylor–Hood pair isorder=2velocity +order=1pressure. Pure-Dirichlet velocity leaves the pressure defined only up to a constant; gauge-fix that null space by addingp.pin()to the constraint list (p.pin(value)sets the gauge).
Which gauge, and when it matters.
p.pin(value)fixes one vertex's discrete value to a continuous one. That is fine whenever only the pressure gradient is used, and wrong as soon as the level is read, because the constant it leaves behind does not shrink with the mesh. Measured on a manufactured 3-D Stokes solution (P2/P1 tets, direct solver), the pressureL2error under refinement is3.57e-2 → 1.08e-2 → 1.34e-2 → 5.56e-3— it rises ath = 0.22, and the observed order is6.16 / -0.89 / 4.38, i.e. no order at all.p.pin(mean=True)gauges to∫p dx = 0instead and the same problem gives8.13e-3 → 4.50e-3 → 2.66e-3 → 1.88e-3, order3.05 / 2.17 / 1.75against the theoreticalO(h²). The velocity is identical either way — the field was always right up to that constant (Bochev & Lehoucq, SIAM Review 47(1), 2005, §3). A natural (do-nothing) outflow fixes the level on its own, so a channel with an outflow wants no pin at all. The normalisation applies wherever a solution is returned — steady vector, transient trajectory, or a lazy solve node — and is plain arithmetic, so it survivesjit/grad. * 1D and 3D — a 1D interval or a 3Dcube/extrudedgmshvolume use the identical API with one fewer / one more coordinate (ui.z,u(xb, yb, zb) - g).
Matrix-valued fields
A field may be a matrix: S, T = d.fem_symbols(value_shape=(2, 2), names=("S", "T")) (or (3, 3) in 3-D)
carries n·m values per node, stored row-major, so entry (i, j) is component i·m + j. On a bound view
Si = S.bind(x=x, y=y) it behaves as a matrix in every expression: Si @ L, L @ Si, a constant
W @ Si, Si.T, jno.np.trace(Si), inner(Si, Ti, n_contract=2), an entry Si[0, 1], a row Si[0],
and the derivatives Si.x (a matrix) and jno.np.jacobian(S, [x, y]) (shape (n, m, dim)). The test
function is the same: Ti[0, 1], trace(Ti) and Ti.x are what they say.
S, T = d.fem_symbols(value_shape=(2, 2), names=("S", "T"), order=2)
Si, Ti = S.bind(x=x, y=y), T.bind(x=x, y=y)
W = jno.np.array([[0.0, g / 2], [-g / 2, 0.0]]) # spin of u = (g y, 0)
D = jno.np.array([[0.0, g / 2], [g / 2, 0.0]]) # rate of deformation
transport = lambda A: g * y * A.x # (u·∇)A
R = transport(Si) - (W @ Si - Si @ W) - 2 * G * D # Jaumann rate = 2G D
fem = jno.fem([inner(R, Ti + tau * transport(Ti), n_contract=2), # SUPG-tested
S(xl, yl) - 0.0]) # stress-free inflow
This is the Eulerian form of the simple-shear test of Dienes (1979): the computed stress converges to
s_xy = G sin(gt), s_xx = -s_yy = G(1 - cos gt) with t = x/(g y) (tests/test_fem_matrix_fields.py,
which also checks the L2 projection, a componentwise Laplace in 2-D and 3-D at P1 and P2, and an inflow
profile carried unchanged — all nodally exact). Dirichlet data on a matrix field is described in
Boundary conditions.
Symmetric storage. fem_symbols(value_shape=(n, n), symmetric=True) stores the upper triangle only,
n(n+1)/2 values per node, in the order xx, xy, yy (2-D) or xx, xy, xz, yy, yz, zz (3-D) — so a 2-D
stress costs 3 DOFs per node instead of 4, a 3-D one 6 instead of 9. Nothing else changes: the symbol is the
full n×n matrix in every expression (S @ W, S.T, trace, inner, S[1, 0] is S[0, 1], S.x), and
its test function is the symmetric one, so the equations are the symmetric part of the full-storage ones.
On a problem whose full-storage solution is symmetric (the Jaumann shear above, a tensor Laplace, a
nonlinear S + |S|²S = F, a transient decay) the answers agree to round-off with 3/4 of the DOFs
(tests/test_fem_matrix_fields.py). The solution vector holds the stored values, node-major. Scope: the
native 2-D/3-D Lagrange assembler — a 1-D domain, a VPINN, periodic ties, a complex field or a mix with a
non-nodal family raise.
Constant unknowns — one value, solved for
U = d.unknown.scalar(constant=True) is one number over the whole domain (d.unknown.vector(n,
constant=True) one vector, d.unknown.matrix(n, m, constant=True) one matrix), solved for together with
the fields. Everything about it follows from its basis function being the constant one:
- its trial
Uis seen identically by every element, soU * inner(n, v)on a boundary is a traction of unknown magnitude; - its test function
W = U.test()is one everywhere, so a weak term carrying it is an integral row —(uin[0] - Q / H) * Won an inlet says∫ u·e_x ds = Q, andui * Wover the volume says∫ u dΩ = 0; u(region) - Uties a field to it on a region exactly (see Boundary conditions), andU - gpins it.
A channel driven at a prescribed flow rate, with the inlet pressure as the unknown:
u = d.unknown.vector(2, order=2); p = d.unknown.scalar(name="p")
P = d.unknown.scalar(constant=True, name="P_in")
v, q, W = u.test(), p.test(), P.test()
fem = jno.fem([inner(grad(u), grad(v)) - p * div(v), -q * div(u),
P * (-vin[0]), # inlet traction -P n, n = (-1, 0)
(uin[0] - Q / H) * W, # its equation: the flow rate through the inlet
u(xw, yw) - 0.0, u(xi, yi)[1] - 0.0])
sol = fem.solve(linear=jno.solve.lu()) # a bordered saddle system: a direct solver
P_in = sol[fem.blocks[fem.block_index(P)]] # = 12 Q L / H³ (Poiseuille), to round-off
Constants are assembled as the last blocks, after every field. A constant whose only equation is a
W-row has no diagonal entry, so the system is a saddle and wants a direct solver (jno.fem warns
otherwise); a tied or pinned constant does not. Verified in tests/test_fem_constant_unknown.py: the
floating boundary value with a prescribed total flux and the flow-rate channel are exact to round-off, the
tie with a pinned U reproduces the uniform Dirichlet condition (bit-identical on the linear path), a box
U.bounds(lo, hi) holds when active, a mean-value multiplier enforces ∫ u = 0 exactly, and the
gradient of a loss in U through a runtime parameter matches the closed form. Scope: the native 2-D/3-D
Lagrange assembler; see the limitations.
Reading a multifield solution back
A coupled solve returns one flat vector, so the fem object carries the handles that say which
part is which. They are the same handles the block preconditioners key on:
st = jno.fem([mu * inner(gu, gv, n_contract=2) - pp * trace(gv), # momentum
-qq * trace(gu), # continuity
u(xb, yb)[0] - u_profile(yb), u(xb, yb)[1] - 0.0, p.pin()])
st.dofs # 850 — total, both fields
st.blocks # [slice(0, 746), slice(746, 850)] — per-field slices into the flat vector
st.offsets # [0, 746, 850] — the same thing as boundaries
st.block_index(u) # 0 — resolve a SYMBOL to its block, never hardcode the position
st.block_index(p) # 1
st.field_points # [(373, 2), (104, 2)] — each field's own node coordinates (P2 velocity, P1 pressure)
st.is_complex # False — also is_linear, is_transient
sol = st.solve()
velocity = sol[st.blocks[st.block_index(u)]]
Resolve the block by symbol, not by position
Field order follows the order the terms were written, which is not always the order you think —
on a phase-field system the degradation factor is written first. block_index(sym) is the only
spelling that cannot silently pick the wrong field.
What the fluid path is verified to do — and what it is not
Scope first, since it is not obvious from the API: jNO's FEM fluid path is laminar incompressible
flow. There is no RANS model (no k–ε, no k–ω SST, no wall functions), no compressible or Euler path,
no free surface / VOF / level set, and no fluid–structure interaction. Nothing about jno.fem stops
you writing those terms; nothing in the library implements or verifies them.
The one qualification is algebraic (zero-equation) LES, below: a subgrid eddy viscosity is a
formula of the resolved velocity gradient, so it is written in the term list like any other
coefficient. Those formulas are checked against a textbook oracle in 2-D and 3-D, and run inside a
Newton solve in the LES subgrid tutorial — where
Vreman and WALE vanish in simple shear (1.1e-6 and 2.0e-19 of nu) while Smagorinsky reports 8.0e-2.
They are still not a validated LES capability: that needs a turbulent benchmark against DNS
(wall-resolved channel, decaying isotropic turbulence), which this library has not run.
Subgrid eddy viscosity — a formula, not an API
Worked end to end, on two flows and with the failure modes measured, in the LES subgrid tutorial.
An algebraic LES model adds ν_t(∇u) to the molecular viscosity. It needs no new API: the filter
width is d.cell_size (or d.cell_metric if you want it direction-aware), and the model is
arithmetic on grad(u). Vreman (Phys. Fluids 16 (2004) 3670, eq. 5), written through
invariants so it reads the same in 2-D and 3-D:
g = grad(u, ax)
tr_b = delta**2 * inner(g, g, n_contract=2) # tr(beta), beta = delta^2 g gᵀ
tr_b2 = delta**4 * einsum("...ik,...jk,...jl,...il->...", g, g, g, g) # tr(beta^2)
B = where(0.5 * (tr_b**2 - tr_b2) > 0, 0.5 * (tr_b**2 - tr_b2), 0.0)
nu_t = jno.lag(0.07 * sqrt(B / (inner(g, g, n_contract=2) + 1e-30) + 1e-30))
mom = (MU / RHO + nu_t) * inner(g, grad(v, ax), n_contract=2) + ... # into the viscous term
Three things that will bite, all measured
ν_tmust be lagged. It is a square root, so its slope atu = 0is infinite and Newton diverges from a rest state outright.jno.lagfreezes it within each linearisation — the same treatment a Carman–Kozeny drag needs.- Clamp the invariant.
B_βand WALE'sS_d:S_dare non-negative in exact arithmetic but are computed as a difference of nearly equal numbers. Measured in pure shear:B_βlands at 1.4e-20 where it should be 0, from a relative cancellation of 2.7e-16. Unclamped, one round-off excursion below zero is not a small error —sqrtand**1.5return NaN. - Smagorinsky does not vanish in laminar shear, and that is a modelling defect, not a detail:
|S|is non-zero in any shear, so it invents eddy viscosity throughout a laminar boundary layer and needs Van Driest damping. Vreman and WALE (Nicoud & Ducros, Flow Turb. Combust. 62 (1999) 183) vanish identically there.tests/test_fem_les.pypins exactly that difference.
Within that scope, measured rather than asserted:
| verified by | |
|---|---|
| 2-D steady Stokes, Taylor–Hood P2/P1 | exact fields recovered to ~1e-13 with a direct solver |
| 2-D steady Navier–Stokes | Kovasznay (closed form) in the convergence matrix: velocity O(h³), pressure O(h²) |
| 2-D transient Navier–Stokes | lid-driven cavity at Re = 200, backward Euler + Newton |
| 3-D Stokes, Taylor–Hood P2/P1 tets | fitted order 3.12 velocity / 2.29 pressure against theory 3 / 2 |
| 3-D Navier–Stokes (convective term) | fitted order 3.13 / 2.37 at ν = 0.05, cell Péclet ≈ 4 |
| coupled (Boussinesq) | its own convergence row, three fields |
| an external benchmark | DFG 2D-1 cylinder at Re = 20 — c_D to 0.02 % of the published value |
| natural (do-nothing) outflow | carried by that same benchmark |
| forces on a body | reaction-based drag/lift via fem.eval + region_dofs |
Two ceilings worth knowing before you plan a run:
- Direct-solver fill-in in 3-D. Measured on one GPU, a 3-D Stokes solve is trivial to ~10k DOF
(0.60 s at 9.1k) and then turns over sharply — 4.02 s at 18.5k, i.e. roughly
O(N^2.7). That puts the practical ceiling forlu()around 30–60k DOF; past it use the block/Schur preconditioners injno.precond(verified in 3-D), orlu(backend="pardiso"/"cudss"). - Stabilisation is a formula, not a feature. SUPG / PSPG / grad-div are terms you write in the
term list, and the two pieces they need are there:
jno.np.laplacianaccepts a vector field (the momentum strong residual carriesnu*lap(u)), anddom.cell_metricgives the element metricG = J^-T J^-1that a direction-awaretauis built on (dom.cell_sizeis isotropic and cannot see a stretched cell). Verified: SUPG cuts the upstream oscillation of a Peclet-1000 transport problem by 3300x, and PSPG makes equal-order P1/P1 flow converge (Kovasznay, observed rates 1.78 velocity / 1.70 pressure, a 4.3x better pressure than unstabilised) — see the stabilised-flow tutorial. What is not settled: the grad-div/LSIC coefficient made both errors worse at Re = 20 and is left uncalibrated, and no unstabilised-P2/P1 Reynolds ceiling has been measured. - Higher-order Lagrange —
order=kgives degree-kelements (P2, P3, P4, … on triangles and tets); read the solution atfem.points. The geometry stays affine-P1 (straight-sided), so on a curved boundary the geometric error caps the observed order regardless ofk— measure high-order convergence on straight-sided/polygonal domains.
Elasto-plasticity — a trace formula, not a module
Plasticity is not a module in jNO; it is a formula in the term list (the FEM contract). The J2 (von
Mises) radial return contracts against the test strain to a scalar per Gauss point — the same trick the
elastic form uses (lam*trace*trace + 2*mu*inner, never an identity), via dev(A):B = A:B - tr(A)tr(B)/3
and ||dev(A)||^2 = A:A - tr(A)^2/3. So the whole return map is six lines of jno.np, behind your aliases:
sym, grad, trace, inner, sqrt, maximum = jno.np.sym, jno.np.grad, jno.np.trace, jno.np.inner, jno.np.sqrt, jno.np.maximum
lam, mu = lame(E, nu); K = lam + 2*mu/3; rt = 1.5**0.5
eps = lambda w: sym(grad(w, [x, y, z]))
eu, ev = eps(u), eps(phi)
tru, trv = trace(eu), trace(ev)
ddev = sqrt(maximum(inner(eu, eu, 2) - tru*tru/3, 0) + 1e-30) # ||dev eps(u)||, safe von-Mises norm at 0
dg = maximum(rt*2*mu*ddev - sy, 0) / (3*mu + H) # plastic multiplier
dev_ev = inner(eu, ev, 2) - tru*trv/3 # dev eps(u) : eps(phi)
mech = K*tru*trv + 2*mu*dev_ev - 2*mu*rt*dg*dev_ev/ddev # = the integrand of sigma(eps(u)) : eps(phi)
sol = jno.fem([mech, u(*bc) - 0.0]).solve(nonlinear=jno.solve.newton())
jno.fem sees the nonlinear form and routes to Newton; the element Jacobian is the consistent
elastoplastic tangent for free (AD of the formula). The solve is differentiable — thread sy (or H,
E) as a jno.np.parameter to recover it from an observed deformation (material-identification inverse
problem). This is Hencky deformation theory (virgin every solve): exact for monotonic proportional loading.
Flow theory — the path-dependent march
(path-dependent; unloading leaves a permanent set) is the identical formula reading the
previous step's per-quadrature-point state with the step-history index .i(k): ee = eps(u) - ep.i(-1)
and sy -> sy + H*al.i(-1), with ep, al declared like any field via fem_symbols. How each state
advances is a named update term in the same list — state.evolves(<formula>), an update, not an
equation (and not an operator: == is reserved for identity, < for comparison). The load is written as
a function of the pseudo-time coordinate tau, the domain carries a tau= load grid, and fem.solve()
marches the path with nothing passed — triggered by .i(k) exactly as u.t triggers transient:
d = jno.shape.box(0, 0, 0, 1, 1, 1, size=0.1).domain(tau=(0.0, 1.0, 40)) # pseudo-time load path
x, y, z, tau = d.variable("interior", split=True) # τ is a coordinate, like t
dev = lambda A: A - trace(A) / 3 * I3 # I3 = jno.np.identity(3)
nrm = lambda A: sqrt(maximum(inner(A, A, 2), 0) + 1e-30) # safe Frobenius norm
ee = eps(u) - ep.i(-1); D = dev(ee); dd = nrm(D) # elastic predictor about the previous state
dg = maximum(rt * 2 * mu * dd - (sy + H * al.i(-1)), 0) / (3 * mu + H)
n = D / dd
sig = K * trace(ee) * I3 + 2 * mu * D - 2 * mu * rt * dg * n # returned stress
P = peak * (1 - jno.np.abs(2 * tau - 1)) # load ramps 0 → peak → 0 with τ
traj = jno.fem([
inner(sig, eps(phi), 2) - P * inner(zhat, phi, 1), # equilibrium (test phi)
ep.evolves(ep.i(-1) + rt * dg * n), # plastic strain advances (a named update)
al.evolves(al.i(-1) + dg), # hardening advances
u(*bc) - 0.0, # clamp
]).solve() # (n_steps, n_dofs) load-path trajectory
How the build infers the history depth
.i(-k) reads history, .evolves writes it. The build infers the keep-depth from the
most-negative index and threads a zeroed per-quadrature-point buffer through the march's lax.scan carry
(one compiled residual, reused every step; frozen-constant in the tangent → the consistent return-map
tangent). The whole march rides custom_root, so it stays differentiable end-to-end: thread sy as a
jno.np.parameter and ∂(unloaded state)/∂sy flows through the entire load path (a material-
identification inverse). A primary-unknown history (u.i(-1)/u.i(-2), e.g. a BDF2 time scheme) is
auto-buffered from the solved u — no .evolves; an internal state read at .i(-1) with no
.evolves on a tau= domain is a build error (never a silently frozen buffer = deformation theory).
Scope
small-strain, isotropic, linear-hardening; 3-D (2-D is plane strain). Kinematic / nonlinear hardening and contact are separate (not built).
A state can be shared by a coupled system. The march is not single-field: history buffers are indexed
by cell, never by field, and the readout gathers every field's cell DOFs at once — so a state written
by one field and read by another is the same march. That is the phase-field / gradient-damage shape,
where an irreversible history H = max_τ ψ⁺(u) couples a displacement field to a damage field:
psi = 0.5 * inner(grad(u, X), grad(u, X), 1) # driving force, from the u field
deg = (1 - dm)**2 + eta # degradation, from the dm field
jno.fem([
deg * inner(grad(u, X), grad(phi, X), 1) - load(tau)*phi, # equilibrium (test phi)
(gc/l)*dm*q + gc*l*inner(grad(dm, X), grad(q, X), 1)
- 2*(1 - dm)*Hs.i(-1)*q, # damage evolution (test q)
Hs.evolves(maximum(Hs.i(-1), psi)), # irreversibility (a named update)
u(*bc) - 0.0,
]).solve() # nothing passed
The running maximum is what makes it irreversible: at zero load the damage is retained rather than
healing. Note the block order is first appearance in the term walk — here dm precedes u, because the
degradation factor is written first — so resolve a block with fem.block_index(dm), never a hardcoded
index.
A form that is linear in every unknown but reads .i(k) marches too — the AT1 damage equation with a
fully lagged driving force is exactly that shape. Each load step is a different linear system whose
coefficients the buffers set, so it routes through the same residual operator as the nonlinear march
(Newton converges in one step on a linear residual). You pay roughly one extra linear solve per step
versus a pure linear assembly, in exchange for one march path rather than two.
The coupled march is differentiable in a material parameter, exactly as the single-field one is: thread a
jno.np.parameter into the form and ∂trajectory/∂θ flows through the whole scan. The same holds for a
coupled steady nonlinear form. What still refuses a runtime parameter is a coupled form that is
linear and carries no history, because that one assembles as a matrix/rhs pair and the coupled linear
assembly has no parametric route; anything on the residual path re-evaluates at the runtime args and is
field-agnostic.
A constraint that jno.fem eliminates rather than assembles — a periodic tie u(A) - u(B), an exact slip
condition n·u = 0, the hanging nodes of a locally refined mesh — is applied at every step: the step solves
Pᵀ r(P ũ) = 0 for the reduced unknowns and the march carries the prolonged u = P ũ, so the history
buffers and .evolves updates see a field that satisfies it. Refused by name with such a constraint:
tau=jno.solve.arclength(...). A .bounds(...) box is imposed on the reduced unknowns; with a periodic
tie it needs the same bound on both tied faces, and beside a weighted elimination (mortar, hanging nodes,
slip) it is refused by name.
Not carried, each rejected with a clear error: a real u.t transient (drive time through tau instead),
a complex form, 1D, non-nodal (Argyris/Morley/edge) elements, and VPINN. A nonlocal jno.Coupling term
composes: it is added to every step's residual (a coupling U -> k M U matches the local k u v to 1e-8,
tied or not, tests/test_fem_coupling_history_march.py), and the step's Newton goes matrix-free, as it
does for any coupling, because the assembled tangent cannot see an opaque function.
A step that did not converge is refused, not carried forward
The march runs its per-step Newton
inside a single lax.scan, and the driver's own convergence check needs a concrete residual — so
inside the scan it disables itself, exactly where the signal matters most. A load path compounds the
loss: a non-converged step becomes the next step's initial state and its history buffers, so one
silent failure contaminates everything after it, and the trajectory still comes back finite and
entirely plausible. Measured on a 3-D Yeoh phase-field march whose undamped Newton overshot into an
inverted element (J = det F ≤ 0, so J**(-2/3) is NaN, which is absorbing): with the grip pinned
to 0.4 the returned displacement read 0.70, with no error raised.
How a failed step is detected out of the scan
So the per-step residual is carried out of the scan and tested where it is concrete, against the
driver's own rtol/atol — the net can only catch what the driver would have caught eagerly, and
never second-guesses a solve configured loosely. Under bounds it scores the min-map, not the bare
residual: on an active constraint that residual is non-zero by construction (it is the multiplier),
and scoring against it would read a correct answer as a divergence. The check costs two residual
evaluations per step — measured at 2.4% (30.30 s → 31.03 s) of an 8-step, 576-DOF Yeoh march. It is
a no-op under jax.grad of a runtime-parametric march, where the norms are themselves traced; there,
as everywhere else in jNO, the solver's iteration cap is all there is.
The error names the fixes in order of what usually works: globalize the per-step solve
(jno.solve.newton(line_search=True) / staggered(line_search=True), or damping<1), take smaller
steps (a finer domain(tau=(...)) grid, or the adaptive path below), or raise max_steps. In the Yeoh
case above, line_search=True alone recovers the exact answer — and note P1 solves the same form
undamped: a higher-order element's full Newton step produces larger gradients at its extra quadrature
points, so P2 is the more exposed one.
Adaptive load stepping
A uniform load grid is wrong in both directions at once: it wastes steps while nothing happens and takes too-large ones through the event. On a path-dependent march that second failure is not merely coarse — a step can converge perfectly and skip the entire transition, leaving a valid sequence of equilibria with no resolved event between them, which is a different answer, not a coarser one.
sol = fem.solve(tau=jno.solve.adaptive(limit=0.05)) # bound every DOF's per-step change
sol = fem.solve(tau=jno.solve.adaptive(limit=[(dm, 0.05)])) # per field — the usual case
The criterion is deliberately not the transient's. A rate-independent load path has no local
truncation error to estimate — each step is an equilibrium, not an approximation to a trajectory — so
the rtol/atol step-doubling estimate that sizes time= measures nothing here. limit bounds how
much the solution may change in one step; a step is rejected (and cut by shrink) when the solve fails
to converge or the change exceeds limit, and a comfortable step grows by grow.
Mechanism — pilot, freeze, replay
Mechanism: pilot → freeze → replay. March eagerly with rejection to discover the schedule, freeze
it, replay it as a fixed-length differentiable scan. Rejection is exactly why the pilot must be
separate: the transient marcher accepts every attempt on purpose, because a discarded state makes the
per-step adjoint run at zero cotangent and returns a NaN gradient. The replay has nothing to reject.
The schedule is piecewise constant in the parameters, so the gradient over a frozen one is the true
derivative almost everywhere — the same contract adapt= makes for a frozen mesh sequence.
The trajectory is resampled back onto the domain's declared tau= grid (as the transient resamples onto
save_ts), so the returned shape does not depend on the steps taken and the resampling error is bounded
by limit itself. fem.tau_schedule reports what the pilot chose.
A parametric form refuses to pilot, by design
The pilot needs concrete values to accept or reject a
step and a differentiable solve hands it tracers; piloting at the parameters' stored values would
silently adapt to whatever they happen to be — 0.0 for a fresh jno.np.parameter, i.e. a load path that
never happened. Discover the schedule forward, then replay it:
fem.solve(tau=jno.solve.adaptive(limit=0.05)) # forward, at the values you want
fem.solve(tau=fem.tau_schedule) # differentiable replay of that schedule
tau=<array> accepts any strictly increasing grid spanning the declared path, so it doubles as the
"non-uniform grid I chose myself" spelling. Not composable with a per-load-step field
(freeze_path(frames)), whose frames are indexed by the declared step count.
Note what adaptivity does not fix. If the step is cut to the floor and the change still exceeds
limit, that is an unstable branch, not a step that is merely too big: under load control a
snap-back has no nearby equilibrium, so no refinement finds one. The error says so and points at
fem.solve(tau=jno.solve.arclength(...)), which advances along the
equilibrium path rather than along the load and so can turn around.
Arc-length exists; it does not yet compose with staggered
It is refused with nonlinear=jno.solve.staggered(...), because freezing the load factor while
sweeping a block is load control — which has no equilibrium past the fold. Since a phase-field
energy needs alternate minimization (see below), this particular study is still without an
instrument. The refusal says so rather than converging to something plausible.
Keep dm in range with dm.bounds(0, 1), which composes with the march.
Note that the bound does not replace the floor eta on the degradation: at dm = 1 exactly,
(1-dm)² makes the displacement block singular, so the floor is a well-posedness requirement in its own
right. And a monolithic Newton is not expected to converge on this energy at all — drive it with
jno.solve.staggered([u, dm]).
Finite strain
Tensor constants broadcast correctly (jno.np.identity(n) carries
a leading batch axis), so F = I + ∇u, E = ½(FᵀF − I), S = λ tr(E) I + 2μ E and the internal virtual
work ∫ (F S):∇δu are written directly — St. Venant-Kirchhoff in five lines, no module:
grad, trace, inner, einsum, I = jno.np.grad, jno.np.trace, jno.np.inner, jno.np.einsum, jno.np.identity(3)
H = lambda w: grad(w, [x, y, z])
F = I + H(u); E = 0.5*(einsum("...ki,...kj->...ij", F, F) - I); S = lam*trace(E)*I + 2*mu*E
mech = inner(einsum("...ij,...jk->...ik", F, S), H(phi), 2) # ∫ (F S):∇δu
jno.fem routes the nonlinear form to Newton (exact 20%-stretch patch test; reduces to linear elasticity
as strain → 0). Combine with the plastic return map for finite-strain plasticity — both are formulas.
Hyperelasticity — the energy is the input
For anything past St. Venant-
Kirchhoff, hand-deriving the stress is where the algebra errors live. diff differentiates a scalar
expression with respect to another expression (the constitutive counterpart of grad, which
differentiates w.r.t. a coordinate), so you write the energy from the paper and get the 1st
Piola-Kirchhoff stress:
det, trace, einsum, jac, I = jno.np.det, jno.np.trace, jno.np.einsum, jno.np.jacobian, jno.np.identity(3)
F = I + jac(u, X) # bind it ONCE, then reuse
I1b = det(F)**(-2/3) * trace(einsum("...ki,...kj->...ij", F, F)) # first isochoric invariant
psi = C10*(I1b - 3) + C20*(I1b - 3)**2 + C30*(I1b - 3)**3 # Yeoh, 3rd order
mech = inner(jno.np.diff(psi, F), jac(phi, X), 2) # P = ∂psi/∂F, then ∫ P:∇δu
Measured: on a Yeoh solid this reproduces the hand-derived S = 2 ∂psi/∂C, P = F S residual
bit-for-bit, and the Neo-Hookean P = μ(F − F⁻ᵀ) + λ ln(J) F⁻ᵀ to 1e-11. The consistent tangent
∂P/∂F comes out of the assembler's own element differentiation — you never write it.
Two scope limits, both fail-loud rather than silent:
- It is pointwise. The derivative is taken independently at each quadrature point, which is what a
constitutive law is; an
Integralinside the target is refused (differentiate the integrand, then integrate). wrtis matched by identity. BindFto a variable and pass that same object. A rebuilt copy (diff(psi, I + jac(u, X))written inline) is a different node, and rather than differentiate to a silent zero it raises.
Any energy-derived law works the same way — Mooney-Rivlin, Ogden, Gent, anisotropic tissue models — as
does a chemical potential mu = diff(f, c) or an electro/magnetostrictive coupling.
Volumetric locking — cellwise, and the one-line B-bar
Everything above has a failure mode that does not announce itself. As nu -> 0.5 a standard
displacement element goes rigid — volumetric locking — and since J2 plastic flow is isochoric,
that is plasticity's default regime, not an edge case. The answer is simply too stiff; nothing warns.
jno.np.cellwise(expr) is the quadrature-weighted mean of an expression over each cell,
(∫_K expr)/(∫_K 1), broadcast back to its quadrature points — the L2 projection onto the piecewise
constants. B-bar is that projection applied to the volumetric strain, and nothing else. Written as a
scalar contraction (the way the elastic and plastic forms above are written, dev(A):dev(B) =
A:B - tr(A)tr(B)/dim, never an identity tensor):
eu, ev = eps(ui), eps(vi)
tru, trv = trace(eu), trace(ev)
cu, cv = cellwise(tru), cellwise(trv) # <- the whole change
dev_uv = inner(eu, ev, 2) - tru * trv / dim # dev(eu) : dev(ev)
mech = lam * cu * cv + 2 * mu * (dev_uv + cu * cv / dim) # sigma(ebar) : ebar(phi)
Delete the two cellwise calls and you have the standard form back, so every J2 return map already
written against an eps alias becomes locking-free by rebinding that one alias.
Refinement does not cure locking — that is what makes it locking
Plane-strain Q1 cantilever under a body load, nu = 0.4999, max |u_y|:
| cells | standard | B-bar |
|---|---|---|
| 16x4 | 2.68e-02 | 3.03e-01 |
| 64x16 | 6.24e-02 | 3.01e-01 |
| 128x32 | 1.30e-01 | 3.02e-01 |
B-bar is converged on the coarsest mesh. The standard element is 11.3x too stiff there and still
2.3x too stiff at 128x32, having spent 64x the cells to get halfway. At nu = 0.3, where there
is nothing to cure, the two agree to 5.6%.
F-bar is the finite-strain counterpart — project the determinant, and put the projection inside the
variable you differentiate so diff still sees a pointwise expression:
F = I + jac(u, X)
Fbar = (cellwise(det(F)) / det(F)) ** (1 / dim) * F
mech = inner(diff(psi(Fbar), Fbar), jac(phi, X), 2)
Three scope limits, all fail-loud except the first, which is a property of the element rather than a restriction:
- It does nothing on P1 triangles/tets. Their strain is already constant over the cell, so the projection is the identity. B-bar is for quadrilateral/hexahedral cells and higher-order simplices; for P1, use Q1/hex cells, raise the order, or write a mixed u-p formulation.
- Native-Lagrange volume terms only. 1-D, non-nodal (Argyris/Morley/RT/N1E), surface terms and VPINN/collocation residuals raise — averaging over those points would not be a per-cell mean.
- Not inside a
difftarget.diffevaluates asgrad(sum(...)), which is the per-point derivative only because the quadrature axis is a batch axis; a projection couples the points and the result would silently be the cell-summed derivative. Writecellwise(diff(psi, F)), or use the F-bar ordering above, where the projection lives insidewrtand never reaches the differentiated expression. The error says both.
Arc-length — passing a limit point
Everything above drives the path by the load. That works until the load-deflection curve turns over: past a limit point there is no equilibrium at a higher load, so no step size finds one, and the adaptive stepper says exactly that rather than grinding to its floor.
fem.solve(tau=jno.solve.arclength(...)) makes the load factor an unknown and constrains the
increment instead (Crisfield, Computers & Structures 13 (1981) 55-62; Non-linear FEA of Solids and
Structures Vol. 1 §9.3.2):
Nothing in the term list changes — the load is still a formula in tau. What changes is that tau is
solved for. This is cheap in jNO because tau is not a DOF: it reaches the residual as a plain
scalar argument, so the border rides a residual wrapper and op.size never moves.
sol = fem.solve(tau=jno.solve.arclength(ds=1e-3))
load = fem.tau_schedule # the load factors reached — NON-monotone across a snap-back
It finds the fold, and goes round it
Bratu's problem -Δu = λ e^u, whose fold is analytic at λ_c = 3.5138307…. Arc-length peaks at
3.51871 (0.14% high, and the error halves under mesh refinement), then the load factor comes
back down to 2.11 while ‖u‖∞ keeps climbing 1.18 → 2.80. The same problem under load control
over the same span does not return a path at all — it fails, which is the honest outcome.
Reading the knobs:
ds— the arc per step. LeftNoneit is calibrated from the declared span, with the consequence that on a linear problem arc-length reproduces the declared uniform grid exactly. With the defaultpsi=0it is a pure displacement measure, i.e. the same quantityadaptive(limit=...)bounds.psi— the weight on the load term;0is Crisfield's cylindrical constraint. Note the deviation: Crisfield weights this by the reference load vectorqᵀq, which jNO does not have (the load is an arbitrary formula intau).psiis a plain weight, and nothing is substituted forqᵀq.domain(tau=(start, end, n))is reinterpreted:endsets the direction and the default arc, not a target. Sonbuys resolution, not reach — to follow the path further, widenend.
Refused by name rather than approximated: nonlinear=jno.solve.staggered(...) (freezing the load
factor is load control), newton(direct=True) (the bordered tangent is not assembled — the system goes
to a matrix-free Newton-Krylov whose jax.linearize builds the border for free), a runtime-parametric
form, and freeze_path(frames). The trajectory is differentiable; fem.tau_schedule is concrete
observability.
The trial may be a network — VPINN and Deep Ritz
Everything above solves for FE coefficients. The same term list also accepts a neural trial: write
the network where the unknown would go and jno.fem detects it, test-projects the weak form onto the
FE basis, and returns a trainable residual instead of an operator. Nothing else about the authoring
changes — same domain, same symbols, same jno.fem([...]).
net = jno.nn(foundax.mlp(2, hidden_dims=32, num_layers=3, activation=jax.nn.tanh, key=key))
ansatz = xi * (1 - xi) * yi * (1 - yi) # hard-BC ansatz: vanishes on the boundary
u_net = net(xi, yi) * ansatz # the trial IS the network
vi = phi.bind(x=xi, y=yi) # the test function is still the FE basis
pde = jno.fem([grad(u_net, xi) * grad(vi, xi) + grad(u_net, yi) * grad(vi, yi) - f * vi,
u(xb, yb) - 0.0]) # declares WHICH test functions vanish
jno.core([pde.mse], domain=dom).solve(2500) # train the weights
This is the Petrov–Galerkin variational PINN of Kharazmi, Zhang & Karniadakis (hp-VPINNs, CMAME
374 (2021) 113547). The Dirichlet term is not optional decoration: it tells jno.fem which test
functions vanish on the boundary, so their irreducible ∂u/∂n flux is masked out of the loss.
Without it the loss minimum is not the PDE solution.
What the network trial actually reaches, against analytic solutions
| problem | rel L2 |
|---|---|
| Poisson, hard-BC ansatz | 4.2e-05 |
| 3-D Poisson on the cube | 4.6e-04 |
| 3-D Neumann flux face | 8.4e-04 |
Neumann flux (u = x) |
6.8e-04 |
cubic nonlinearity (+ u³) |
9.8e-05 |
vector Poisson, u* = (a, 2a) |
1.3e-04 / 2.8e-04 |
| Deep Ritz (energy, Gauss quadrature) | 8.8e-04 |
| network trial + inverse parameter | k: 1.00 → 3.0004 (truth 3.00), field 4.5e-04, data term weighted 100× |
The last row is the combination worth knowing: a network trial and a jno.np.parameter train in
the same loss, so the field and an unknown coefficient are recovered together. Note it needs a
data term — the residual alone is degenerate, since k·a(u,v) = (f,v) with u free is
satisfied by any k with u rescaled.
A source on a vector field — one DSL, either trial
A value-channel coefficient is a scalar per quadrature point, or one vector per point — quadrature first, value axis trailing. A constant carries no quadrature axis at all, so it broadcasts. All of these lower identically, and are accepted by the FEM trial and the network trial alike:
g * vi[0] + (2 * g) * vi[1] # per component
jno.np.inner(jnp.array([1.0, 2.0]), vi, 1) # a CONSTANT vector
jno.np.inner(g * jnp.array([1.0, 2.0]), vi, 1)
jno.np.inner(jno.np.stack([g, 2 * g], axis=-1), vi, 1)
stack defaults to axis=0, which is the wrong axis here
jno.np.stack([f0, f1]) builds a component-first (vec, Nq) array, and the value axis is
trailing throughout the assemblers. Both lowerings refuse it by name and point at axis=-1,
rather than transposing it silently — a silent transpose is a guess at intent, and it would make
one lowering accept a weak form the other rejects.
Two conventions worth stating once, since they are the only places the layout is not inferable:
- a bare
(k,)coefficient withkequal to the quadrature-point count is read as a per-point scalar, that being the ordinary case — so a constant vector of exactly that length needs an explicit quadrature axis,(1 + 0*x) * jnp.array(...); - the value axis is trailing, everywhere, for both trials.
A coupled system, as one vector field
Two separate fem_symbols calls raise. That is a real boundary — the lowering wraps a single primary
unknown — but it is rarely the end of the road, because a coupled system whose fields share a test
space is the same system as one vector field. Inter-field coupling becomes a cross-component term:
u, phi = d.fem_symbols(value_shape=(2,)) # u = (a, b), one field
net = jno.nn(foundax.mlp(2, output_dim=2, ...)) # one network, two outputs
u_net = net(xi, yi) * ansatz
vi = phi.bind(x=xi, y=yi)
# -Δa = fa + b , -Δb = fb
jno.fem([inner(jac(u_net, X), jac(vi, X), 2)
- fa * vi[0] - fb * vi[1] # per-component sources
- u_net[..., 1] * vi[0], # the coupling: b (component 1) enters a's equation
u(xb, yb) - (0.0, 0.0)])
The rewrite is exact on both trials
Solving that identical form with an FEM trial recovers the manufactured (s, 2s) to 9.3e-04 /
9.2e-04, so the vector rewrite of the coupled system is correct. The network trial on the same
form reaches 1.6e-04 on the coupled component and 4.0e-04 on the uncoupled one (h = 0.08,
a 3 × 32 tanh MLP, 4000 Adam steps).
A component is [..., i]; a bare [i] on a network is a point
An earlier version of this example wrote the coupling as u_net[1]. A subscript indexes the
leading axis, as in NumPy, and a network's output is (points, components), so u_net[1] is
point 1 — and the coupled component stalled at 8.0e-02, which this page then blamed on loss
balancing. Write u[..., i] or u.vector[i]: component i on every trial and path (FE trial,
network trial, FDM unknown, PINN), kept as a per-point scalar (N, 1). On an FE symbol a bare
u[i] is also read as the component; on a derived FE expression such as grad(u, X)[0] it is
refused at build time, naming [..., i].
Fields that genuinely need different test spaces — a Taylor–Hood velocity/pressure pair — have no route yet, since the lowering builds one test context.
Deep Ritz — and the quadrature that makes it honest
For an energy-minimising formulation there are no test functions at all: write the functional and minimise it.
energy = (0.5 * (ux**2 + uy**2) - f * uu).integrate(quadrature="gauss")
jno.core([energy], domain=dom).solve(4000)
E & Yu, Commun. Math. Stat. 6(1) (2018). Use quadrature="gauss": the default nodal rule samples
the energy only at mesh vertices, and a network expressive enough to develop structure between them
drives the discrete energy below the true minimum — a variational crime in which the reported loss
keeps falling while the solution degrades.
One DSL, either trial — swept operator by operator
A weak form should mean the same thing whichever trial it carries, so the operators were checked
side by side: the same form built once with d.fem_symbols() and once with a network, comparing
what each lowering accepts. grad·grad, inner(grad, grad), reaction, u³, exp(u),
laplacian(u)·v, a coefficient k(x), a Neumann boundary term, inner(jac, jac, 2),
symgrad : symgrad, div(u)·div(v) and component terms (u[0]·v[0], u[1]·v[0]) all lower on
both.
Two of those did not, until this sweep found them. A constant scalar source — - 1.0 * phi,
the plainest one there is — raised on the network trial, because a constant carries no quadrature
axis and the value channel demanded one per point; every shipped VPINN happens to write a
coordinate source, which does. And div, which has no node of its own (it is
trace(jacobian(phi, X)), the way a book writes it), was not recognised by the channel extractor,
so grad-div stabilisation and an incompressibility penalty assembled on the FEM path only.
div(v) is I : grad(v), so it lowers to the grad channel with the identity as its coefficient.
Checked end to end: a VPINN trained on a grad-div form (γ = 5) matches the FEM solve of the
identical form to 1.3e-02, and on the FEM trial trace(jac(w)) equals the longhand
∂w₀/∂x + ∂w₁/∂y exactly.
One asymmetry is inherent rather than a gap: a bound field view offers ui.x, while a network
expression is not a field view and takes jno.np.grad(u_net, xi). That is the trial object
differing, not the weak-form language.
Boundary conditions, and complex forms
| network trial | |
|---|---|
| Dirichlet, homogeneous | declaration u(region) - 0.0; the network satisfies it through its ansatz |
| Dirichlet, inhomogeneous | refused — put g in the ansatz (g + ansatz * net), see below |
| Neumann flux | a boundary term; its coefficient must carry a coordinate from that region |
Robin a·u·v − g·v |
a boundary term with the network evaluated on the face |
| Mixed (Dirichlet + flux on different regions) | works |
| Vector, all components or one (a roller) | works |
Complex coefficient (a 1j in the form) |
works — the residual stays complex |
fem_symbols(complex=True) |
refused: a ComplexPair is two coupled real fields, i.e. multi-field. Use one field with value_shape=(2,) and a 2-output network for (Re, Im) |
An essential condition DECLARES; it does not impose
For a network trial u(region) - g says which test functions vanish — the value never reaches
the residual. Measured with one fixed network: - 0.0, - 0.5, - 7.0 and - sin(πx) all give a
bit-identical residual. A non-zero value therefore looked like a boundary condition and did
nothing, so it is now refused by name. Put it where the network can satisfy it exactly:
A complex form's .mse is a complex loss
pde.mse on a complex weak form comes back complex128, and minimising a complex number is not
defined. Build a real objective explicitly — (r.real**2 + r.imag**2).mean — the same caveat that
applies to a complex FEM inverse problem.
Scope — refused by name
- Steady only. The lowering test-projects onto a spatial FE basis, so a form carrying the
time coordinate is refused. It used to build and evaluate, and the number meant nothing:
measured on a heat form over
domain(time=(0, 0.1, 5)), the spatial quadrature carried 120 points against the time coordinate's 5, the declared grid never reached the residual (5 steps and 17 gave a bit-identical value), and the initial condition was discarded entirely (u(initial) - 0andu(initial) - 7also bit-identical). Use an FE trial for a transient weak form, or drive a time-dependent network as a collocation PINN throughjno.core, where the residual and the initial condition are both explicit losses. - A boundary coefficient must carry a coordinate. A bound test function keeps its binding on
the view, not in the expression tree, so once the weak form is flattened
-1.0 * v_rightand-1.0 * v_interiorare indistinguishable and both read as volume. Write(g + 0.0 * xr) * v_ragainst that region's own coordinates; a bare constant is refused rather than integrated over the volume, which trains happily and is wrong (measured 3.9e-01 against 6.8e-04 for the same problem). The FEM trial classifies the raw constraint, where the binding survives, and needs no such spelling. - Single field. The lowering wraps one primary unknown, so two separate fields raise — but a coupled system whose fields share a test space is one vector field, and that works. See below.
- No periodic ties. A tie is an algebraic reduction of FE trial DOFs, and a network trial has none. Impose periodicity inside the network instead (a periodic input embedding).