Assembly#

FluxFEM provides two complementary assembly styles:

  • Tensor-based assembly: write per-quadrature array integrands directly (scikit-fem style).

  • Weak-form-based assembly: write expressions close to the mathematical weak form and let FluxFEM compile them into element kernels.

Both styles target the same assembly routines, so you can mix them in one project.

When to use which?

  • Tensor-based: explicit data flow and shapes; good when you want full control, custom kernels, or to follow scikit-fem-like patterns.

  • Weak-form-based: concise and expressive; good for rapid prototyping and for matching equations in papers.

Single-Space vs Role-Explicit Assembly#

FluxFEM supports two complementary calling styles for the same core assembly machinery:

  • space.assemble_*: the shortest path when test/trial/unknown all live on the same space.

  • ff.assemble_*(*Spaces(...), ...): the explicit path when you want to bind named roles such as test and trial directly in the public API.

Deprecated compatibility paths are documented in migration_role_spaces.rst. The preferred assembly surface shown here uses the *Spaces family directly.

For standard Galerkin problems, the single-space form remains the simplest:

import fluxfem as ff

K = space.assemble_bilinear_form(ff.diffusion_form, params=1.0)
F = space.assemble_linear_form(ff.make_scalar_body_force_form(lambda x: 1.0), params=None)

The paired helper space.assemble_bilinear_linear_pair(...) remains supported for the common same-space case, but it is only convenience sugar over the separate bilinear and linear assembly calls. There is no separate role-explicit PairSpaces abstraction; the underlying public building blocks are still BilinearSpaces and LinearSpaces.

For role-explicit assembly, use the *Spaces family:

import fluxfem as ff
import fluxfem.helpers_wf as wf

U = ff.NamedSpace("U", trial_space)
V = ff.NamedSpace("V", test_space)

bilinear = ff.BilinearForm.volume(
    lambda u, v, p: p.kappa * wf.dot(wf.grad(v), wf.grad(u)) * wf.dOmega()
)
linear = ff.LinearForm.volume(
    lambda v, p: wf.dot(v, p.force) * wf.dOmega()
)

A = ff.assemble_bilinear_form(
    ff.BilinearSpaces(test=V, trial=U),
    bilinear,
    ff.Params(kappa=1.0),
)
b = ff.assemble_linear_form(
    ff.LinearSpaces(test=V),
    linear,
    ff.Params(force=1.0),
)

The same pattern extends to nonlinear volume assembly:

residual = ff.ResidualForm.volume(
    lambda v, u, p: p.kappa * wf.gaction(v, wf.grad(u)) * wf.dOmega()
)

R = ff.assemble_residual(
    ff.ResidualSpaces(test=V, unknown=U),
    residual,
    u_vec,
    ff.Params(kappa=1.0),
)
J = ff.assemble_jacobian(
    ff.JacobianSpaces(test=V, trial=U),
    residual,
    u_vec,
    ff.Params(kappa=1.0),
)

Measure handling (weak form vs tensor vs kernel)#

All three assembly styles use the same space.assemble(...) entry point, but they differ in who owns the quadrature measure (w * detJ). This is the most common source of confusion, so the rules are summarized here:

Style

What your form returns

Measure rule

Weak-form DSL

Expression for the integrand

Must multiply by dOmega()/ds()

Tensor-based form

Per-quadrature integrand arrays

Must NOT include dOmega()/ds()

Element kernel (JIT)

Integrated element vector/matrix

Must already include the measure

Quick examples:

import fluxfem.helpers_wf as wf

# weak form: include dOmega()
form = ff.BilinearForm.volume(lambda u, v, p: (v.grad @ u.grad) * p.kappa * wf.dOmega())
# tensor form: integrand only (no dOmega)
def diffusion_form(ctx, kappa):
    return kappa * jnp.einsum("qia,qja->qij", ctx.test.gradN, ctx.trial.gradN)
# element kernel: already integrated over quadrature
def linear_kernel(ctx):
    integrand = ff.scalar_body_force_form(ctx, 2.0)
    wJ = ctx.w * ctx.test.detJ
    return (integrand * wJ[:, None]).sum(axis=0)