Limits, build time, and worked examples
Known limitations
Almost every boundary below is an explicit, fail-loud NotImplementedError, raised when you
assemble a weak form or solve a transient problem through the time route. The residual-PINN
path is unaffected.
Two limits are silent — every other one raises
The assembler cannot distinguish these from a legitimate modelling choice, so nothing stops you.
- Affine geometry on a curved boundary — by default the mesh is straight-sided, so the
domain is approximated to O(h²), which caps every element order above it. The solve is simply
suboptimal.
shape.curved()fixes it (order 2, simplices) — see Curved geometry. - The
2πrmeasure on an axisymmetric vector form — exact for scalars, wrong for vectors. - Host geometry inside a
jno.derivedrule on a moving mesh — ray tables, view factors and neighbour lists are built once, outside the rule, and closed over. They are correct for the mesh they were built on and are never rebuilt; on a mesh that moves or is remeshed they silently describe the old one. Rebuild them and build a newjno.fem.
All three are detailed below, and stated again at the point where you make the choice.
| Area | The limit | How it fails |
|---|---|---|
| Transient mass, non-nodal | a trainable network coefficient on u_t * phi — the mass block is assembled once. A scalar parameter, a coordinate function, or a net on a nodal form are all fine |
raises |
| Temporal order inside a group | a group nested in a product CHAIN (rho * c(T) * (T_t + u.grad T) * phi) or handed to an OPERATOR (inner(u_t + K*u, v)) stays atomic, and its spatial part is stripped into the mass matrix |
raises |
| A march that never solves | an ABSOLUTE atol above the form's own residual scale passes Newton's first test at the incoming iterate, so every step returns its input |
raises (the residual is bit-identical before and after) |
d.tag(name, pred) over a geometry face |
the predicate filters point sampling only; facets still map through the geometry, so naming a DIFFERENT face splits the region | warns |
| Second order in time | nodal Lagrange only, 1-D/2-D/3-D, scalar or vector; the temporal side must stay linear | raises |
Reduced-order basis= |
steady + first-order transient only | raises |
| Runtime Dirichlet parameters | steady linear, steady nonlinear, linear transient | raises |
Time-varying Dirichlet g(x, t) |
first-order transients, linear or nonlinear, single-field or coupled, a trainable parameter inside the value included (differentiable); not a trainable net inside the value, nor a net/parameter-valued Dirichlet beside it, not a parameter inside it on a u_tt form or the τ load path, not a nonlinear u_tt form. sdirk(3) and ros2 lose some order to it, and ros2 holds the wall value only to its order (measured below) |
raises |
Slip n·u = 0 on a surface moved by .trainable() coordinates |
steady nonlinear only (its P is rebuilt per solve there) |
raises |
| Affine parameter lowering | one trainable scalar per additive term, not nested | raises |
| Enclosure radiation | 2-D / axisymmetric, needs a direct solve; you write the radiosity yourself | manual composition |
jno.derived nonlocal fields |
nodal Lagrange on= only; no chained derived fields; host geometry closed over by the rule is frozen at build, so it goes stale on a moving mesh |
raises, except the moving mesh — silent |
| Plasticity | small-strain, isotropic, linear-hardening | raises |
symmetric=True matrix fields |
native 2-D/3-D Lagrange only — not 1-D, VPINN, periodic ties, complex, or mixed with a non-nodal family; a time-varying whole-tensor Dirichlet value is not mapped to the stored values (write it per entry); an asymmetric whole-tensor value is refused | raises |
Constant unknowns (constant=True) |
native 2-D/3-D Lagrange only; a tie u(region) - U on steady linear/nonlinear forms only, and not together with a periodic tie, a moving slip surface, a node that both slips and is tied in the same component, or a hanging-node mesh; U - g takes a value that does not depend on position; jno.fdm has no constants |
raises |
d.unknown(...) in jno.fdm |
a real P1 nodal unknown with full storage; order=2, a non-nodal space=, complex=True, symmetric=True or constant=True make it FEM-only |
raises |
| Adapting a moving mesh | relocate fails on two separate bodies (use alpha); enrich() (p) is not wired; checkpoint= on a moving mesh needs adapt=, and a checkpointed march cannot be differentiated; runtime connectivity is affine-simplex only |
raises |
Interpolation covers (space="cover") |
first order, simplices only; the layout is padded so memory scales by 1+dim even where enrichment is off; jno.solve.enrich is steady-only; a u.gap contact search may not read a cover field |
raises |
FEM functionals (fem.eval(F, u) with a test-free F, expr.integrate(fem)) |
steady, native Lagrange; the whole volume or a tagged boundary region — not a sub-region; not transient, complex, 1-D, non-nodal or VPINN (use sum(fem.eval(F * phi, u)) there) |
raises |
| VPINN (network trial) | steady only, single field (scalar or vector), no periodic ties; a boundary coefficient must carry a coordinate | raises |
dom.cell_size / dom.cell_metric |
native 2-D/3-D volume terms only — a 1-D form or a non-nodal family packs no element Jacobian | raises |
| Element order on RT / N1E / P0 / Hermite / Argyris / Morley | each family has one intrinsic order | raises |
eigs on a non-symmetric pencil |
eigenvalues differentiate, eigenvectors do not | NaN, not a silent zero |
| Curved-boundary geometry | straight-sided by default; shape.curved() is the fix |
silent |
| Axisymmetric vector forms | the 2πr measure is wrong for vectors |
silent |
The detail
Second order in time (u_tt) — what composes and what refuses
A nonlinear spatial operator (sine-Gordon, cubic Klein–Gordon, large-deformation
elastodynamics) is supported — Newton on the augmented [u; v] block. The temporal side
must stay linear: a state-dependent mass or damping c(u)·u_tt is refused, since M2/C are
extracted by differentiating at u=0 and would otherwise be frozen there.
A coupled 2-D/3-D system flows through the same assembler as a single field, so damping,
the nonlinear path and a driven boundary g(x,t) all compose with coupling. A coupled form
still refuses:
- a field with no
u_ttterm — its velocity rows would be singular; - runtime parameters — the parametric coupled steady assembly underneath is not wired;
- periodic ties.
Time-varying Dirichlet is refused on nonlinear u_tt forms (a first-order nonlinear transient
takes it). A nonlocal jno.Coupling is refused on any u_tt form, and on a single-field transient
with a periodic tie; both routes used to drop it silently. A complex coefficient on any u_tt form is refused by name — it used to be
silently cast to real. Write the problem first-order in time instead; the complex transient is
supported.
A coupled 1-D system carries u_tt on narrower terms (linear, undamped): the augmented state is
[u_all; v_all], so fem.offsets lists the displacement blocks then the velocity blocks.
A trainable net on a non-nodal transient mass — measured boundary
The mass block of a non-nodal transient form is assembled once, so a coefficient that trains cannot enter it. The limit is narrower than it sounds — this is the whole map, measured:
| element | c in c * ui.t * vi |
|
|---|---|---|
| P1 nodal | constant, jno.np.parameter, 1 + xi, trainable net |
all solve |
| Hermite / non-nodal | constant, jno.np.parameter, 1 + xi |
all solve |
| Hermite / non-nodal | trainable net | raises |
u, phi = d.fem_symbols(space="Hermite")
rho = 1.0 + jno.nn(net)(xi, yi) # trainable, spatially varying
jno.fem([rho * ui.t * vi + (ui.x * vi.x + ui.y * vi.y), ...])
# NotImplementedError: jno.fem (non-nodal): a trainable neural coefficient on the mass (u_t)
# term is not supported -- the mass block is assembled once. Use it on spatial terms, or
# .freeze() the network.
jno.fem([ui.t * vi + rho * (ui.x * vi.x + ui.y * vi.y), ...]) # instead: on the stiffness
Reduced-order solves — what basis= covers
Steady and first-order transient, linear and nonlinear. Second-order-in-time (u_tt),
complex, and periodic-tied problems each refuse with their own reason. Nonlinear reduces, but
without hyper-reduction that is a memory win, not a speed one.
Runtime Dirichlet parameters — where the gradient flows
A trainable jno.np.parameter may sit in an essential value (u(top) - g, or scaling a
coordinate profile u(top) - g*sin(pi*x)), and the boundary value is recovered from data like
any other parameter — ∂b/∂g flows through the symmetric elimination (linear), and ∂/∂g
through the solve's / each step's custom_root (nonlinear / transient).
A value that is both parametric and t/τ-dependent is held at each step's t/τ WITH the runtime
parameters, on a first-order transient and on a τ load-path march (u(top)[1] - g*tau,
displacement control with a trainable grip). Refused loudly on a second-order (u.tt) form, whose
block evaluates the value and its rate without them: train that amplitude through a Neumann / body
term instead. A parametric wall beside a τ-ramped one
(u(left) - g with u(right) - delta*tau) is fine on the load path: both are held at every step.
A t/τ-dependent essential value needs steps to be held at: on a steady form that reads no step
history (.i(k)), and so does not march its domain(tau=...) grid, it is refused, with or without
a parameter in the form. A FIELD-sized optimizer-less parameter stays
the nodal data-field value (a neighbour's field in a DD solve), gathered per node.
Time-varying Dirichlet g(x, t) — the orders each scheme keeps
The constrained rows are imposed at the time each step or stage lands on, on linear and nonlinear,
single-field and coupled first-order transients alike (see
Boundary conditions). What that costs in
accuracy depends on the scheme. Measured on the Taylor–Green vortex (Navier–Stokes, Taylor–Hood
P2/P1, ν = 1, T = 0.5, h = π/6): the walled box [0, π]² has the exact velocity imposed on
all four walls; the periodic box [0, 2π]² has no Dirichlet data at all. Velocity error against a
same-mesh 256-step ros34pw2 reference (so the spatial error cancels), rates over 8 → 16 → 32 steps:
| scheme | walls driven by g(x, t) |
periodic box | wall rows exact? |
|---|---|---|---|
theta(1.0) |
0.98, 0.99 | 0.96, 0.98 | yes |
theta(0.5) |
2.18, 2.01 | 2.10, 2.01 | yes |
bdf2() |
2.23, 1.82 (→ 1.96 by 128 steps) | 2.10, 2.04 | yes |
sdirk(2) |
2.01, 2.00 | 2.01, 2.00 | yes |
sdirk(3) |
2.63, 2.60 | 2.95, 2.98 | yes |
rosenbrock() (ros34pw2) |
2.87, 2.93 | 2.95, 2.98 | yes |
rosenbrock("ros2") |
1.48, 1.68 | 1.77, 1.87 | no — 5.3e-4 off at 16 steps |
The losses in bold are the classical order reduction of one-step methods with low stage order
under time-dependent boundary data (Ostermann & Roche, Math. Comp. 59 (1992) 403–420), not a
mis-timed boundary value: the same form with the convection dropped, marched as a linear block,
gives rates identical to three digits, and the one-step-late control drops BDF2 to first order
(1.05) at ~900× the error. "Wall rows exact" means the nodal wall value equals g(x, tⁿ) to
round-off at every step: a stiffly accurate scheme's last stage is the step, so it lands on
g(tₙ₊₁); ros2 is not stiffly accurate, so its step is a combination of stages and holds the wall
value only to its order (1.4e-3 off after the first step, 5.3e-4 at T, falling as dt²). Re-imposing
g(tₙ₊₁) on the wall rows after each step was tried and not adopted: the wall becomes exact, but the
change it makes to the interior decays only as O(dt) — on the walled Taylor–Green box the ros2 rate over
32 → 64 → 128 → 256 steps fell from 1.81, 1.89, 1.94 to 1.77, 1.83, 1.82, with 19 % more interior error
at 256 steps. Use the default ros34pw2 when a time-varying wall value must hold exactly.
Plasticity — what runs today
Deformation theory (monotonic / proportional) and the path-dependent flow-theory tau=
load-path march both run. The march assembles on the real, steady native-Lagrange path,
single-field or coupled — not transient / complex / 1-D / non-nodal, each rejected with a
clear error. Periodic ties, exact slip conditions and hanging-node constraints compose (each step
solves in the reduced space u = P ũ); with one of them, tau=jno.solve.arclength(...) refuses by
name. A .bounds(...) box composes with a periodic tie whose bound is the same on both faces, and
refuses beside a weighted elimination (mortar, hanging nodes, slip).
An internal state advances on every cell by default; state.evolves(formula, region="strip")
restricts it to one region, and outside it the state is frozen at the value it already has —
so a zero-initialised plastic strain leaves the rest of the body elastic without a second
constitutive branch, and the return map is never evaluated on cells carrying another material's
constants. That is what makes a plastic part bearing on an elastic one expressible.
Kinematic / nonlinear (Voce) hardening are separate formulas, not built.
Curved boundaries: P3 buys nothing over P2
With the default straight-sided mesh the domain itself is approximated to O(h²), and that
error caps every element order above it. Measured on the unit disk
(-Δu = 4, u|∂Ω = 0, exact u* = 1 − r²), L2 rates under refinement:
| order | expected | measured | error at h = 0.05 |
|---|---|---|---|
| P1 | 2 | 2.00 | 1.14e-03 (1 536 dofs) |
| P2 | 3 | 2.02 | 7.46e-04 (6 015 dofs) |
| P3 | 4 | 2.01 | 7.40e-04 (13 438 dofs) |
P3 buys nothing over P2 — 13 438 dofs for the same answer — and both are barely ahead of P1. The cap comes from imposing the boundary condition at straight-edge nodes that sit on the chord, O(h²) inside the true arc.
On a polygonal domain the advertised rates hold exactly (the suite measures P2/P3 there).
The fix on a curved one is shape.curved(), which places the midside nodes on the true surface
and recovers P2's own third order — 570× more accurate at the finest resolution in the same
study. It is order 2 and simplices only, and non-nodal families keep affine geometry, so where it
does not apply the advice above still stands: prefer h-refinement (or the adaptive loop) over
order ≥ 2. See Curved geometry.
Interpolation covers — the padding, and the Dirichlet trace
A cover field's node array is the mesh nodes repeated 1+dim times, so memory scales by that
factor even where enrichment is switched off — an unenriched node's slots exist and are pinned
to zero (the Dirichlet mechanism, eliminated exactly). That is the price of the padded layout, and
it is what buys a rebuild that changes no points and no connectivity.
An inhomogeneous Dirichlet trace stays the P1 interpolant of g: the tangential covers pin to
zero, not to dg/ds. On a curved or non-constant boundary value that is a real accuracy limit, not
a formality.
jno.solve.enrich(...) is wired on the steady loop only — 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. A transient problem is refused by name, as is
order= (the cover supplies the extra order) and a non-simplex cell (the cover gradient assumes an
affine map). See Interpolation covers.
VPINN (network trial) — where the lowering stops
A network trial is test-projected onto the FE basis, and that projection is spatial — so a
form carrying the time coordinate is refused. It used to build and evaluate on a meaningless
number: the time grid never reached the residual and the initial condition was silently dropped
(measured: u(initial) - 0 and u(initial) - 7 gave bit-identical residuals). Making it real
needs a decided treatment of time — space-time test functions, or collocation on the declared grid
with the IC as its own loss — which is a design choice rather than an implementation gap.
3-D works. It used to raise -- first on an internal quadrature-pool tag, then by name -- and the restriction turned out to be a conservative guard rather than a limitation: the native assembler builds the context in 3-D exactly as in 2-D. Measured on the cube: Poisson 4.6e-04, a Neumann flux face 8.4e-04, with the same scalar and vector operator surface as 2-D.
One spelling is required on a boundary term: its coefficient must carry a coordinate from that
region ((g + 0.0 * xr) * v_r). A bound test keeps its binding on the view rather than in the
expression tree, so a bare constant is indistinguishable from a volume term once the form is
flattened, and would be integrated over the volume silently. It is refused instead. It is also
single-field: a vector unknown is fine (one field with value_shape=(d,), and all four source
spellings lower identically), but a coupled multi-field system is not. Periodic ties are
refused because a tie is an algebraic reduction of FE trial DOFs and a network trial has none —
impose periodicity inside the network instead.
Each of the three refuses by name at the point the decision is made. They used to surface further
in: a 3-D VPINN died on Tag 'fem_gauss' is not in the mesh pool or context, an internal
quadrature-pool name for a scope limit nobody could infer from it.
What does compose is on
Formulations, measured against
analytic solutions — including a network trial co-trained with a jno.np.parameter.
Element order on a non-nodal family — refused, not applied
RT / N1E / P0 / Hermite / Argyris / Morley each have one intrinsic order. space="N1E", order=2
used to return the same lowest-order space silently; it now raises. Note where it raises:
fem_symbols accepts the argument, and jno.fem([...]) is what refuses — the order is not
checked until an element is actually built.
u, v = d.fem_symbols(space="N1E", value_shape=(2,), order=2) # accepted
ui, vi = u.bind(x=xi, y=yi), v.bind(x=xi, y=yi)
jno.fem([inner(ui, vi) - inner(e_x, vi)]) # <- raises here
# NotImplementedError: jno.fem: order=2 is not selectable on the N1E element — its order is
# intrinsic to the family (lowest order (N1E0)) and jNO builds only that one. Drop order= (the
# default) to get it.
u, v = d.fem_symbols(space="N1E", value_shape=(2,)) # instead: take the family's order
The mesh is the only accuracy knob on an H(curl)/H(div) problem — see Mesh resolution for wave problems for what a given points-per-wavelength buys, measured.
A complex essential value — refused in 1-D, 2-D and 3-D
A complex form is solved as two coupled real legs that share one Dirichlet row set. That set
can impose Re u = g with Im u = 0, but not a prescribed Im u, so a complex g is refused
rather than silently truncated:
fem = jno.fem([ui.x * vi.x + ui.y * vi.y - k**2 * (1 + 0.1j) * ui * vi - 1.0 * vi,
u(xb, yb) - (0.5 + 0.25j)])
# NotImplementedError: jno.fem: a COMPLEX essential value on region 'boundary' is not supported
# — a complex form's Re/Im legs share one Dirichlet row set ...
fem = jno.fem([..., u(xb, yb) - 0.5]) # instead: a REAL essential value
The restriction is on the boundary value, not on the problem: the operator and the source stay
fully complex, and the solution has an imaginary part everywhere (measured max|Im u| = 2.6e-02
on the 2-D Helmholtz above with g = 0.5). Carry the complex part there.
eigs routing, differentiability, and when linear= pays
jno.solve.eigs / FEM.eigs route on the operator's actual symmetry. A symmetric pencil
takes the symmetric reductions (real spectrum, differentiable). A genuinely non-self-adjoint
operator goes to ARPACK/Arnoldi (Lehoucq & Sorensen 1996) and returns the complex
spectrum it actually has — the case that matters for stability problems (resistive MHD growth
rates, drift waves, anything with a mean flow), where the sign of the growth rate is the
physics. Neither path ever returns the spectrum of ½(K+Kᵀ) as though it were the answer.
The routing probe is a randomized bilinear test and is concrete-only, so under jit the
symmetric path is assumed.
Differentiability of the non-symmetric path. The eigenvalues are differentiable in reverse
mode — dλ = wᴴ(dA − λ dB)v / (wᴴBv) for a simple eigenvalue (Wilkinson 1965 ch. 2), with the
left eigenvector obtained by inverse iteration on (A − λᵢB)ᴴ, verified against finite
differences to 1e-09. The eigenvectors are not, and differentiating through them yields
NaN rather than the silent zero a plain callback would give. A defective eigenvalue has
no derivative at all (its perturbation series runs in √ε) and is detected via the eigenvalue
condition number, giving NaN instead of the enormous finite number the formula would produce.
Because that guard is a custom_vjp, forward mode (jax.jvp/jacfwd) is unavailable — use
jax.grad / jacrev.
linear= is opt-in because it is not always a win. It accepts
jno.solve.lu(backend="pardiso"/"cudss"/"host") to drive ARPACK's shift-invert factorization —
those kernels are plain numpy functions, so ARPACK can call them from host code — but refuses
backend="device" (a JAX primitive), the Krylov solvers, and precond=, rather than ignoring
them. ARPACK applies the inverse ~50–70 times, so it trades one fast factorization against
per-application overhead. Measured with PARDISO:
| pencil size | speed-up |
|---|---|
| n = 3,000 | 0.72× (slower) |
| n = 20,000 | 10.05× (21.6 s vs 217 s) |
It needs k < n-1; smaller pencils take an exact dense scipy.linalg.eig. Order with
which="LR" / "SR" (real part — the growth rate) or target an interior region with sigma=.
Axisymmetric vector forms are your responsibility
The 2πr measure is exact for scalars and wrong for vectors — elasticity hoop strain, and for
vector Maxwell the cylindrical curl's own 1/r terms plus the meridional/azimuthal decoupling.
For elasticity and flow the extra terms are short, and the recipe is written out and verified in
The vector recipe: add ε_θθ = u_r/r to the strains
and to the trace (so div u = ∂ᵣu_r + u_r/r + ∂zu_z). It reproduces the Lamé thick-walled cylinder
to under 1 % and Poiseuille pipe flow to machine precision; dropping the hoop term is over 10 % wrong
and raises nothing (tests/test_fem_axisymmetric_vector.py). Vector Maxwell is the case with no
recipe here: use a full 3-D mesh.
jNO ships no axisymmetric H(curl)/H(div) element, and multiplying by r is arithmetic the
assembler cannot distinguish from a legitimate radial coefficient, so nothing raises.
Use a full 3-D mesh for vector Maxwell, or write the cylindrical operator out yourself. See Axisymmetric (bodies of revolution).
Build time: what to expect, and the one knob
jno.fem([...]) fully assembles the operator — it returns concrete matrix values, which is why
fem.solve() is then only the linear solve (measured 7 ms on 3-D Poisson at 27,833 nodes).
Some libraries instead defer assembly into their solve. That makes their "build" look faster and their solve slower, so compare build + solve — and remember jNO assembles once, while a per-solve assembler pays again on every Newton iteration.
Most of a cold build is XLA compilation, and the cost is fixed per problem structure rather than per DOF: a 15× larger mesh still compiles about the same number of programs. Two caches attack it, both on by default.
Across processes — the persistent XLA cache
Enabled at import jno, stored in ~/.cache/jno/xla.
3-D Poisson, 27,833 nodes
| first build | repeat build | |
|---|---|---|
| no cache | 4.75 s | 2.48 s |
| persistent cache (default) | 2.22 s | 1.51 s |
The very first run on a machine is slower — populating the cache costs more than not having one. It pays back from the second process onward, which is jNO's normal life: sweeps, optimisation loops, test suites, re-running a script after an edit.
Opting out
Any one of: JNO_COMPILE_CACHE=0, jno.setup(__file__, compile_cache=False), or per project in
.jno.toml:
Within a process — identical-problem reuse
Rebuilding an identical problem (same mesh content, same terms) reuses the already-compiled assembly kernels outright, keyed on content rather than object identity. A rebuild then costs meshing plus host prep and no XLA work at all — measured 1.94 s → 0.28 s on 3-D Poisson at 29k nodes.
Anything that changes the operator — a different mesh, a different coefficient — recompiles exactly
the kernels that bake it. Structure a tokenizer cannot key by value simply never caches (a safe
miss); the coverage is measurable, not guessed (jno.utils.solver.fem_utils._ELEM_MAP_STATS).
Worked examples
The FEM tutorials cover every pattern in this guide:
| Elliptic | Poisson · mixed Dirichlet/Robin reaction–diffusion · mixed-BC Helmholtz |
| Nonlinear | Allen–Cahn interface |
| Solid & fluid | linear-elastic cantilever · Stokes channel flow |
| Transient | heat · two second-order-in-time wave examples (vibrating membrane, vector-elastodynamics cantilever) |
| Inverse | a hidden diffusivity field · a transient rate |
| Non-nodal | H(div) mixed Poisson · H(curl) Maxwell / eddy currents |
| Neural | a variational PINN — a neural trial in the same weak form |