Equations¶
This page provides an overview of writing equations with the Recipe API. The spatial operators the equations draw on are covered in operators.
The equation interface¶
@spec.equation(writes=..., reads=...) registers a closure as the
equation for one field. The running example from
the Recipe API:
@spec.equation(writes="u", reads=("u",))
def heat(q):
return q.ops.laplacian(q.u) # du/dt = lap(u)
writesnames the field the equation determines.readsnames every field and global the closure depends on.namelabels the equation and defaults to the function's name.
The access object¶
Inside the closure, q is the access object:
q.<field>is the field's current state. On finite-element domains it is a quadrature-native field object, covered with operators.q.<global>is a global's value.q.p.<name>is a declared parameter andq.fn.<name>a declared function, called explicitly asq.fn.<name>(q.t).q.tis the time andq.xthe node coordinates.q.opsis the domain's operator bank.q.dt(field)is the field's time derivative, discussed below.q.<alias>reads a value coupled in from another component, discussed below.
Pointwise interpretation¶
An equation closure is ordinary JAX code. An expression that does not call an operator is interpreted pointwise, evaluated independently at every node or cell, so arbitrary nonlinear functions of the state apply directly. Spatial coupling between nodes enters through the operator bank:
@spec.equation(writes="u", reads=("u",))
def reaction_diffusion(q):
return q.ops.laplacian(q.u) + q.u - q.u**3 # du/dt = lap(u) + u - u^3
The two forms¶
An equation closure has two readings, distinguished by what it returns. Returning a plain array states the mass-matrix form
with the return value read as the right-hand side
\(d(\text{field})/dt = \text{value}\). Returning an expression that
contains q.dt(field) states the fully implicit form
with the expression read as \(\text{expression} = 0\). The running example in implicit form:
@spec.equation(writes="u", reads=("u",))
def heat(q):
return q.dt("u") - q.ops.laplacian(q.u) # du/dt - lap(u) = 0
Both forms build the same problem, and they correspond to the two problem types the engines accept (the problem interface). The implicit form exists for what the mass-matrix form cannot state: mass terms that are non-diagonal or nonlinear in the derivative.
The derivative algebra¶
q.dt(field) composes through a small linear algebra:
- addition and subtraction of derivative terms,
- scaling by any derivative-free value, including values computed from the state, the time, or a declared parameter,
- application of a declared
ds.MassOperatorfor non-diagonal mass blocks, - structural
gather/reduce/scattermaps with static indices, discussed below.
A variable heat capacity is a scaled derivative:
return rho_c * q.dt("T") - q.ops.div_flux(k, q.T) # rho_c dT/dt = div(k grad T)
rho_c is a scalar, a nodal array or, on a mesh, one value per cell.
The mass is exact on grids and lumped on meshes, and boundary fluxes on
T keep their scale.
A banded mass block applies a declared operator to the writing field's derivative:
op = ds.MassOperator.sparse(rows, cols, values, n=n)
@spec.equation(writes="u", reads=("u",))
def balance(q):
return op(q.dt("u")) - flux(q) # M @ du/dt = flux
An expression linear in the derivative lowers to the mass form with the exact sparse coefficient structure of the expression. A nonlinear dependence on the derivative lowers to the general implicit residual form.
Field roles and derivative use must agree. A field declared
mass="differential" is expected under q.dt somewhere, and taking
q.dt of a field declared mass="algebraic" is a build error naming
the field and both roles.
Structural maps¶
Fields are plain arrays inside a closure, so selecting and summing
their entries uses ordinary array operations (q.u[indices],
weights @ q.u). A derivative expression is symbolic rather than an
array, so it carries its own three structural maps, with static
indices:
q.dt(field).gather(indices)selects entries of the derivative.q.dt(field).reduce(weights)forms the weighted sum of its entries, a single row.q.dt(field).scatter(indices, out_size)places its entries into a larger row block.
A mean-rate closure row combines another field's derivative:
@spec.equation(writes="rate", reads=("rate",))
def rate_row(q):
return q.rate - q.dt("c").reduce(weights) # rate = weights . dc/dt
Multi-output equations¶
@spec.equations(writes=(...), reads=(...)) (plural) registers one
closure that writes several fields at once, returning a dict keyed by
field name. The closure runs once per residual call, for kernels that
produce residuals for several coupled fields in a single pass:
@spec.equations(writes=("c", "mu"), reads=("c", "mu", "phi"), name="kernel")
def _rhs(q):
return {
"c": -(q.c - 0.5) + q.mu + q.phi,
"mu": (q.mu - q.c) + 1e-3 * q.ops.laplacian(q.c),
}
Populations¶
Some models contain many copies of the same sub-model: the same
domain, fields, equations, and boundary conditions repeated count
times, differing only in parameter values.
spec.family(name, count=..., domain=..., params=...) declares such
a population once, as a member template:
members = spec.family(
"particles",
count=2,
domain=cartesian_grid_1d(2, 0.0, 1.0, method="fvm"),
params={"rate": [1.0, 2.0]},
)
members.field_decl("c", mass="differential")
@members.equation(writes="c", reads=("c",))
def member_decay(q, m):
return -m.rate * q.c
- The returned handle carries the member template:
members.field_decl(...)declares a member field,@members.equation(...)a member equation, andmembers.bc.flux(...)a member boundary condition. - A member equation closure takes two arguments, the access object
qand the member viewm, withm.<name>the member's entry of a per-member parameter array andm.member_indexits index. paramsholds the per-member parameter arrays (each of shape(count,), or a shared scalar). They are the family's differentiable and vmappable leaves, so a fit can learn them.stencil_radius=rdeclares how far a member kernel's Jacobian reaches on the member grid, in cells per axis. The default reach is one cell, which the built-in operators satisfy exceptdiv_advectunder"tvd"(the default) and"weno3", which read two cells: the build refuses those belowstencil_radius=2. A composedlaplacian(laplacian(c))also needs2, and there under-declaring is not detected and gives a structurally wrong sparse Jacobian. Over-declaring is safe.members.field_decl(name, mass="algebraic", shape=(1,))declares a per-member scalar, one algebraic unknown per member closed by its own member equation (a surface rate, an overpotential). It reads the member's profile fields,members.field(name).surface()projects its only value, and its Jacobian blocks against the profile fields are exact. A differential per-member scalar is refused.- The population lowers to one batched block: the member kernel is
traced once and evaluated over all members in a single
vmap, socountmembers cost one kernel. domainaccepts one shared member domain, or a sequence ofcountdomains declaring a ragged population.vmapbatching needs uniform member shapes, while numerical stability often ties the discretization to the member's size. We resolve this by bucketing: members sharing a domain form one uniform size bucket, and each bucket lowers to its own batched group.- Member initial conditions are supplied at
assemble_statetime under the key"<family>.<field>", andscale=/residual_scale=map member fields to their scales. - Members connect to the rest of the problem through the couplings discussed below.
A member boundary condition is a flux,
members.bc.flux(var, on=..., value= | value_fn= | fn=...), with the
same three forms a component takes
(boundary conditions).
Inside fn=lambda q, t: ..., q.<field> is the member field at the
selected boundary cell, q.m.<name> the member's entry of a
per-member parameter, such as a per-particle reaction rate, and
q.nodal.<field> the member's full field. The flux scatters onto the
selected boundary cells, member selectors name node indices directly,
and a condition the member kernel cannot assemble is refused at build.
Members of one family can also exchange with each other pairwise, through a graph abstraction. The edges are declared once, and one callback states the physics of an edge:
edges = np.stack((np.arange(count - 1), np.arange(1, count))) # a chain: 0-1, 1-2, ...
members.graph("chain", edges=edges, directed=False, weights=1.0)
@members.graph_exchange(graph="chain", writes="c", reads=("c",))
def exchange(source, target, edge):
return edge.weight * (source.c - target.c) # flow into the target
edgesis a(2, E)array holding each edge's source and target member index.- The callback receives the two endpoint members and the edge, with
source.<field>andtarget.<field>the fields named inreadsandedge.weightthe declared weight. It returns the flow into the target, and lowering scatters it with opposite signs onto the two endpoint rows, so every exchange conserves by construction. readsdeclares the endpoint fields the exchange sees, which fixes the exact cross-member Jacobian blocks.
Coupling components¶
Each registered domain, and each family, forms a component with its
own fields and equations. Where fields in different components couple, we express the access
patterns through spec.couple. Its keyword onto names the receiving
component, and at gives the node indices where the transferred values
live.
The running example for this section is solute exchange between oil and water. A solute diffuses along a water channel, and a population of oil droplets suspended in it exchanges solute with the water at each droplet's location.
The water channel is a one-dimensional finite-volume grid holding
the solute concentration c_w. We keep the handle spec.field
returns, because a coupling names its source by handle:
water = cartesian_grid_1d(64, 0.0, 1.0, method="fvm")
spec = ds.problem("oil_water")
spec.domain("water", water)
c_w = spec.field("c_w", mass="differential", scale=1.0)
The droplets are a population: each is its own small
one-dimensional domain holding an internal concentration c_o, with
a per-droplet exchange rate k:
droplets = spec.family(
"droplets",
count=2,
domain=cartesian_grid_1d(8, 0.0, 1e-3, method="fvm"),
params={"k": [1.0, 2.0]},
)
droplets.field_decl("c_o", mass="differential")
Each droplet sits at a physical location in the channel, and at
converts those locations into node indices, one per droplet, by
finding the channel cell nearest each location:
x_cells = water.node_coordinates()[:, 0] # cell-center coordinates
xs = np.array([0.2, 0.8]) # droplet locations
at = np.array([np.argmin(np.abs(x_cells - x)) for x in xs]) # nearest cell to each droplet
Gathering onto a family¶
Each droplet needs the water concentration at its own location.
spec.couple hands member m the source field's value at node
at[m], and the member closure lists the source in reads and
reads it by name:
spec.couple(c_w, onto="droplets", at=at)
@droplets.equation(writes="c_o", reads=("c_o", "c_w"))
def droplet_uptake(q, m):
return q.ops.laplacian(q.c_o) + m.k * (q.c_w - q.c_o)
Scattering onto a domain¶
The water sees the droplets in return: a scatter sends one value per member into the receiving field's rows at the same nodes.
droplets.couple(droplets.field("c_o").surface(), onto="water", at=at, reduce="sum")
The source is a member-field projection:
.surface(): the member's value at its outer surface, one per member..boundary(tag): the member's trace at a tagged boundary..at(idx): the member's value at a chosen node index.
reduce= merges members that land on the same receiving node:
"sum"(the default) adds them."mean"averages them."none"declares that every member has its own node.
Read-through aliases¶
When the oil phase is a second continuous layer over the same
channel grid rather than a population, the transfer between the two
domains is a read. spec.couple with as_= injects the source values under a new
name, read as q.<alias> in the receiving closures, while the unknown
stays owned by the source. Each oil node reads the
water concentration directly below it:
spec.domain("oil", cartesian_grid_1d(64, 0.0, 1.0, method="fvm"))
c_o = spec.field("c_o", domain="oil", mass="differential", scale=1.0)
spec.couple(c_w, onto="oil", at=np.arange(64), as_="c_w_below")
@spec.equation(writes="c_o", reads=("c_o", "c_w_below"))
def oil_phase(q):
return q.ops.laplacian(q.c_o) + K * (q.c_w_below - q.c_o)
Observations¶
spec.observe registers named outputs computed from the state.
Observations ride along the built problem as a callable for
post-processing and fitting, outside the residual:
spec.observe.scalar(name=..., fn=...)is a single scalar,spec.observe.field(name=..., fn=...)a spatially resolved output,spec.observe.spatial_mean(name=..., fn=...)the mean over the domain,spec.observe.boundary_mean(name=..., fn=..., tag=...)the mean over a tagged boundary.
For both means fn returns one value per node. spatial_mean weights
them by the cell volumes on a grid and the lumped mass on a mesh, and
boundary_mean by the face areas or facet measures of the tag.
Every verb also takes domain=, the domain the observation is evaluated
on. By default this is the domain whose fields fn reads, or the first
declared domain if fn reads no fields.
Each fn is a closure over the same access object q:
spec.observe.scalar(name="mean_u", fn=lambda q: jnp.mean(q.u))