Element families
Non-nodal element families: H(div) and H(curl)
Experimental — the validated scope
Validated on 2-D triangular meshes at lowest order (RT₀ / N1E₀); the API may
still change. Steady-linear, steady-nonlinear (Newton), and transient M u̇ + A u = c (including the
mixed/saddle DAE, e.g. transient Darcy) all work, as does the differentiable fem.solve() inverse
(a scalar or spatially-varying P1 field k(x) in a volume term, steady and transient). Every
edge/cell (RT/N1E/P0) operator — the steady A, and the transient mass M and spatial A (also when
re-assembled per step for a parametric march) — is assembled sparsely, one element at a time (a BCOO,
mirroring the native Lagrange assembler), never a dense global jacfwd; the mixed-DAE initial state is
projected by a matrix-free CG on the (SPD) field mass block. So a 3-D N1E eddy-current / time-domain
Maxwell transient scales instead of hitting the O(n_dof²) dense-assembly wall past ~10⁴ edges.
3-D Nédélec on tetrahedra
3-D Nédélec (H(curl)) is supported on a tetrahedral mesh: the first-kind "N1E" element
assembles the H(curl) mass and curl-curl forms (inner(u, v) + inner(u.vector.curl(x,y,z),
v.vector.curl(x,y,z))) — the correct edge discretisation for Maxwell / eddy currents (nodal
Lagrange gives spurious modes). The covariant push-forward Φ_phys = J^{-T} Φ_ref is dimension-agnostic
and the curl is taken from the physical gradient. The essential PEC wall n×E = 0 is supported,
written u.vector.cross(d.variable(region, normals=True)) (facet-based; it pins every boundary-face edge
DOF of the region). On a tet mesh only N1E is wired — RT / P0 / Hermite / Argyris / Morley remain
2-D-triangle only and raise. On a line mesh "Hermite" selects the 1D cubic beam element
(see above); the other families raise.
One intrinsic order per family — order= is refused
RT₀ / N1E₀ lowest, P0 constant, Morley quadratic,
Hermite cubic, Argyris quintic — set by the element definition, not chosen. order= is a nodal-Lagrange
knob and is refused here rather than ignored: space="N1E", order=2 used to hand back the same
lowest-order space silently (an identical operator), which is the worst failure shape for a wave problem
— you pay for accuracy, get first-order convergence, and only find out from a convergence study that
stalls. Refine the mesh instead (see Mesh resolution for wave problems below), or use a nodal Lagrange
field, where order= does apply.
The guard fires at assembly (jno.fem(...)), not at fem_symbols, which accepts
the argument — so check the error by building the form, not by creating the symbols.
Not yet wired
the rest of the zoo in 3-D (RT / C¹ / plate are 2-D only); the inhomogeneous n×E = g on
N1E; higher order; other families (BDM, second-kind Nédélec, Bell); quad / non-triangular meshes; a
runtime parameter or trainable neural coefficient in a host-assembled RT-pressure / plate
boundary term (the N1E tangential-trace impedance / incident surface BC is differentiable in a
boundary-term parameter and a learned net(x) coefficient); the
constraint-consistent algebraic initial state at t0 in the saddle-DAE transient (only the reported t0
algebraic value is affected).
Beyond nodal Lagrange (P1/P2), jno.fem assembles edge-DOF families on 2-D triangles — for
problems whose natural space is not H¹. Pick one with the space= knob on fem_symbols:
space |
Space | DOF | Use |
|---|---|---|---|
"Lagrange" (default) |
H¹ | nodal value | standard PDEs |
"RT" |
H(div) Raviart–Thomas | edge normal flux ∫ₑ u·n |
mixed Poisson, Darcy, conservation |
"N1E" |
H(curl) Nédélec (1st kind) | edge tangential ∫ₑ u·t |
Maxwell, eddy currents |
"P0" |
L² (piecewise constant) | one per cell | the pressure / multiplier of a mixed pair |
"Hermite" |
C⁰ cubic, vertex value + ∇ DOFs | u, ∂u/∂x, ∂u/∂y at vertices (+ centroid) |
smooth/gradient-aware fields; the foundation for C¹ elements |
"Argyris" |
C¹ quintic (TUBA-6) | value + ∇u + D²u at vertices, ∂u/∂n at edge midpoints |
conforming biharmonic / plate / Cahn–Hilliard |
"Morley" |
non-conforming quadratic (6 DOF) | value at vertices, ∂u/∂n at edge midpoints |
cheap biharmonic / plate — scales to fine meshes |
Hermite — C⁰ value + gradient
Hermite is the first element with a per-cell DOF-mixing transform M(cell) (global derivative
DOFs are the physical gradient ∇u at the vertices). It is C⁰ (not C¹), so it is not a conforming
biharmonic element — it de-risks the M(cell) machinery the C¹ Argyris element reuses. A
value-Dirichlet u(region) - g pins boundary-vertex value DOFs (derivatives free); composes with the
steady / transient / nonlinear paths (tests/test_fem_hermite.py).
How the non-nodal families assemble
These families assemble sparsely. Their linear operator is built element-by-element like every
other family, so peak memory grows as n^1.0 and is set by the operator rather than by an
intermediate. Argyris reaches 22,511 DOFs on an 8 GB card; Morley and Hermite go further still.
The solve stays sparse-direct for these families. Going sparse would otherwise have handed 4th-order biharmonic operators to the Jacobi-preconditioned BiCGStab that serves real elliptic systems, where it does not converge — a solver change disguised as a storage change. Their second-order-in-time path is still dense, as it is for every non-nodal family.
Argyris — C¹-conforming quintic
Argyris is the C¹-conforming quintic triangle (21 DOF) — the element for 4th-order PDEs.
Across a shared edge both u and ∂u/∂n are continuous, so ∫Δu·Δv is a convergent biharmonic
discretisation (the conformity caveat above does not apply). The reference dual basis is mapped to each
physical cell by the affine-equivalence DOF-transform M(cell) (Kirby, SMAI J. Comput. Math. 4, 2018;
element: Argyris–Fried–Scharpf 1968); its globally-oriented edge-normal DOF is what makes it C¹ on an
unstructured mesh. Essential BCs are the two plate traces — u(region) - g (deflection) and
u.dn(region) - h (rotation ∂u/∂n); the boundary curvature ∂²u/∂n² is always left free. These are
wired for axis-aligned boundary edges only; a non-axis-aligned edge is rejected with a clear error
(use Morley there). Composes with steady / transient / nonlinear fem.solve() and inverse (scalar or
P1-field k(x), steady and transient — tests/test_fem_argyris.py, tests/test_fem_inverse.py).
Morley — the cheapest biharmonic element
Morley is the cheapest biharmonic element (6 DOF, quadratic). It is non-conforming — yet
passes the patch test and converges (energy O(h), L² O(h²)). It reuses Argyris's M(cell) transform
and globally-oriented edge-normal DOF but with ~3.5× fewer DOF, so it is much cheaper per node
and scales to finer meshes. Modelling subtlety: because it is non-conforming,
the biharmonic form must be the full-Hessian inner product inner(hessian(u), hessian(v)) (∫D²u:D²v),
not ∫Δu·Δv — the Laplacian form is singular for Morley (xy has Δu = 0 but D²u ≠ 0, a spurious
kernel). Its two plate traces u(region) - g and u.dn(region) - h work on any boundary orientation.
Periodic ties compose too — u(top) - u(bottom) ties the vertex-value and the edge-normal-derivative
DOFs across a matched (conforming) boundary pair, so a y-periodic biharmonic solve recovers a manufactured
sin(πx)sin(2πy) at the optimal L² rate (tests/test_fem_morley.py::test_morley_periodic_biharmonic_convergence);
the reduction requires conforming periodic boundaries (matching vertices/edges) and is Morley-only for now —
the other C¹ families (Argyris, Hermite) raise a clear NotImplementedError
(Morley, Aeronautical Quarterly 19, 1968; tests/test_fem_morley.py).
Plate boundary conditions
Plate boundary conditions. For a 4th-order (plate/biharmonic) field the boundary trace has two
essential parts to pin independently — the deflection u(region) - g and the rotation
u.dn(region) - h — plus two conjugate natural parts (bending moment M_n, effective shear V_n)
that emerge on any trace you don't pin. The classical BCs compose from these:
| BC | Physics | How to write it (on a region) |
|---|---|---|
| Clamped | w=0, ∂w/∂n=0 |
u(reg)-g, u.dn(reg)-0 |
| Simply-supported | w=0, M_n=0 |
u(reg)-g |
| Guided / sliding | ∂w/∂n=0, V_n=0 |
u.dn(reg)-h |
| Free | M_n=0, V_n=0 |
(write neither — natural) |
A free edge gets the natural M_n=V_n=0 from the ν-weighted plate energy
(1-ν)·inner(hessian(u),hessian(v)) + ν·laplacian(u)·laplacian(v) (validated against Timoshenko's
square-plate coefficients). A nonzero prescribed edge moment is the boundary load M_n * phi.dn(region)
(∮_region M_n ∂φ/∂n ds), built from each cell's geometry so it applies on any edge orientation
(though on Argyris the essential pin is still axis-aligned-only). The conjugate shear load V_n is
not yet wired.
u, v = d.fem_symbols(value_shape=(2,), names=("u", "v"), space="RT") # H(div) flux
p, q = d.fem_symbols(names=("p", "q"), space="P0") # piecewise-constant scalar
Vector operators (on a bound vector view): u.div(x, y) is the divergence and u.curl(x, y) the
2-D scalar curl ∂uy/∂x − ∂ux/∂y; in 3-D u.curl(x, y, z) returns the curl vector (used for the
N1E tet curl-curl form). After binding, the no-arg u.div() / u.curl() reuse the bound coordinates.
Essential (edge-trace) BCs — the outward normal is d.variable(region, normals=True, split=True):
| Family | Trace | Term |
|---|---|---|
| RT | normal flux u·n = g |
u(b)[0]*nx + u(b)[1]*ny - g |
| N1E | tangential u×n = g |
u(b)[0]*ny - u(b)[1]*nx - g |
For the RT mixed-Poisson saddle, a Dirichlet condition on the scalar p is natural — add the weak
term p_D * (v[0]*nx + v[1]*ny), no essential constraint on the flux. A BC may target a sub-region
(a shape.rect edge tag or any d.tag(...) boundary subset; sub-region normals are computed from the
geometry).
Tutorials: mixed_poisson_rt_2d.py (H(div)), maxwell_nedelec_2d.py (H(curl): magnetostatics + eddy
current), and maxwell_nedelec_3d.py (the 3-D PEC cube cavity resonator — recovers k²=π²(l²+m²+n²),
spurious-free, converging to 2π² from below).
Quadrilateral and hexahedral (tensor-product) cells
jNO meshes simplices by default — triangles in 2-D, tetrahedra in 3-D. Tensor-product cells are available two ways, and which one you can use is decided by what a mesher can actually produce:
# 2-D quadrilaterals on ARBITRARY geometry, via gmsh recombination
d = jno.shape.disk(0, 0, 1, size=0.1).quad().domain()
u, v = d.fem_symbols(order=2) # Q2 / Q3 work exactly as P2 / P3 do
# structured grids, no mesher involved — a rectangle of quads, a box of hexes
d = jno.shape.rect(0.0, 0.0, 1.0, 1.0).structured(n=40).quad().domain()
d = jno.shape.box(0.0, 0.0, 0.0, 1.0, 1.0, 1.0).structured(n=16).quad().domain()
Nothing else in the term list changes — the weak form, the boundary conditions and the solve are written exactly as they are on a simplex mesh.
Why bother. Linear triangles and tetrahedra are stiff: they lock in near-incompressible elasticity and are poor in bending, which is why mechanics codes are built on hexahedra. A quadrilateral grid is also the mesh the topology-optimisation literature is written on, and for layered geometry (a thin-film stack, a wound magnetic component, a PCB) a structured hex mesh is both better conditioned and far cheaper per node than tetrahedra.
Why 3-D is structured-only. gmsh cannot hexahedral-mesh general geometry. Measured here,
Recombine3DAll on a plain box returns 944 tetrahedra and zero hexahedra; hexes come only from
sweeping/extruding or transfinite meshing. shape.quad() therefore refuses a 3-D shape by name
rather than quietly handing back tetrahedra. 2-D recombination has no such limit — a disk
recombines to pure quadrilaterals just as a rectangle does.
Accuracy. Q1 recovers O(h²), measured on -Δu = 2π²sin(πx)sin(πy):
| mesh | rates per halving | note |
|---|---|---|
| structured quads, Q1 | 1.87, 1.92, 1.96 | same error as the triangulation of the same grid, from half the cells |
| structured hexes, Q1 | 1.79, 1.86 | same error as the tetrahedralisation, from a sixth the cells |
recombined quads (shape.quad()), Q1 |
1.83, 1.91 | ~5× the L2 error of triangles at equal node count on this smooth problem |
| structured quads, Q2 | 3.94, 3.96 | ~8× lower error than P2 triangles at the same node count |
| structured quads, Q3 | 3.87, 3.95 | ~5× lower than P3 triangles at the same node count |
| structured hexes, Q2 | 4.27, 4.29 | ~10× lower than P2 tetrahedra at the same node count |
Order is the lever, not cell shape. At Q1 the two cell types are indistinguishable; raising the order is what moves the rate. A Q{k} quad mesh and a P{k} triangulation of the same grid have exactly the same node count, so those comparisons are DOF-for-DOF rather than favourable. (The rates are nodal RMS on structured meshes, where nodal superconvergence is expected — read them as a comparison between cells, not as the theoretical O(h^{k+1}).)
That last row is worth reading honestly: recombination is not a free accuracy win. It leaves some poorly-shaped cells, and on smooth scalar Poisson the triangulation is more accurate per node. The tensor-product advantage is in bending and near-incompressibility, not here.
The geometry map is formed per quadrature point, because a bilinear quad or trilinear hex has a
Jacobian that varies within the cell even when the cell looks straight-sided — the same machinery
shape.curved() introduced. The quadrature degree is raised by 2 for the same reason it is on curved
cells: the map makes the integrand rational, so no rule is exact.
Scope — what is not supported yet, each refusing by name rather than approximating:
Measured coverage. Every row below was run on a tensor-product mesh and on the simplex mesh of
the same grid, so an API mistake could not be mistaken for a limitation. Working on both quads and
hexes: steady linear, Neumann / surface terms, nonlinear (Newton), vector fields (elasticity),
coupled multifield including Taylor-Hood Q2/Q1, transient marches, runtime parameters differentiated with
jax.grad, u.bounds, attach coefficients, eigensolves, fem.eval readout, and the direct
solver slots. Whole-domain periodic BCs work on both, in every direction — matched faces collapse
onto one DOF, so the periodicity holds exactly rather than to a tolerance.
| not supported on quad/hex | why |
|---|---|
| integrated (mortar) ties across a hexahedral facet | the mortar rows clip triangles and have no quadrilateral analogue. The collocated coupling does support it, and is what a hex tie uses — see below |
h-adaptive remeshing on HEXES (adapt=) |
not for want of plumbing: no general all-hex mesher exists (gmsh's Recombine3DAll on a plain box returns tetrahedra and no hexahedra), so there is nothing to remesh to. Hexes adapt by local refinement with hanging nodes instead — refine_domain, see below |
r-adaptivity (relocate=) on quad/hex |
the monitor, the cell measures and the validity check are all barycentric; a bilinear cell's validity is the sign of the sampled det J, not one determinant |
| 4th-order forms (plates, phase-field) | the physical-Hessian push-forward assumes an affine cell — the same refusal curved simplices already carry |
| non-nodal families (N1E, RT, Argyris, Morley) | Argyris and Morley are defined on triangles; the quad analogues (RTCF/NCE, Bogner–Fox–Schmit) are different elements |
shape.quad().curved() |
a curved quadrilateral is a 9-node block the emitter does not produce |
The recovery error estimator runs on both cell families. On a simplex the P1 gradient is constant
per cell, so inverting the edge matrix is the answer; on a bilinear cell the gradient varies, and the
sample is taken at the cell centroid — the superconvergent (Barlow) point of a Q1 gradient, where
it is O(h²) accurate against O(h) elsewhere, which is the property Zienkiewicz–Zhu recovery needs
of its samples. Measured as the effectivity index eta / true error on -Δu = f:
| n | quads | triangles |
|---|---|---|
| 8 | 1.301 | 1.018 |
| 16 | 1.147 | 1.012 |
| 32 | 1.069 | 1.006 |
Both asymptotically exact. The indicator integrates the gradient gap over the cell with the element's
own quadrature rather than sampling it at the centre: on a quad both the recovered field and ∇u_h
are superconvergent at the centre, so a centroid rule compares two good approximations and misses the
error, and its effectivity decayed (0.81 → 0.53 → 0.35 over the same meshes) while looking
healthy. That fix improved the simplex path too, from ~0.77 to ~1.006.
Reference: Zienkiewicz & Zhu, IJNME 33 (1992) 1331–1364; Barlow, IJNME 10 (1976) 243–251.
Refining on your own criterion. adapt= marks on the Zienkiewicz–Zhu recovery estimator by
default. Production AMR codes mark on physical quantities instead — a density gradient, an
interface, vorticity — and criterion= takes any traced expression, with no test function (a
criterion is a field, not an equation):
ui = u.bind(x=xi, y=yi)
remesh(criterion=jno.np.sqrt(ui.x**2 + ui.y**2)) # gradient / shock detector
remesh(criterion=phi * (1.0 - phi)) # phase-field interface
remesh(criterion=jno.np.abs(uy.x - ux.y)) # 2-D vorticity
d.attach("weld", refine=1.0).attach("plate", refine=0.0)
remesh(criterion=d.refine) # refine one material
How the residual estimator is formed
It is assembled against the problem's own test function, normalised by the lumped mass to a nodal
field — so the scale is the criterion, not criterion x cell volume, and theta means the same thing
on a coarse and a fine mesh — then integrated per cell and marked as usual. Everything else on the
spec is unchanged, so a criterion composes with theta, refine_factor, max_dofs and the quad
rebuild path below. Measured on a thin diagonal ridge: cells on the feature come out 1.85x smaller
than off it on triangles and 1.92x on quads.
A Löhner-style detector |D²u|/|Du| needs order >= 2 — a P1 Hessian is identically zero, so at
order 1 it would evaluate to nothing.
h-adaptivity on quadrilaterals works, by a different mechanism than on simplices. mmg adapts
triangles and tets by local edge split/collapse/swap and has no quad analogue — but a quad mesh in
jNO is a triangulation gmsh recombined, and the size field driving it is already part of the
shape plan. So the remesh stage rebuilds the plan at the marked size field:
d = jno.shape.polygon(L_SHAPE, size=0.12).quad().domain()
u = fem.solve(adapt=jno.solve.remesh(theta=0.6, max_iters=4)) # refines at the corner, stays all-quad
Measured on the L-shape's re-entrant corner, against uniform refinement at matched DOFs: adapting is 1.23× more accurate on quads and 1.31× on triangles (Ritz energy against a fine reference). The refined cells go where they should — mean cell size near the corner is 1.5× smaller than far from it, against 1.19× for the triangle control.
Three things it is not. It is a global remesh, so the mesh does not nest and each round costs a
full gmsh rebuild. It needs a geometry to rebuild from, so a quad mesh loaded from a .msh file
refuses by name. And it cannot refine a .structured() plan, whose resolution is its cell counts
rather than a size field — that would silently return the same mesh, so it refuses too. Recombination
purity is checked on every round rather than assumed.
Tying two hexahedral domains across a non-matching interface works. A tie constrains each secondary node to a weighted combination of the main face's nodes, and those weights were barycentric (triangle) shape functions — which a hexahedron's quadrilateral facet has none of, so the tie refused rather than interpolate a quad from three of its four nodes.
A quad facet's map is bilinear and has no closed-form inverse, so its reference coordinates come
from a Newton inversion (fem_lagrange._invert_tensor_map, the same inverse the quad solution
transfer uses). Measured on two hex blocks meeting at different resolutions (16 facets against 9), the
linear patch test passes to 5.6e-16 — solved directly, because the default iterative solver stops
at 4.1e-04 in float32 and would hide a constraint error of that size.
The integrated (mortar) coupling still refuses on a quad facet: its rows clip triangles. Collocated weights are linearly complete and are what a hex tie — and a hanging node — actually needs.
Local refinement with hanging nodes (quadrilaterals and hexahedra)
adapt=jno.solve.remesh() refines a quad mesh by rebuilding its shape plan at a finer size field,
which is global and needs a geometry to rebuild from. Splitting marked cells into four needs neither —
it works on a mesh loaded from a file, every old node survives, and it is the mechanism 3-D hexes will
use, since no all-hex mesher exists to remesh to.
The price is conformity: a split cell's edge midpoint is not a vertex of its unrefined neighbour, so that node's value is not free. It is a hanging node, constrained to the coarse edge it lies on:
u = fem.solve(adapt=jno.solve.refine(theta=0.4, max_iters=4)) # ZZ-driven, like remesh()
u = fem.solve(adapt=jno.solve.refine(criterion=jno.np.abs(ui.x))) # or on a traced criterion
Why refine is separate from remesh
refine sits beside remesh rather than being a flag on it, because it is a different algorithm:
remesh re-runs the mesher at a finer size field (needs a geometry to rebuild from, and the new mesh
does not nest inside the old), while refine splits marked cells (local, keeps every node and its
value, works on a mesh loaded from a file). Marking is shared — theta, criterion, max_iters,
max_dofs, tol, eps all mean the same thing. There is no refine_factor (a split halves the cell
by construction) and no anisotropic (a split is isotropic, so there is no direction to stretch along;
that needs a simplex mesh).
To refine a specific set of cells outside the loop, call refine_domain(domain, cell_ids) from
jno.utils.solver.fem_refine — the loop is a marking strategy on top of it.
u_hanging = Σᵢ wᵢ u_parentᵢ is the same relation a periodic tie and a mortar coupling impose, so it
rides the same prolongation (prolongation_from_ties) and reaches reduce_matrix_periodic and the
B(P) block fusion with no path of its own — the reason this is a constraint layer and not a second
constraint mechanism. deal.II, MFEM and p4est all take this route.
On -Δu = 1 over the unit square (exact centre 0.073671, Timoshenko & Woinowsky-Krieger, Theory of
Plates and Shells, 2nd ed. 1959, Art. 30), refining the middle four cells of a 4×4 grid gives
0.075063 against 0.077679 for that grid and 0.074598 for a uniform 8×8. Four rounds into the
centre converge monotonically to 3.3e-05. Dropping only the constraint on the same mesh gives
0.090093 — 11.9× worse, and worse than the coarse grid it was refined from: without the tie the
refinement is not merely wasted but harmful.
Two things in jNO assumed conformity and had to change with it, both silent when wrong:
- The boundary. jNO derives it topologically — a facet belonging to exactly one cell — and across
a 2:1 interface the coarse edge and both half-edges each belong to one cell. Left alone, the
interface reads as boundary and a Dirichlet condition pins it in the middle of the domain:
measured, 32 identity rows on a mesh whose perimeter is 16 nodes, and a centre value of
0.0194. - Refining an already-refined mesh must reuse the hanging node at an edge midpoint rather than make a second node there. Edge topology cannot see it (once non-conforming, the coarse edge and the two half-edges are different edges), and the duplicate splits the mesh along the interface while the area, the winding and the 2:1 balance all still check out.
Hexahedra
The same call refines a hex mesh, splitting each marked cell into 8. This is the only h-adaptivity a hex mesh has, for the reason in the scope table above: there is no all-hex mesher to remesh to.
d = jno.shape.box(0, 0, 0, 1, 1, 1).structured(n=4).quad().domain() # .quad() on a 3-D lattice = hexes
...
u = fem.solve(adapt=jno.solve.refine(theta=0.4, max_iters=3)) # the same slot, in 3-D
3-D adds a second kind of constrained node. A 2:1 face interface leaves the coarse face's four edge midpoints hanging on 2 parents each and its centre hanging on all 4 corners at ¼ each — 18 constrained nodes around a single refined interior hex (12 + 6). A hex face's map is bilinear, but its centre is the parameter point (½, ½) where the Q1 weights are exactly ¼ however the face is warped, so no Newton inversion is needed here (unlike the general non-matching tie above).
On -Δu = 1 over the unit cube against a uniform 16³ reference:
| mesh | nodes | error |
|---|---|---|
| uniform 4³ | 125 | 6.0e-03 |
| uniform 8³ | 729 | 1.1e-03 |
| 4³ + 2 rounds into the centre | 1277 | 2.7e-04 |
A quarter of the uniform mesh's error for 1.75× its nodes. The constraints are satisfied exactly (the DOFs are eliminated), and no hanging node acquires a hanging parent across the rounds.
The subtlety 3-D adds is in the boundary: a refined face on the domain surface splits into four sub-faces that are still boundary, and whose corners include genuinely hanging edge midpoints (their edges are shared with unrefined neighbours). Only a node with as many parents as the facet has vertices — a face centre — proves a facet was covered. Treating every hanging node as proof of interiority deleted 9 faces of the cube's own surface: 96 where the answer is 105.
Through the slot, on the same 3-D problem: 125 → 216 nodes over three rounds with the ZZ estimate
falling 5.2e-03 → 4.3e-03 → 3.6e-03, and both hanging kinds present throughout with nothing chained.
Before this, a hexahedral mesh had no h-adaptive path at all.
What it composes with, measured. Scalar and vector fields (value_shape=(d,), constrained
component by component), coupled multifield and complex problems (one prolongation block per
field — a complex field is two coupled real fields, and both halves are constrained), 2-D and 3-D, and
any Lagrange order on quadrilaterals (verified to order 3). Higher order needs the coarse edge's
higher-degree basis, and it changes which DOFs hang, not just their weights: jNO shares one DOF per coordinate, so the fine vertex at a coarse
edge's midpoint is that edge's own order-2 DOF — free, not hanging — while the DOFs that do hang sit
at the quarter points with weights (0.375, −0.125, 0.75). Those negative weights are real; a quadratic
through three points is not a convex combination. On -Δu = 1, refining at order 2 gives 1.16e-05
against 1.98e-05 for order 2 on the mesh it was refined from.
Element families. Lagrange only. Simplex-only families — the C¹ spaces (Argyris, Morley, Hermite) and the H(div)/H(curl) ones (RT, N1curl) — are unreachable here rather than refused: they are defined on triangles in jNO, and a triangular mesh cannot be locally refined at all (see below), so the combination cannot arise. Asking for one on a quadrilateral mesh fails the same way with or without refinement.
Limitations, measured. Order 2 and above on hexahedra is refused: a hex's 2:1 interface also constrains
DOFs lying on a face, needing that face's 9-node basis rather than the edge basis. A hanging node on
a tied or periodic interface is refused — that composes two prolongations and their order changes the
answer; it is reachable in 3-D only (along a 2-D boundary an edge belongs to one cell, so refining it
makes its midpoint a real vertex and nothing hangs there). Steady problems only — the transient
driver carries state across each mesh change, and that transfer does not apply the hanging constraint,
so it would be violated on the first step after a split. Simplex meshes refuse by name and should use
remesh. Geometry is preserved exactly only for affine cells — a warped hexahedron's faces are
non-planar, so a 0.06 warp on a 0.25 cell moves the total volume by 3.9e-04, shrinking as the mesh
refines (the usual O(h²) straight-edge error); the constraint weights stay exact regardless. The split
is a Python loop over marked cells (27 lattice points each in 3-D), which suits the cell counts
adaptivity produces and is not tuned for refining a whole large mesh at once.
Volume terms, Dirichlet conditions and surface terms all work on both cells — Neumann, Robin and flux integrals included.
How a named subset of a hex boundary is resolved
A named subset of a hexahedral boundary is resolved by facet identity, not by its nodes. The
distinction is only visible when a tag omits a facet whose corners it keeps: a physical group naming
8 of the 9 facets of a face shares every corner of the ninth with its neighbours, and the all-nodes
mask puts that facet back. Measured as an applied load, that is 1.000 where the truth is 8/9.
The same applies to region.contains(p) — a point-in-region query anywhere between nodes — which
tests the quadrilateral directly rather than a triangulation of it.
What hexahedra needed that quadrilaterals did not
Hexahedra took extra machinery to get there, and it is worth knowing why. A quadrilateral's facet is
a straight edge (restricted to one edge a bilinear map is linear), so a single normal per facet is
exact. A hexahedron's facet is a bilinear surface: its normal turns across the facet, and its
area element varies with it. Both are therefore formed at each quadrature point by Nanson's formula,
from the same physical tangents the area element already needed. The check is the divergence
theorem, ∮ x·n dS = 3·Vol, which is sensitive to the normal's direction and orientation at every
point: it holds to machine precision (≈3e-16) on deliberately warped meshes with non-planar
faces, and a single per-facet normal cannot pass it there.
Higher order on independently meshed bodies
shape.regions(..., conforming=False) meshes each body on its own, so a shared face carries two
coincident node sets — that duplication is the point, and it is what a contact gap opens. Promoting
such a mesh to P2/P3 has to synthesise new nodes on that face, and the question is when two of them
are the same node.
Keying on the physical coordinate is the right conformity test for one body and the wrong one for two: every synthesised node at a coincident interface was merged, welding the bodies (measured on a two-body bar: 37 nodes referenced by cells of both). It is now keyed on the topological entity — the P1 vertices spanned by a reference point's non-zero weights — which separates entities that merely coincide in space while still collapsing one that is genuinely shared.
The practical consequence is that mixed-order spaces work across independently meshed bodies:
Taylor–Hood (order=2 velocity with order=1 pressure) on a conforming=False domain, which
previously had to be refused.
Two non-conformities want opposite answers here, so one combination is still refused by name: a mesh
that is both locally refined (hanging nodes) and carries a conforming=False interface. A 2:1
refinement needs its coincident nodes merged — the coarse edge's node is the fine edges' shared
vertex, and merging is what keeps the constrained refinement continuous — while two bodies need theirs
kept apart. One mesh cannot have both; use order=1, or drop one of the two.
Mesh resolution for wave problems
Because N1E is lowest order only (order= is refused, above), the mesh is your only accuracy
knob on a wave problem — so it is worth knowing what a given resolution buys. Measured on the PEC cube
cavity, whose lowest mode k² = 2π² is analytic, with h expressed as points per wavelength
(λ = 2π/k = √2 on the unit cube, ppw = λ/h):
h |
ppw | DOFs | mean lowest triplet | rel. error |
|---|---|---|---|---|
| 0.50 | 2.8 | 53 | 14.52 | 26% |
| 0.40 | 3.5 | 121 | 18.67 | 5.4% |
| 0.30 | 4.7 | 217 | 19.48 | 1.3% |
| 0.25 | 5.7 | 276 | 19.07 | 3.4% |
| 0.20 | 7.1 | 532 | 19.27 | 2.4% |
| 0.16 | 8.8 | 1083 | 19.46 | 1.4% |
The fitted rate is 2.1 in h, consistent with the theoretical O(h²) for eigenvalues with
lowest-order edge elements (the field itself converges at O(h)). Extrapolating the fit: ~9 ppw for
1% and ~28 ppw for 0.1% eigenvalue error.
Read those numbers with two caveats, both real:
- The sequence is not monotone (0.25 is worse than 0.30). gmsh meshes at different
hare unstructured and non-nested, and the mode is 3-fold degenerate, so the triplet mean wobbles by a percent or so between meshes. Treat the table as a trend, not a per-hguarantee — and do your own convergence check on your own geometry rather than reading a single number off a single mesh. - A cavity eigenvalue is the friendly case. A driven problem at high frequency additionally
suffers the pollution effect: holding
ppwfixed as the domain grows in wavelengths does not hold the error fixed — it degrades withk(kh)^{2p}(Babuška & Sauter, Is the pollution effect of the FEM avoidable for the Helmholtz equation considering high wave numbers?, SIAM J. Numer. Anal. 34(6), 2392–2423, 1997). So an electrically large problem needs more points per wavelength than a small one, and lowest order is where that bites hardest. If you need many wavelengths across the domain, budget accordingly.
Interpolation covers — the missing p (space="cover")
jNO adapts a mesh three ways: remesh rebuilds it finer, refine splits its cells, relocate
moves its nodes. All three change the geometry. The fourth — raise the polynomial order and leave
the mesh alone — needs an element that can carry a variable order, and that is what this is.
Each node carries, besides its value, a first-order polynomial cover multiplied by its own hat (Kim & Bathe, Comput. Struct. 115 (2013) 1–11; used for deformable-mesh topology optimisation by Jung, Yun & Kim, Comput. Struct. 331 (2026) 108403):
with s_i a nodal length scale. Declared on the symbol, and nothing else in the form changes:
u, phi = d.fem_symbols(space="cover") # 1 + dim DOFs per node, at the P1 nodes
fem = jno.fem([inner(grad(u, X), grad(phi, X), 1) - f * phi, u(cw[0], cw[1]) - 0.0])
Because the enrichment rides the partition of unity, an enriched node beside an unenriched one already blends correctly. That is the property the whole feature rests on: switching a node's order needs no constraint equations and no edge-mode matching, which is what a hierarchical p-basis has to do wherever the order changes.
Accuracy, measured on -Δu = f with u = sin(πx)sin(πy), L² error against actual mesh spacing
(the P1 control was measured in the same harness — fitting against the nominal size= instead put
even P1 at 1.59, so the control is what makes the band meaningful):
| space | fitted order | error at h = 0.103 |
|---|---|---|
| P1 | 2.00 | 8.70e-3 |
| cover, all covers pinned at the wall | 1.86 | 6.94e-3 |
| cover, tangential-only pinning | 3.13 | 2.31e-4 |
The middle row is the one worth keeping: pinning all covers at a Dirichlet node collapses the
boundary layer to P1, and a layer of width h caps the global rate below P1's own. On a straight
facet through node i, (x − x_i) is tangential, so only the tangential cover components enter the
trace — the normal one is the ∂u/∂n freedom and pinning it was pure loss. A cover slot is freed
exactly when its axis is orthogonal to every region tangent at the node; an oblique facet frees
nothing, which over-constrains but never violates the condition.
Against P2, honestly. On straight-sided simplices the enriched span is elementwise P2 — the element reproduces every global quadratic exactly (tested), so the same order and the same errors are the expected result, not a disappointment. What differs is the count and the composition:
- Fewer DOFs for that span.
(1+dim)·n_vagainst P2'sn_v + n_e— about3n_vvs4n_vin 2-D and4n_vvs8n_vin 3-D, since a triangulation has ~3 edges per vertex and a tetrahedral mesh ~7. - The DOFs ride the P1 nodes, which are the nodes
.trainable()moves. P2's edge midpoints are not mesh vertices, so a moving-mesh problem has to manage them; a cover field has nothing extra to move. - Selective enrichment has no P2 analogue — see
jno.solve.enrichbelow.
One expectation that did not materialise: extra robustness to element distortion over P2. Against P1 yes; against P2 the two behave the same on affine simplices. The enriched basis contains the exact quadratic on any straight-sided cell, so what distortion costs is conditioning, not consistency.
Differentiable, including with respect to the node positions — the combination a deformable-mesh
optimisation needs. One caveat, stated because it is invisible otherwise: the nodal length scale s_i
is computed on the host at build time (like the facet orientation signs) and does not track moving
coordinates. That is a conditioning drift, not a wrong gradient.
It marches in time, and the initial condition is the part that had to be got right. A cover node
carries its value and the coefficients of (x − x_i)/s_i, so an IC interpolated onto "the nodes"
must fill the latter with a_i = ½∇g(x_i)·s_i — writing g(x_i) into every slot of the node hands
the cover coefficients a function value where a scaled gradient belongs. That is not a mild
inconsistency: the enriched DOFs are stiff (the generalized spectrum splits into a handful of
value-dominated modes at λ ≈ 2–5 and dozens of cover-dominated ones reaching λ ≈ 185), so a
θ-stepper damps the bad start by 1/(1+Δt·λ) and annihilates it in a single step rather than
relaxing it — measured, the cover coefficients collapsed to 0.17 of their initial size while the node
values correctly reached 0.86. With the enriched interpolation in place they decay together, and on
the analytic heat benchmark at a converged time step the enriched space is 49.8× more accurate than
P1 in L2 at the same mesh. Note the corollary for benchmarking: at a coarse Δt, P1 can look
better than it is through cancellation between its temporal and spatial errors — refine Δt before
comparing spaces.
The null space is structural, and it is gauged. Since Σ_i h_i (x − x_i) ≡ 0, the coefficients
a_i = S·x_i + c with S skew are the zero function — dim(dim+1)/2 modes per component,
mesh-independent. Whatever the boundary pins leave alive is removed exactly like a pressure gauge: one
extra cover DOF per surviving mode, chosen by Gaussian pivoting. Every member of the solution family
is the same physical field, so the gauge moves nothing.
p-adaptivity — fem.solve(adapt=jno.solve.enrich(...))
The loop that puts the order where it is needed. Same round as the h-adaptive one — solve, estimate, mark, rebuild — except that marking is per node and the rebuild changes the space rather than the geometry:
u, phi = d.fem_symbols(space="cover")
ui = u.bind(x=xi, y=yi)
fem = jno.fem([...])
u = fem.solve(solve_fn, adapt=jno.solve.enrich(
criterion=jno.np.sqrt(ui.x**2 + ui.y**2), theta=0.5, max_iters=6))
theta, criterion, max_iters and max_dofs mean what they do for remesh — a criterion is the
same traced, test-function-free field, and metric_field picks which field of a coupled problem
drives the marking. A criterion is required and tol/eps are refused, both for the same reason: nothing
available here is an error estimate. A recovery estimator reconstructs its gradient from the vertex
values, so it is blind to the cover coefficients enrichment adds — measured, it ROSE from 1.3353e-01
to 1.3377e-01 across eight rounds while the true L2 error fell 4.692e-03 → 2.677e-03. A hierarchical
residual is honest (it vanishes where enrichment is already on) but marked no better and left a sharp
spike untouched. A hand-written sqrt(ui.x**2 + ui.y**2) beat both by ~2× per DOF — so the loop asks
for the criterion rather than guessing one, and bounds itself with max_iters/max_dofs. start= pre-enriches a fraction before the first solve (default
0, i.e. begin at plain P1).
Three things follow from changing the space instead of the mesh, and each shows up in the API:
- Marking is over the unenriched nodes. Splitting a cell drops its indicator, so an h-loop can
re-rank the whole field each round; enriching a node does not move a geometric criterion at all, so
ranking globally would re-mark the same top nodes forever and the loop would stop after one round.
Each round asks which of the remaining nodes carry
thetaof the error still on the table. n_dofsinfem.adapt_historyis the ACTIVE count, not the total. The padded layout gives every node its cover slots and an unenriched node simply has them pinned, so the total never changes;max_dofsbudgets against the free count, which is what the eliminated system actually costs. The history also recordsn_enrichedand the per-nodeenrichedmask, so a run's order distribution is a fact you can plot rather than infer.- There is no field transfer between rounds. The mesh, the DOF nodes and the connectivity are identical throughout — only how many coefficients each node carries changes.
The adaptivity tutorial compares h, r and p on
the same problem. Compose with refine/remesh across successive fem.solve calls for hp: h where the solution is
rough, p where it is smooth.
Scope, refused by name rather than discovered: the field must be declared space="cover" (there is
nothing to switch on in a Lagrange field); first-order covers only (order= is refused — the cover
supplies the extra order); simplices only, since the cover gradient assumes an affine map; and the loop
is steady — enriching mid-march would change the DOF layout under the stepper, and the state
transfer that carries a solution across an adapt round is written for a change of mesh, not of space.
An inhomogeneous Dirichlet trace stays the P1 interpolant of g (the tangential covers pin to zero,
not to dg/ds), which is a real accuracy limit on a curved or non-constant boundary value.