1. The same 2D Laplacian, one block per cell
\(-\Delta u = f\)
Same mesh, same basis family, a
different Variables class: VariablesDG
keeps one full set of degrees of freedom per cell instead of
sharing nodes. What replaces the sharing is a flux
— here SIPGFlux, symmetric interior penalty — added on
every interior and boundary face.
import jax.numpy as jnp
from scimba_jax.linear_approximation.basis.analytic_bases import local_lagrange_basis
from scimba_jax.linear_approximation.basis.general_bases import AnalyticBasis
from scimba_jax.linear_approximation.galerkin.dg.elliptic_dg_scheme import (
EllipticDGscheme,
)
from scimba_jax.linear_approximation.galerkin.dg.flux import SIPGFlux
from scimba_jax.linear_approximation.meshes.mesh import Mesh
from scimba_jax.linear_approximation.quad.gauss_quad import UnitSquareTensorized
from scimba_jax.linear_approximation.variables.variables_dg import VariablesDG
from scimba_jax.mapping.mapping import InvertibleFunction, Mapping
from scimba_jax.physical_models.abstract_physical_weak_model import (
AbstractPhysicalWeakModel,
)
from scimba_jax.physical_models.classical_weakform.laplacian_weak_form import (
LaplacianWeakForm,
)
order = 2
mesh = Mesh(
dim=2,
n_cells=(24, 24),
ref_quad=UnitSquareTensorized(dim=2, order=2 * order + 1),
mapping=Mapping(mappings=[InvertibleFunction(lambda x: x, lambda y: y)]),
)
basis = AnalyticBasis(
nb_basis=(order + 1) ** 2,
out_dim=1,
mesh=mesh,
local_basis=lambda y, i, m: local_lagrange_basis(y, i, m, order=order, out_dim=1),
basis_type="scalar",
)
# One block of DOFs per CELL, no sharing -- a jump across a face is the
# unknown the flux term controls, not an error.
variables = VariablesDG(basis=basis, nb_variables=1)
def source(x): # same manufactured solution as the FEM page
return 2 * jnp.pi**2 * jnp.sin(jnp.pi * x[0]) * jnp.sin(jnp.pi * x[1])
pde = LaplacianWeakForm(dim=2, f=source)
model = AbstractPhysicalWeakModel.from_weak_form(pde, dirichlet=lambda x: jnp.zeros(1))
# Continuity is PENALISED, not assumed -- strength must scale like 1/h.
flux = SIPGFlux(sigma=10.0 * order * (order + 1))
scheme = EllipticDGscheme(model, variables, flux, use_scan_quad=False)
scheme = EllipticDGscheme.solve(scheme, matrix_free=True, tol=1e-10)
# Same problem, order and mesh as the FEM page: 1.7e-05 relative L2 error.
2. What a flux actually is
\(\widehat{F} = \{\nabla u\}\cdot n - \dfrac{\sigma}{h}\,[\![u]\!]\)
Every DG scheme reads a flux the same way: given the two sides of a
face (their trace, gradient and outward normal), return what each
side owes the other. SIPGFlux is the default for a
diffusion term; an upwind flux is the same
contract applied to advection, and a Riemann solver for a
hyperbolic system.
class AbstractFlux(ScimbaPytree):
"""What a DG scheme asks of a flux."""
def __call__(self, varL, varR):
"""Two sides' contributions on one INTERIOR face."""
raise NotImplementedError
def boundary_call(self, var_interior, n_interior, dirichlet_val):
"""The interior side's contribution on one BOUNDARY face."""
raise NotImplementedError
# SIPGFlux is consistent, symmetric (keeps the operator SPD), and penalised
# by a LOCAL sigma/h -- a global h is wrong as soon as the mesh isn't uniform.
# An upwind flux is the same contract, for advection instead of diffusion.
3. Time-dependent problems
\(\partial_t u - \Delta u = 0\)
TimeDiscreteDGscheme re-solves the same elliptic DG
problem at every stage of a Butcher tableau, exactly like
TimeDiscreteFEscheme on the FEM page. The one thing
worth knowing before choosing a tableau: DG's interior-penalty flux
adds stiffness beyond the bare operator, so an explicit tableau
needs a noticeably smaller time step than FEM would at the same
mesh — an implicit tableau sidesteps that restriction entirely.
from scimba_jax.linear_approximation.basis.analytic_bases import local_taylor_basis
from scimba_jax.linear_approximation.galerkin.dg.time_discrete_dg_scheme import (
TimeDiscreteDGscheme,
)
from scimba_jax.time_discrete.butcher_tableau import build_implicit_euler_tableau
# Same du/dt - u_xx = 0 as the FEM time page. DG's explicit-Euler threshold
# is ~5x stricter than FEM's here (the penalty adds stiffness), so implicit
# is the natural default for DG diffusion.
n_cells, order = 16, 1
mesh_1d = Mesh(dim=1, n_cells=(n_cells,), ref_quad=UnitSquareTensorized(dim=1, order=4),
mapping=Mapping(mappings=[InvertibleFunction(lambda x: x, lambda y: y)]))
basis_1d = AnalyticBasis(
nb_basis=order + 1, out_dim=1, mesh=mesh_1d,
local_basis=lambda y, i, m: local_taylor_basis(y, i, m, order=order, out_dim=1),
basis_type="scalar",
)
scheme = TimeDiscreteDGscheme(
spatial_weak_form_factory=LaplacianWeakForm(dim=1, f=lambda x: jnp.zeros(())),
variables=VariablesDG(basis=basis_1d, nb_variables=1),
flux=SIPGFlux(sigma=(order + 1) * (order + 2), h=1.0 / n_cells),
butcher_tableau=build_implicit_euler_tableau(),
dt=0.1 / 8,
dirichlet=lambda x: jnp.zeros(1),
)
dofsl_init = scheme.initialize(lambda x: jnp.sin(jnp.pi * x))
dofsl_final, history = scheme.solve(dofsl_init, t0=0.0, nt=8)
# Same 8 steps, order-1 Taylor basis: 5.5e-02 relative L2 error -- matches
# FEM's at this resolution, as expected for the same order and mesh.
DG's block structure changes what an efficient solver looks like too — see Multigrid for the block-Jacobi smoother that reads one cell's dense block per sweep instead of a single diagonal entry, and Nonlinear solvers for a nonlinear flux.