1. A Poisson problem as a conservation law

\(-\nabla\cdot(\nabla u) = f\)

This is already in conservation form: the flux is grad u, and its divergence balances the source. AbstractConservativePDE asks for exactly that split — a diffusive flux A, an optional advective one F, a reaction term R, and a source f — and TPFADiffusionFlux (two-point flux approximation) is the simplest consistent way to evaluate the diffusive one on a Cartesian mesh: the flux through a face is proportional to the difference of the two neighbouring cell values, divided by the distance between them.

import jax
import jax.numpy as jnp
from scimba_jax.linear_approximation.finite_volume import (
    EllipticFVScheme,
    TPFADiffusionFlux,
)
from scimba_jax.linear_approximation.meshes.cartesian_mesh import cartesian_mesh
from scimba_jax.linear_approximation.variables.variables_fv import VariablesFV
from scimba_jax.nonlinear_approximation.model_class.funcparam_matrix import (
    ParamMatrixFunction,
)
from scimba_jax.nonlinear_approximation.model_class.funcparam_scalar import (
    ParamScalarFunction,
)
from scimba_jax.physical_models.abstract_conservative_pde import (
    AbstractConservativePDE,
)
from scimba_jax.physical_models.abstract_physical_conservative_model import (
    AbstractPhysicalConservativeModel,
)


class Poisson2D(AbstractConservativePDE):
    """``-div(grad u) = f``, written as a CONSERVATION LAW: div(flux) = source."""

    def __init__(self):
        super().__init__(dim=2)

    def construct_A(self, u):  # the diffusion tensor multiplying grad(u)
        return ParamMatrixFunction(u.dims, lambda _sp, _x: jnp.eye(2), f_type=u.f_type)

    def construct_f(self):  # the source, one value per cell
        return ParamScalarFunction(
            {"x": 2, "mu": 0},
            lambda _sp, x: 2.0 * (x[0] * (1.0 - x[0]) + x[1] * (1.0 - x[1])),
            f_type="x",
        )

    def construct_F(self, u):
        return None  # no first-order (advective) flux here

    def construct_R(self, u):
        return None  # no reaction term here


mesh = cartesian_mesh([32, 32], quad_order=3)
model = AbstractPhysicalConservativeModel.from_pde(Poisson2D(), dirichlet=lambda x: jnp.zeros(1))
# TPFA: flux through a face = (u_right - u_left) / distance, times A.
scheme = EllipticFVScheme(
    model, VariablesFV(mesh, projection="average"), TPFADiffusionFlux(),
    assemble_first_order=False, assemble_reaction=False,
)
scheme = EllipticFVScheme.solve(scheme)
# RMS error against x(1-x)y(1-y): 3.7e-05 at 32x32 cells.

2. The flux class

Every finite-volume flux is an AbstractFVFlux: up to four small methods, one per combination of interior/boundary and first-order (advective)/second-order (diffusive). A scheme only ever calls the ones its problem needs — TPFADiffusionFlux above overrides just the diffusive pair, since the Poisson problem has no advection. A hyperbolic conservation law is the mirror image: RusanovFlux overrides only the advective pair, and needs nothing more than a bound on the local wave speed to stay stable and monotone.

class AbstractFVFlux(ScimbaPytree):
    """Every finite-volume flux answers up to four questions on a face."""

    def first_order(self, model, u_left, u_right, context):
        """Advective flux density leaving the left cell (a Riemann solver)."""
        return jnp.zeros_like(u_left)

    def second_order(self, model, u_left, u_right, context):
        """Diffusive flux density leaving the left cell (e.g. two-point flux)."""
        return jnp.zeros_like(u_left)

    def boundary_first_order(self, model, u_inside, boundary_condition, context):
        """Same, on a boundary face -- context.point replaces u_right."""
        return jnp.zeros_like(u_inside)

    def boundary_second_order(self, model, u_inside, boundary_condition, context):
        """Diffusive counterpart of boundary_first_order."""
        return jnp.zeros_like(u_inside)

# TPFADiffusionFlux only overrides the diffusive pair. A hyperbolic law is
# the mirror image: RusanovFlux overrides only first_order, a local
# Lax-Friedrichs flux built from a spectral-radius bound on the wave speed.
from scimba_jax.linear_approximation.finite_volume.hyperbolic import RusanovFlux

flux = RusanovFlux(wave_speed=lambda _model, u, _context: jnp.abs(u[0]))
# {F(uL)+F(uR)}/2 - alpha/2 (uR - uL), alpha = max wave speed on the face.

3. Time-dependent problems

\(\partial_t u + \partial_x\!\left(\dfrac{u^2}{2}\right) = 0\)

TimeDiscreteFVscheme wraps a FiniteVolumeScheme the same way TimeDiscreteFEscheme wraps an elliptic FEM problem: the spatial scheme becomes a right-hand side, and a Butcher tableau decides how it is stepped. For a shock-forming hyperbolic problem like Burgers, an explicit tableau under a CFL condition is the natural choice — there is no smooth stiffness for an implicit step to buy back.

from scimba_jax.linear_approximation.finite_volume import (
    FiniteVolumeScheme,
    TimeDiscreteFVscheme,
)
from scimba_jax.linear_approximation.finite_volume.hyperbolic import RusanovFlux
from scimba_jax.linear_approximation.meshes.cartesian_mesh import cartesian_mesh
from scimba_jax.linear_approximation.variables.variables_fv import VariablesFV
from scimba_jax.nonlinear_approximation.model_class.funcparam_scalar import (
    ParamScalarFunction,
)
from scimba_jax.physical_models.abstract_conservative_pde import AbstractConservativePDE
from scimba_jax.physical_models.abstract_physical_conservative_model import (
    AbstractPhysicalConservativeModel,
)
from scimba_jax.physical_models.weak_boundary_conditions import Dirichlet
from scimba_jax.time_discrete.butcher_tableau import build_explicit_euler_tableau

# Burgers: du/dt + d(u^2/2)/dx = 0, a Riemann problem u_L=1, u_R=-0.25 that
# forms a shock. FiniteVolumeScheme becomes a right-hand side to STEP,
# the same way EllipticFVScheme became a residual to SOLVE.
class BurgersPDE(AbstractConservativePDE):
    def __init__(self):
        super().__init__(dim=1)

    def construct_F(self, u):
        return ParamScalarFunction(u.dims, lambda sp, x: 0.5 * u(sp, x) ** 2, f_type=u.f_type)

    def construct_A(self, u):
        return None

    def construct_R(self, u):
        return None


model = AbstractPhysicalConservativeModel(BurgersPDE())
model.add_boundary_condition("west", Dirichlet(lambda _x: jnp.array([1.0])))
model.add_boundary_condition("east", Dirichlet(lambda _x: jnp.array([-0.25])))
flux = RusanovFlux(wave_speed=lambda _model, u, _context: jnp.abs(u[0]))
spatial_scheme = FiniteVolumeScheme(
    model, VariablesFV(cartesian_mesh([40], quad_order=2)), flux,
    assemble_second_order=False, assemble_reaction=False, assemble_source=False,
)
scheme = TimeDiscreteFVscheme(spatial_scheme, build_explicit_euler_tableau(), dt=0.4 / 40)
initial = scheme.initialize(lambda x: jnp.where(x[0] < 0.5, jnp.array([1.0]), jnp.array([-0.25])))
final, history = scheme.solve(initial, t0=0.0, nt=20)
# Explicit Euler needs a CFL-limited dt (dt <= CFL h / max wave speed); a
# shock has no smooth stiffness for an implicit step to buy back. 40 cells,
# CFL=0.4 to t=0.2: mass conserved to 0.46875 (exact), no overshoot.

No basis functions means no separate Basis functions page for this one — the mesh alone determines everything. A nonlinear diffusion coefficient is handled the same way as for FEM and DG: see Nonlinear solvers.