Skip to main content

jNO logo

Dev Docs License Citation Docker image available arXiv Paper

Features · Install · Example · Docs · Citation

jNO (jax Numerical Operators) is a JAX-native library for differentiable numerical methods. Classical solvers — finite elements, finite differences, and spectral (RCWA) — and scientific machine learning — PINNs, neural operators, Bayesian inference — are two pillars on one substrate: you write the math (a weak form, a strong-form stencil, a PDE residual, a data loss), and it lowers to a single GPU-ready, end-to-end differentiable, jit-compiled graph.

Because every solve is differentiable, an inverse problem, a PDE-constrained optimization and a neural-network coefficient are one composition away from a forward solve — no glue code, no finite differences, no leaving JAX.

What you can do with jNO

jNO overview: the five-stage workflow from geometry to result, and the modules of the two pillars

Five stages, four front doors, one graph. Every module named above is public API; the matrix below is the same map, with a maturity label and a docs page per feature.

Feature matrix — maturity and per-feature docs (click to expand)

Pillar 1 — differentiable numerical methods. Every entry is matrix-free, GPU-ready and differentiable end to end.

FEM · stable Elements · H(div)/H(curl)/C¹ experimental FDM · stable
RCWA · stable Solvers & preconditioners · stable Eigenproblems · beta
Time integration · stable Adaptive meshing · beta Domain decomposition · beta
Geometry — jno.shape · stable Inverse & PDE-constrained · stable Limits & build time

Pillar 2 — scientific machine learning. All stable, and all composable with any solve above.

Forward PINNs Variational PINNs Operator learning
Bayesian PINNs Stochastic PDEs Adaptive resampling
LoRA & PEFT Explainability Foundation models
Parallelism W&B + checkpointing IREE deployment

Architectures come from foundax — MLPs, transformers, DeepONet, FNO, PROSE — wrapped with jno.nn(...).

43 worked tutorials span elliptic, parabolic, hyperbolic, coupled, inverse, integral, stochastic, FEM / variational, FDM, RCWA, Bayesian and operator-learning problems — browse the tutorials index.

Install

pip install jax-numerical-operators

One install — FEM, FDM, the solver stack, PINNs, and the scientific-ML tooling all come in the box, running on CPU out of the box. An NVIDIA GPU is one extra away — pip install "jax-numerical-operators[cuda]" — and the heavy, self-contained backends stay behind extras too: [fem] (adaptive remeshing, the PARDISO/cuDSS sparse-direct backends, METIS for the Schwarz preconditioner), [fdm] (METIS), [rcwa] (the Fourier-modal EM solver), [amg] (GPU algebraic multigrid), [iree], combinable as [cuda,fem]. The Installation guide has the full table, plus Pixi, Docker, and pinning a specific CUDA build.

PS: I recommend pulling the latest main branch to always be up to date!

Example

Differentiable FEM & FDM — one term list, weak or strong form
import jno

d = jno.shape.rect(0, 0, 1, 1, size=0.05).domain()
xi, yi, _ = d.variable("interior", split=True)
xb, yb, _ = d.variable("boundary", split=True)

# The same BVP two ways — −Δu = 1 on the unit square, u = 0 on the boundary.

# FEM — the WEAK form is the term list (with a test function v):
u, v = d.fem_symbols()
ui, vi = u.bind(x=xi, y=yi), v.bind(x=xi, y=yi)
u_fem = jno.fem([ui.x * vi.x + ui.y * vi.y - 1.0 * vi,   # ∫∇u·∇v − ∫f·v = 0
                 u(xb, yb) - 0.0]).solve()

# FDM — the STRONG form: the same term list, no test function, collocated at the nodes:
w  = d.unknown()
wi = w.bind(x=xi, y=yi)
u_fdm = jno.fdm([-wi.d2(xi) - wi.d2(yi) - 1.0,           # −Δu = 1
                 w(xb, yb) - 0.0]).solve()

# Both are sparse, matrix-free, GPU-ready, and end-to-end differentiable —
# wrap either in an objective to recover a coefficient, a source, or the geometry.
Inverse — recover a coefficient by differentiating through the solve (click to expand)
import jax.numpy as jnp
import optax
import jno

d = jno.shape.rect(0, 0, 1, 1, size=0.2).domain()
xi, yi, _ = d.variable("interior", split=True)
xb, yb, _ = d.variable("boundary", split=True)
u, v = d.fem_symbols()
ui, vi = u.bind(x=xi, y=yi), v.bind(x=xi, y=yi)

# Name the bilinear and linear forms once, then write the weak form as it reads on paper.
a = lambda k, w, z: k * (w.x * z.x + w.y * z.y)      # ∫ k ∇u·∇v
L = lambda z: 1.0 * z                                 # ∫ f v
const = lambda c: (lambda *a, **kw: jnp.array([c]))

# Synthetic observations from the true coefficient.
k_true = jno.np.parameter((1,)).initialize(const(2.5)).freeze()
u_obs = jnp.asarray(jno.fem([a(k_true, ui, vi) - L(vi), u(xb, yb) - 0.0]).solve())

# The unknown coefficient — a trainable parameter sitting inside the weak form.
k = jno.np.parameter((1,), name="k").initialize(const(1.0))
k.optimizer(optax.adam(5e-2))

fem  = jno.fem([a(k, ui, vi) - L(vi), u(xb, yb) - 0.0])
crux = jno.core([(fem.solve() - u_obs).mse], domain=d)   # the solve IS the forward model
crux.solve(400)                                          # k → 2.4999  (truth 2.5)

No adjoint derived, no finite differences, no second implementation of the physics — the gradient flows back through the assembly and the linear solve. Swap k for a spatially varying field, for the mesh coordinates, or for jno.nn(net) and nothing about the structure changes.

Coupled multiphysics — Rayleigh–Bénard convection in one term list (click to expand)
import jax
jax.config.update("jax_enable_x64", True)          # the assembler builds in float64

import numpy as np
import jno

Pr, Ra = 1.0, 1.0e4                                 # Ra >> Ra_c ≈ 1708 → vigorous convection
Lx, Ly, dt, nsteps = 2.0, 1.0, 0.009, 26

d = jno.shape.rect(0, 0, Lx, Ly, size=0.11).domain(time=(0.0, nsteps * dt, nsteps + 1))
u, v = d.fem_symbols(value_shape=(2,), names=("u", "v"), order=2)   # P2 velocity  ┐ inf-sup
p, q = d.fem_symbols(names=("p", "q"), order=1)                     # P1 pressure  ┘ stable pair
T, s = d.fem_symbols(names=("T", "sT"), order=1)                    # P1 temperature
xi, yi, ti = d.variable("interior", split=True)
xb, yb, _  = d.variable("boundary", split=True)
ci = d.variable("initial", split=True)
ub, vb = u.bind(x=xi, y=yi, t=ti), v.bind(x=xi, y=yi, t=ti)
pb, qb = p.bind(x=xi, y=yi, t=ti), q.bind(x=xi, y=yi, t=ti)
Tb, sb = T.bind(x=xi, y=yi, t=ti), s.bind(x=xi, y=yi, t=ti)

ux, uy, vx, vy = ub[0], ub[1], vb[0], vb[1]
uxx, uxy, uyx, uyy = ub.x[0], ub.y[0], ub.x[1], ub.y[1]             # grad-then-index: ∂uᵢ/∂xⱼ
vxx, vxy, vyx, vyy = vb.x[0], vb.y[0], vb.x[1], vb.y[1]

momentum = ((ub.t[0] * vx + ub.t[1] * vy)                           # ∂u/∂t
            + ((ux * uxx + uy * uxy) * vx + (ux * uyx + uy * uyy) * vy)   # (u·∇)u  — nonlinear
            + Pr * (uxx * vxx + uxy * vxy + uyx * vyx + uyy * vyy)  # Pr ∇u : ∇v
            - pb * (vxx + vyy)                                      # −p ∇·v
            - Pr * Ra * Tb * vy)                                    # buoyancy:  T → momentum
continuity = qb * (uxx + uyy)                                       # ∇·u = 0
energy = Tb.t * sb + (ux * Tb.x + uy * Tb.y) * sb + (Tb.x * sb.x + Tb.y * sb.y)   # u → T

Tcond = 1.0 - ci[1] / Ly                                            # hot floor, cold lid
T0 = Tcond + 0.05 * jno.np.sin(2 * np.pi * ci[0] / Lx) * jno.np.sin(np.pi * ci[1] / Ly)

fem = jno.fem([momentum, continuity, energy,
               u(xb, yb) - 0.0,                     # no-slip walls
               T(xb, yb) - (1.0 - yb / Ly),         # conductive profile held on the walls
               p.pin(),                             # gauge-fix the pressure null space
               u(*ci) - 0.0, T(*ci) - T0])          # start at rest, seeded

assert fem.is_transient and not fem.is_linear       # both DETECTED from the terms, not configured

sol  = fem.solve(linear=jno.solve.lu())             # a saddle system: name a direct solver
traj = np.asarray(jno.core([sol.mse]).eval([sol]))  # (27, 2398) — one row per step
uu, pp, TT = (traj[:, a:b] for a, b in zip(fem.offsets[:-1], fem.offsets[1:]))
# T range 0.000..1.000   peak |u| 27.7   — started at rest, ended in counter-rotating rolls

Three fields, coupled both ways: buoyancy Pr·Ra·T drives the momentum balance, and u·∇T carries the flow back into the energy balance — a product of two different unknowns, so the system is nonlinear. Nothing above selects a mode: fem.solve() reads u.t and the nonlinearity off the term list and marches backward-Euler with Newton per step.

RCWA — a periodic metasurface, from the same constraint list (click to expand)
import jno
import jax.numpy as jnp

# A periodic metasurface unit cell — a patterned high-index slab between two ambients.
K0 = 2 * jnp.pi                                            # vacuum wavenumber (wavelength λ = 1)
d = jno.shape.box(0, 0, 0, 0.6, 0.6, 1.0, size=0.12).domain()
d.tag("bottom", lambda x, y, z: z < 0.01);  d.tag("top",   lambda x, y, z: z > 0.99)   # z ambients
d.tag("left",   lambda x, y, z: x < 0.01);  d.tag("right", lambda x, y, z: x > 0.59)   # x-periodic
d.tag("front",  lambda x, y, z: y < 0.01);  d.tag("back",  lambda x, y, z: y > 0.59)   # y-periodic

u, v = d.fem_symbols()
xi, yi, zi, _ = d.variable("interior", split=True)
ui, vi = u.bind(x=xi, y=yi, z=zi), v.bind(x=xi, y=yi, z=zi)
def on(tag):                                              # bind u, v on a named face
    s = d.variable(tag, split=True)
    return u.bind(x=s[0], y=s[1], z=s[2]), v.bind(x=s[0], y=s[1], z=s[2])
(ut, vt), (ub, vb) = on("top"), on("bottom")
ul, ur, uf, ubk = on("left")[0], on("right")[0], on("front")[0], on("back")[0]

slab = jno.fn(lambda x, y, z: jnp.where((0.4 < z) & (z < 0.6), 1.0, 0.0), [xi, yi, zi])
eps  = 1.0 + 10.0 * slab      # a patterned slab (swap in jno.np.parameter(...) for inverse design)

# the SAME scalar-Helmholtz list you'd hand jno.fem — rcwa infers period, layers, ε, and incidence:
sol = jno.rcwa([
    ui.x * vi.x + ui.y * vi.y + ui.z * vi.z - K0**2 * eps * (u * vi),   # ∇u·∇v − k₀²·ε·u·v
    -(1j * K0 * ut) * vt,                   # outgoing radiation (top ambient)
    -(1j * K0 * ub - 2j * K0) * vb,         # incident plane wave + radiation (bottom)
    ul - ur,  uf - ubk,                     # Floquet periodicity (x and y)
], orders=200).solve()

sol.efficiency("T")     # transmitted power fraction (needs the [rcwa] backend)
sol.order(+1, 0)        # a chosen diffraction order
Operator learning — an FNO trained on the PDE residual, with no labelled solutions (click to expand)
import jno
import jax
import optax
import foundax

# `diff_type` declares the differentiation for the whole run. Spectral differentiates the operator's
# OUTPUT along its own grid axes, so the residual needs no autodiff path from x — which is what makes
# a physics loss writable on a field-to-field operator.
dir = jno.setup("./runs/pino", diff_type="spectral")

# Solve −Δu = f on the periodic unit square, for a whole DISTRIBUTION of forcings f at once.
N = 64
dom = 256 * jno.shape.rect(0, 0, 1, 1, size=1 / N).domain(structured=True)
x, y, _ = dom.variable("interior", split=True)

# The input function: a fresh Gaussian random field per batch — an operator dataset without a dataset.
# (Swap in your own: `f = dom.variable("_f", forcing)` with forcing shaped (samples, H, W, 1),
#  eagerly or as a lazy h5py/zarr handle that streams per batch.)
f = jno.noise.grf(x, y, length_scale=0.2)

grid = (N + 1, N + 1)              # `size=1/N` spans the interval inclusive of both endpoints
net = jno.nn(foundax.fno2d(in_features=1, hidden_channels=32, n_modes=16, d_model=grid))
net.optimizer(optax.adam(1e-3))

u = net(f).bind(x=x, y=y)          # the operator's output, bound to the grid coordinates
pde = u.xx + u.yy + f              # Δu + f = 0 — the two partials fuse into ONE transform pair

crux = jno.core(constraints=[pde.mse], domain=dom)
crux.solve(epochs=20_000, batchsize=8).plot(f"{dir}/training.png")

Citation

If you use jNO in academic work, please cite:

@article{armbruster2026jno,
  title   = {jNO: A JAX Library for Neural Operator and Foundation Model Training},
  author  = {Armbruster, Leon and Ramesh, Rathan and Kruse, Georg and Straub, Christopher},
  journal = {arXiv preprint arXiv:2605.10159},
  year    = {2026},
  doi     = {10.48550/arXiv.2605.10159},
  url     = {https://arxiv.org/abs/2605.10159}
}

AI Disclosure

Parts of this codebase — including model ports, tests, and documentation — were developed with the assistance of AI coding tools. All contributions are reviewed and tested to the best of our ability, but mistakes may remain; please open an issue if you spot one.

Metadata

Release files for jax-numerical-operators 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for jax-numerical-operators 0.4.0
File Size Uploaded
jax_numerical_operators-0.4.0.tar.gz 3.2 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for jax-numerical-operators 0.4.0
File Interpreter ABI Platform
jax_numerical_operators-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 5.0 MB

Release files / jax_numerical_operators-0.4.0.tar.gz

Download URL jax_numerical_operators-0.4.0.tar.gz
Size 3.2 MB
Tags Source
SHA-256 checksum
How to use checksums
fbbc9a17d33472385dd29cf44e7cd419e3e0ca439849ffc3664a841439f61ef4
BLAKE2b-256 checksum
How to use checksums
b6184558283904621bcff13c9e0f40d919312c9634428076fde0507a251f3e08
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release files / jax_numerical_operators-0.4.0-py3-none-any.whl

Download URL jax_numerical_operators-0.4.0-py3-none-any.whl
Size 1.8 MB
Tags Python 3
SHA-256 checksum
How to use checksums
038212d84912bde053a7036735edf2319b8fde366cc02da6c77a9cb7a4faa505
BLAKE2b-256 checksum
How to use checksums
40a4db9ed7986217f1114425f4900cccc61ebc8260e1841bd5e82aa76d8dd3a2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.1

2 release files

0.3.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page