Boundary conditions¶
Conditions are declared by boundary name, as on paper; Conatus decides how each one enters the discrete system and prints that decision.
import conatus as cn
m = cn.Model(cn.Box(x=cn.periodic(2.0), z=cn.walls(-0.5, 0.5)), name="convection", Ra=2500, Pr=1)
u, p, T = m.velocity("u"), m.pressure("p"), m.scalar("T")
m.at("bottom", u=0, T=+0.5) # no slip, hot plate
m.at("top", u=0, T=-0.5) # no slip, cold plate
os_ = cn.Model(cn.Box(y=cn.walls(-1, 1)), name="Orr-Sommerfeld", Re=1e4, alpha=1.0)
v = os_.scalar("v")
os_.at("walls", "v = 0", "dy(v) = 0") # conditions as text, on both walls
Keywords set an unknown to a value (a number, or a callable of the boundary’s coordinates). Text
conditions accept any equation that involves one unknown at that boundary: derivatives
(dz(T) = 0), Robin conditions (dz(T) + Bi*T = 0), values given by a parameter. Boundary names
come from the geometry (Geometries): walls stands for both ends.
How many conditions¶
Each unknown needs as many conditions as its order along the bounded coordinate, times the number
of walls divided by two. A temperature with \(\nabla^2 T\) between two plates needs two; the
Orr–Sommerfeld equation, fourth order in \(y\), needs four (two per wall); in a disk, a ball or a
pipe the centre is not a boundary, so a second-order unknown needs one condition at wall.
A pressure that only appears under grad(p) needs none: Conatus recognises the incompressibility
structure (below).
A missing condition is refused before anything is built:
import conatus as cn
m = cn.Model(cn.Box(x=cn.walls(0, 1)), name="heat", kappa=1.0)
T = m.scalar("T")
m.equations("dt(T) - kappa*lap(T) = 0")
m.at("x=0", T=0)
try:
m.simulate(N=16, scheme="SBDF2", dt=1e-3)
except cn.errors.BoundaryConditionError as err:
print(err)
BoundaryConditionError T: 1 boundary condition(s) for 2 needed (order 2 along x, 2 wall(s))
where: at(...) calls: at('x=0', T = 0)
why: missing conditions leave the discrete system singular
fix: declare 2 condition(s) on T (boundaries: 'bottom', 'top', 'x=0', 'x=1', 'walls')
(suggestion only: Conatus does not change your problem for you)
What Conatus does with them¶
Conatus imposes conditions with the tau method: each condition adds one unknown constant along the wall, which enters one equation through a fixed polynomial. You never write these terms. They are placed by one deterministic rule:
each condition constrains one unknown;
it goes into the equation where that unknown has its highest order along the bounded coordinate;
inside that equation, the conditions fill the highest modes in the order you declared them.
m.explain() lists every placement:
Boundary conditions -> tau terms (automatic, deterministic):
T = 0 at walls (left: x=0): tau_T1 lifted into mode N_x-1 of [0] eq0 -- T has its highest x-order (2) there
T = 0 at walls (right: x=1): tau_T2 lifted into mode N_x-2 of [0] eq0 -- T has its highest x-order (2) there
Incompressibility is recognised from the structure of the equations, never from a name: a
scalar that appears only under grad(·) and has no condition, paired with the single equation
without a time derivative that contains div(u). Conatus then fixes the mean pressure
(integ(p) = 0) and adds the divergence correction of the geometry. m.pressure("p") only asks
Conatus to check that this structure is there.
When the rule cannot decide¶
Conatus refuses rather than guesses. Each message names the condition and the way out:
Situation |
What to write |
|---|---|
the unknown has its highest order in two equations |
|
a condition involves two unknowns, or none |
split it, or write the problem in the expert layer |
too many conditions for an unknown |
remove one (the extra one would perturb an equation) |
a scalar condition on one component of a vector unknown |
the expert layer; a stress-free wall is |
an unknown boundary name |
one of the names listed by |
Writing lift(...) yourself in a native model is refused: the placement is Conatus’s job, and the
expert layer is there when you want to do it by hand (Tau terms by hand).
Spurious modes in eigenvalue problems¶
Truncation creates a few spurious eigenvalues (very large, sometimes unstable). Conatus does not
filter them with a hidden threshold. m.eigenvalues(N=64, drift=96, drift_tol=1e-6) solves the problem again
at 96 modes and keeps only the eigenvalues found at both resolutions within the drift tolerance
(yours, required: it decides which modes are returned); the result says how many were removed. A physical filter is allowed, but it is yours and it is logged:
res.select(np.flatnonzero(abs(res.values) < 2), why="|c| < 2"). Worked example: 1. A first eigenvalue problem: Orr–Sommerfeld.
Free-slip walls in the native layer¶
In the native layer (cn.Model) conditions are written by boundary name and the taus are
placed for you. A stress-free wall is one declaration on the velocity:
import conatus as cn
box = cn.Box(x=cn.periodic(2.0), z=cn.walls(0, 1))
free = cn.Model(box, name="RB stress-free", Ra=700, Pr=1)
u, T = free.velocity("u"), free.scalar("T")
free.at("walls", u=cn.free_slip, T=0) # u·n = 0 and dz(u_t) = 0 at both walls
rigid = cn.Model(box, name="RB rigid", Ra=2000, Pr=1)
u, T = rigid.velocity("u"), rigid.scalar("T")
rigid.at("walls", u=cn.no_slip, T=0) # the same as u=0, named for symmetry
slip = cn.Model(box, name="partial slip", nu=1.0)
u = slip.velocity("u")
slip.pressure("p")
slip.equations("dt(u) + grad(p) - nu*lap(u) = -u@grad(u)", "div(u) = 0")
slip.at("bottom", u=cn.navier_slip(0.1)) # u·n = 0, u_t + 0.1 dn(u_t) = 0 (Navier slip length)
slip.at("top", u=cn.no_slip)
slip.simulate(N=(16, 16), scheme="RK443", dt=1e-3).run(steps=2)
For a plane wall of normal \(n = e_z\) it is compiled as one vector condition on the single unknown \(u\),
whose normal component is \(u_z = 0\) and whose tangential components are \(\partial_z u_x = 0\)
(\(\partial_z u_y = 0\) in 3D). It therefore reuses the vector-tau rule unchanged: one vector tau per
wall in the equation where \(u\) has its highest \(z\)-order (momentum), and the divergence tau of the
Cartesian recipe in the continuity equation. m.explain() prints, for the stress-free
Rayleigh–Bénard model of Bases and domains (Sine/cosine walls) with Chebyshev walls:
u: free-slip (u·n = 0, dz(u_t) = 0) at walls (left: z=0): tau_u1 lifted into mode N_z-1 of [0] momentum -- u has its highest z-order (2) there; Chebyshev lift into the last modes
u: free-slip (u·n = 0, dz(u_t) = 0) at walls (right: z=1): tau_u2 lifted into mode N_z-2 of [0] momentum -- u has its highest z-order (2) there; Chebyshev lift into the last modes
divergence tau dot(ez, lift(tau_u2, z, -1)) (the velocity tau of the lowest lifted mode)
Validation: the Rayleigh–Bénard onset between stress-free
isothermal walls, \(Ra_c = 27\pi^4/4\) at \(k_c = \pi/\sqrt{2}\) (Rayleigh 1916; Chandrasekhar 1961),
and the exact growth rate of the mode \(n = 1\) to \(\sim 10^{-12}\) at \(N_z = 16\); the same model
text with cn.walls(0, 1, basis="sincos") needs no tau at all (Bases and domains, Sine/cosine
walls).
On curved walls (disk, annulus, pipe, ball, shell) m.at("wall", u=cn.free_slip) (or "walls",
"inner", "outer") means \(u_r = 0\) and a vanishing tangential traction,
\(r\,\partial_r(u_t/r) = 0\), written without any division by \(r\) as one vector condition per wall,
\(R\,P_t\cdot\partial_r u + K\cdot u = 0\) with \(P_t = I - e_r e_r\) and \(K = e_r e_r - P_c\) (\(P_c\) projects on
the curved tangential directions; along the pipe axis the condition is \(\partial_r u_z = 0\)). The
taus are those of no-slip. Checks: a solid-body rotation is
a steady solution (drift \(4\times10^{-15}\) in the ball, \(2\times10^{-14}\) in the shell over 100 steps,
while no-slip does not even admit it), Stokes decay rates match their Bessel closed forms to
\(2\times10^{-12}\) (disk, pipe, ball), \(8\times10^{-13}\) (annulus), \(10^{-10}\) (shell), and a Navier–Stokes run
in the ball and the shell matches Dedalus with the conditions of its ball convection example to
\(3\times10^{-13}\) and \(9\times10^{-13}\). The rank report flags the rigid rotations (groups \(\ell=1\) or
\(m=0\)): a physical kernel, angular momentum is conserved with stress-free walls.
Interfaces, half lines and the whole line¶
On a compound interval (cn.walls(a, c, b)) you declare the wall conditions only. For each
unknown of order \(q\) the native layer adds, at every interface, the continuity of the value and
of the derivatives up to \(q-1\), written with one-sided traces, and gives each piece \(q\) tau terms
in its last modes; explain() lists them (see Bases and domains). In the IR the same conditions read
import numpy as np
from conatus.expert import (LBVP, CartesianCoordinates, CompoundChebyshev, Domain, Field, diff,
discretize, interp, lap, lift)
cs = CartesianCoordinates("x"); x = cs["x"]
b = CompoundChebyshev(x, (24, 24), (-1, 0.0, 1), dealias=1) # two pieces, interface x = 0
D = Domain(cs, [b])
u, f = Field("u", D), Field("f", D)
t01, t02, t11, t12 = (Field(name, D.without(x)) for name in ("t01", "t02", "t11", "t12"))
pb = LBVP([u, t01, t02, t11, t12], name="Poisson on two pieces")
pb.add_equation((lap(u) + lift(t01, b, -1, piece=0) + lift(t02, b, -2, piece=0)
+ lift(t11, b, -1, piece=1) + lift(t12, b, -2, piece=1), f))
pb.add_equation((interp(u, x, 0.0, side="above") - interp(u, x, 0.0, side="below"), 0))
pb.add_equation((interp(diff(u, x), x, 0.0, side="above")
- interp(diff(u, x), x, 0.0, side="below"), 0))
pb.add_equation((interp(u, x, "left"), 0))
pb.add_equation((interp(u, x, "right"), 0))
sol = discretize(pb).solve({f: lambda X: -np.pi**2 * np.sin(np.pi * X)}) # u = sin(pi x)
and a trace at an interface without side= is refused (it is one-sided there). The banded solver
keeps, for each piece, as many pinned columns as that piece has taus (otherwise its banded part
would split into non-square blocks).
cn.half_line(...) has one wall ("wall"): an equation of order \(q\) takes \(q/2\) conditions
there, exactly like the radial axis of a disk. cn.line(...) has no boundary: a condition on it
is refused (no boundary), and so is a trace at \(\pm\infty\) in the IR (the Hermite functions
decay; there is nothing to impose).
Boundary values that depend on time¶
t is the time of a time-dependent model. A boundary value may be any expression of t, the
parameters and the given fields, written as text or as a function of t:
import numpy as np
import conatus as cn
m = cn.Model(cn.Box(x=cn.periodic(2 * np.pi), z=cn.walls(0, 1)), name="moving walls")
u, T = m.scalar("u"), m.scalar("T")
m.given(S=lambda x: np.sin(x), C=lambda x: np.cos(x))
m.equations("dt(u) - lap(u) = 0", "dt(T) - lap(T) = 0")
m.at("bottom", u="S*cos(t) - C*sin(t)", # sin(x - t): separable form with given fields
T="cos(t)") # keyword value as text
m.at("top", "u = 0.5*sin(2*t)", # condition text
T=lambda t: np.exp(-t)) # numpy functions of t build the expression
m.simulate(N=(16, 16), scheme="RK222", dt=1e-2).run(steps=2)
A function of t and of coordinates (lambda x, t: np.sin(x - t)) is refused with the
separable form to write instead: a coordinate itself is not smooth data on a periodic axis. t in
an implicit term (t*u on the left) or in a model without dt() is refused. Such values are
imposed by default with the stage values that the scheme gives for \(g'(t)\), exact here
(bc_time="auto" → "rk"), so that the Runge–Kutta schemes keep their order (RK443 2.8 instead
of 2.0 with \(g\) at the stage times); bc_time="stage" and bc_time="step" (Dedalus) are the other
conventions. See Simulations and time steppers. In the expert layer the same holds for
pb.add_bc((interp(u, y, 0), cos(t))) with IVPSolver(..., time_parameter="t").
On a disk, an annulus, a pipe or a sphere, a given field of the wall coordinate used in a wall
condition (m.given(g=lambda phi, r: ...), m.at("wall", "T = g")) is replaced by its trace at the
wall, evaluated from its function (exact, listed by explain()); under a derivative (dr(g)) it
is refused: declare the wall value as a function of the other coordinates.