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.