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 astestandtrialdirectly 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 |
Tensor-based form |
Per-quadrature integrand arrays |
Must NOT include |
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)