Skip to content

Weak-form vocabulary

Everything you write inside jno.fem([...]) is a symbolic term built from a small set of primitives, composed by ordinary arithmetic. This page is a reference for what you can already write — the declarative building blocks — so you reach for an escape hatch only when you truly need one. A term's region is carried by the symbols you bind into it (no string kwargs): bind a field to an interior/boundary tag and the term assembles there.

d = jno.domain(box(0, 0, 1, 1), mesh_size=0.05)
u, v = d.fem_symbols()                       # trial, test
xi, yi = d.variable("interior", split=True)  # coordinates carry the region
ui, vi = u.bind(x=xi, y=yi), v.bind(x=xi, y=yi)
fem = jno.fem([ui.x * vi.x + ui.y * vi.y - f * vi, u(xb, yb) - 0.0])   # ∇u·∇v = f, Dirichlet

Fields, coordinates, derivatives

You want Write
trial/test fields (scalar/vector, P1/P2, RT/Nédélec) u, v = d.fem_symbols(value_shape=(2,), order=2, space="RT", names=("u","v"))
coordinates + region xi, yi, ti = d.variable("interior", split=True); bind via u.bind(x=xi, y=yi, t=ti) or u(xi, yi)
partial derivatives (any order) ui.x, ui.y, ui.t, ui.xy, ui.xx
grad / div / curl / laplacian / hessian ui.grad(xi, yi), ui.div(xi, yi), ui.curl(xi, yi), ui.laplacian(xi, yi), ui.hessian(xi, yi)

Algebra & nonlinear physics

Closed-form nonlinear physics is already symbolic — + - * / ** and the jno.np library compose straight into a term:

D = k * (1e-3 + ui.x**2 + ui.y**2) ** 0.3          # nonlinear diffusivity D(|∇u|)
react = ui * ui * vi                                 # u² reaction (nonlinear)
fem = jno.fem([ui.t*vi + D*(ui.x*vi.x + ui.y*vi.y) + react - f*vi, ...])
Group Primitives (jno.np.*)
elementary exp log sin cos tan sqrt cbrt abs sign square power (and **)
conditional / piecewise where(cond, a, b), maximum(a, b), minimum(a, b), comparisons u > 0
vector / tensor inner(a, b, n_contract=), dot cross outer trace sym; vector .norm() .dot() .cross()
matrix / Voigt / complex MatrixView (.det .inv .eigvals .sym), VoigtView (.von_mises .deviatoric .invariants), ComplexView (.real .imag .conj)
constitutive diff(psi, F) — stress from a stored energy, by AD; cellwise(expr) — the per-cell L2 projection onto P0 (B-bar / F-bar)

Ordinary Python arithmetic over components composes too, so a vector source can be written either way — inner when the whole vector is to hand, a plain sum when the components are:

f_dot_v = jno.np.inner(f, vi, n_contract=1)          # f is a vector expression
f_dot_v = sum(f[k] * vi[k] for k in range(3))        # components, built one at a time

Both assemble to the same operator (to assembly round-off — the products reduce in a different order). The builtin sum() seeds with a literal 0; that zero belongs to no equation block and is dropped. Every other additive piece of a form must carry exactly one test field, since the test field is what selects the block: a source term is -f * vi, never -f.

Locking-free strains — cellwise

cellwise(expr) is the quadrature-weighted mean of an expression over each cell, (∫_K expr)/(∫_K 1), broadcast back to that cell's quadrature points. It is what B-bar and F-bar are written with, and it is a one-line edit to a form you already have — project the volumetric strain and nothing else:

eu, ev   = eps(ui), eps(vi)
tru, trv = trace(eu), trace(ev)
cu, cv   = cellwise(tru), cellwise(trv)          # <- drop these two calls to get the standard form
dev_uv   = inner(eu, ev, 2) - tru * trv / dim    # dev(eu) : dev(ev)
mech     = lam * cu * cv + 2 * mu * (dev_uv + cu * cv / dim)

It is the difference between an answer and a locked one

Plane-strain Q1 cantilever at nu = 0.4999, 16x4 cells: standard 2.68e-02, B-bar 3.03e-01 — 11.3x. B-bar is converged there (it moves 0.7% out to 128x32); the standard element is still 2.3x too stiff at 128x32. Refinement does not cure locking. At nu = 0.3, where there is nothing to cure, the two differ by 5.6%.

It does nothing on P1 triangles and tets

Their strain is already constant over the cell, so this projection is the identity there. B-bar is for quadrilateral/hexahedral cells and higher-order simplices; for P1 volumetric locking, use Q1/hex cells, raise the order, or write a mixed u-p formulation.

Scope: native-Lagrange volume terms. 1-D, non-nodal (Argyris/Morley/RT/N1E), surface terms and VPINN/collocation residuals raise rather than average over the wrong set of points. It may not appear inside a diff target — write cellwise(diff(...)), or put the projection inside wrt as F-bar does.

Integrals (local and non-local)

energy = (ui.x**2 + ui.y**2).integrate()            # ∫_Ω |∇u|² dΩ  (scalar)
heat   = (u * v).integrate(ti)                       # ∫ u·v dt      (time window)

# non-local / Fredholm kernel: ∫ K(x,y) u(y) dy, returned per collocation point
x, _ = d.variable("interior"); y, _ = d.variable("interior")
Ku = (kernel(x, y) * u_of(y)).integrate(var=x)

expr.integrate() is a domain (or boundary) integral; expr.integrate(var=x) is the non-local kernel form (one value per collocation point — Fredholm-type operators); expr.integrate(t) is a time-window integral.

Geometry symbols

Symbol Meaning Use
d.variable(tag, normals=True, split=True) → nx, ny boundary outward normal flux / Robin terms nx*ui.x + ny*ui.y
d.variable(tag, normals=True, reverse_normals=True, split=True) the same normals, negated an enclosure or cavity where the meshed side faces inward
d.variable(tag, return_indices=True, split=True) one extra placeholder: the sampled point indices scattering a result back onto the mesh, or tying a subset to data
d.cell_size element size h (|detJ|^(1/dim)) at quad points SUPG/GLS stabilization τ = h/(2·|β|)
d.enclosure(tags) view-factor matrix + measures grey-body radiation (.view_factor, .field(), .load())
# SUPG-stabilized advection–diffusion, fully declarative:
h    = d.cell_size
beta = jno.np.vector(1.0, 0.5)
tau  = h / (2 * beta.norm())
adv  = beta[0]*ui.x + beta[1]*ui.y
fem  = jno.fem([adv*vi + nu*(ui.x*vi.x + ui.y*vi.y) - f*vi
                + tau * adv * (beta[0]*vi.x + beta[1]*vi.y), u(xb, yb) - 0.0])

A component of jno.np.vector may be a bare number next to per-point expressions: jno.np.vector(1.0, 0.0, x) broadcasts the numbers over the points and is exactly jno.np.vector(1.0 + 0.0*x, 0.0*x, x).

Coefficients & the escape hatch

You want Write
learnable PDE coefficient (inverse design) k = jno.np.parameter((1,), name="k")
fixed coefficient / field / table jno.np.constant(...), d.variable(tag, sample=array)
arbitrary JAX not covered above jno.fn(lambda a, b: ..., [ui, vi]) → a traced node usable in any term

jno.fn is the escape hatch: any differentiable JAX function of traced arguments becomes a term. Reach for it only when the math isn't expressible from the primitives above — which, with conditionals, non-local integrals, the geometry symbols, and the full tensor calculus, is rare.

Introspection

fem.term_kinds (provisional) classifies each PDE term — is_local (pointwise reaction/mass) vs. global (neighbour-coupling diffusion/advection), its temporal order, trial/test gradient channel, and linearity — the basis for operator-splitting routing. See fem.md.