Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions chapter2/monolithic_navierstokes_crp0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Monolithic Incompressible Navier–Stokes with CR–P0 (Steady Picard)

---

We solve the steady incompressible Navier–Stokes equations in a 2D channel using a **monolithic** formulation with **Crouzeix–Raviart (CR)** velocity and **DG-0** pressure. Dirichlet velocities on the left, top, and bottom are imposed **weakly via Nitsche’s method**, suitable for the nonconforming CR element.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
We solve the steady incompressible Navier–Stokes equations in a 2D channel using a **monolithic** formulation with **Crouzeix–Raviart (CR)** velocity and **DG-0** pressure. Dirichlet velocities on the left, top, and bottom are imposed **weakly via Nitsche’s method**, suitable for the nonconforming CR element.
We solve the steady incompressible Navier–Stokes equations in a 2D channel using
a **monolithic** formulation with [Crouzeix–Raviart](https://defelement.org/elements/crouzeix-raviart.html)
(CR) for the velocity and **DG-0** pressure.
We will impose Dirichlet conditions for the velocity field on the top, bottom and left part of the channel weakly using
**Nitsche's method** (see [Weak imposition of Dirichlet conditions for the Poisson equation](../chapter1/nitsche]) for more details).


---
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
---


## Key Parameters
- **Velocity space:** $ V_h = [\mathrm{CR}_1]^2 $ (nonconforming, facet-based)
- **Pressure space:** $ Q_h = \mathrm{DG}_0 $
- **Linearization:** Picard (frozen convection); start from Stokes baseline
- **Stabilization:** small pressure mass $ \varepsilon_p \int_\Omega p\,q\,dx $ removes nullspace
- **Solver:** PETSc GMRES + Jacobi
- **Output:** XDMF for ParaView
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- **Output:** XDMF for ParaView
- **Output:** {py:class}`VTXWriter<dolfinx.io.VTXWriter>` for Paraview

As the velocity field is discontinuous this is a more suitable space.


---

## Governing Equations

$$
-\nu \Delta \mathbf{u} + (\mathbf{u}\cdot\nabla)\mathbf{u} + \nabla p = \mathbf{f},
\quad \text{in } \Omega,
$$

$$
\nabla\cdot\mathbf{u} = 0, \quad \text{in } \Omega.
Comment on lines +22 to +27
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
-\nu \Delta \mathbf{u} + (\mathbf{u}\cdot\nabla)\mathbf{u} + \nabla p = \mathbf{f},
\quad \text{in } \Omega,
$$
$$
\nabla\cdot\mathbf{u} = 0, \quad \text{in } \Omega.
\begin{align}
-\nu \Delta \mathbf{u} + (\mathbf{u}\cdot\nabla)\mathbf{u} + \nabla p &= \mathbf{f} && \text{in } \Omega,\\
\nabla\cdot\mathbf{u} &= 0 && \text{in } \Omega.
\end{align}

$$

### Boundary Conditions
- Inlet (left): $ \mathbf{u} = \mathbf{u}_{\text{in}} $
- Walls (top/bottom): $ \mathbf{u} = \mathbf{0} $
- Outlet (right): natural (traction-free)

---

## Weak Formulation

For test functions $ \mathbf{v}, q $, find $ (\mathbf{u},p) $ such that:

$$
a_{\text{core}}(\mathbf{u},p;\mathbf{v},q)
= \nu (\nabla\mathbf{u}, \nabla\mathbf{v})_\Omega
- (p, \nabla\cdot\mathbf{v})_\Omega
+ (q, \nabla\cdot\mathbf{u})_\Omega,
$$

$$
a_{\text{conv}}(\mathbf{u};\mathbf{v}\mid\mathbf{u}_k)
= ((\mathbf{u}_k\cdot\nabla)\mathbf{u},\mathbf{v})_\Omega,
$$

$$
a_{\text{pm}}(p;q)
= \varepsilon_p (p,q)_\Omega.
Comment on lines +42 to +55
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
a_{\text{core}}(\mathbf{u},p;\mathbf{v},q)
= \nu (\nabla\mathbf{u}, \nabla\mathbf{v})_\Omega
- (p, \nabla\cdot\mathbf{v})_\Omega
+ (q, \nabla\cdot\mathbf{u})_\Omega,
$$
$$
a_{\text{conv}}(\mathbf{u};\mathbf{v}\mid\mathbf{u}_k)
= ((\mathbf{u}_k\cdot\nabla)\mathbf{u},\mathbf{v})_\Omega,
$$
$$
a_{\text{pm}}(p;q)
= \varepsilon_p (p,q)_\Omega.
\begin{align}
a_{\text{core}}(\mathbf{u},p;\mathbf{v},q)
&= \nu (\nabla\mathbf{u}, \nabla\mathbf{v})_\Omega
- (p, \nabla\cdot\mathbf{v})_\Omega
+ (q, \nabla\cdot\mathbf{u})_\Omega,\\
a_{\text{conv}}(\mathbf{u};\mathbf{v}\mid\mathbf{u}_k)
&= ((\mathbf{u}_k\cdot\nabla)\mathbf{u},\mathbf{v})_\Omega\\
a_{\text{pm}}(p;q)&= \varepsilon_p (p,q)_\Omega.
\end{align}

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General comment here; Do you have a reference for this variational formulation?

$$

Right-hand side:
$$
\ell(\mathbf{v}) = (\mathbf{f},\mathbf{v})_\Omega.
$$

---

### Nitsche Boundary Terms

$$
\begin{aligned}
& -\nu\int_{\Gamma_D} (\nabla\mathbf{u}\,\mathbf{n})\cdot\mathbf{v}\,ds
-\nu\int_{\Gamma_D} (\nabla\mathbf{v}\,\mathbf{n})\cdot(\mathbf{u}-\mathbf{u}_D)\,ds
+ \alpha\frac{\nu}{h}\int_{\Gamma_D} (\mathbf{u}-\mathbf{u}_D)\cdot\mathbf{v}\,ds \\
&\quad - \int_{\Gamma_D} p\,\mathbf{n}\cdot\mathbf{v}\,ds
+ \int_{\Gamma_D} q\,\mathbf{n}\cdot(\mathbf{u}-\mathbf{u}_D)\,ds.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this term actually needed? Do you have a reference for this?

\end{aligned}
$$

### Under-relaxed Picard Update

$$
\mathbf{u}_{k+1}^{(\text{lin})}
= \theta\,\mathbf{u}_{k}^{(\text{new})}
+ (1-\theta)\,\mathbf{u}_{k}^{(\text{lin})}, \quad 0<\theta\le1.
$$

---

## Example Output (ParaView)

![Velocity field](chapter2/open_cavity_velocity_glyphs.png)

**Figure:** Velocity magnitude field with CR–P0 (Stokes baseline).
Color shows $ |\mathbf{u}| $ and arrows indicate flow direction and speed.
Inlet on the left, natural outlet on the right.

---

## Run Instructions (Docker)

```bash
docker run --rm -it -v "$PWD":"$PWD" -w "$PWD" \
ghcr.io/fenics/dolfinx/dolfinx:stable \
python3 chapter2/monolithic_navierstokes_crp0.py \
--uin 0.5 --nu 1e-2 --theta 0.5 --alpha 300
219 changes: 219 additions & 0 deletions chapter2/monolithic_navierstokes_crp0.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
from __future__ import annotations
import argparse
import numpy as np
from mpi4py import MPI
from dolfinx import fem, io, mesh
from dolfinx.fem import petsc
from petsc4py import PETSc
import ufl
from basix.ufl import element, mixed_element


def parse_args():
p = argparse.ArgumentParser(description="CR–P0 steady Navier–Stokes with Nitsche BCs (robust)")
p.add_argument("--nx", type=int, default=64)
p.add_argument("--ny", type=int, default=32)
p.add_argument("--Lx", type=float, default=2.0)
p.add_argument("--Ly", type=float, default=1.0)
p.add_argument("--nu", type=float, default=1e-2)
p.add_argument("--uin", type=float, default=1.0)
p.add_argument("--picard-it", type=int, default=20)
p.add_argument("--picard-tol", type=float, default=1e-8)
p.add_argument("--theta", type=float, default=0.5)
p.add_argument("--eps-p", type=float, default=1e-12) # tiny pressure mass to remove nullspace
p.add_argument("--stokes-only", action="store_true")
p.add_argument("--no-output", action="store_true")
p.add_argument("--alpha", type=float, default=400.0, help="Nitsche penalty (100–1000 recommended)")
p.add_argument("--outfile", type=str, default="chapter2/open_cavity_crp0.xdmf")
return p.parse_args()
Comment on lines +12 to +28
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The demo shouldn't use argparsers.



args = parse_args()
comm = MPI.COMM_WORLD
rank = comm.rank

# --- mesh ---
domain = mesh.create_rectangle(
comm,
[np.array([0.0, 0.0]), np.array([args.Lx, args.Ly])],
[args.nx, args.ny],
cell_type=mesh.CellType.triangle,
)
tdim = domain.topology.dim
fdim = tdim - 1
gdim = domain.geometry.dim
cell = domain.basix_cell()

# Ensure needed connectivities
domain.topology.create_connectivity(fdim, tdim)
domain.topology.create_connectivity(tdim, tdim)

# --- boundary facet tags (needed for ds with Nitsche) ---
left_facets = mesh.locate_entities_boundary(domain, fdim, lambda x: np.isclose(x[0], 0.0))
right_facets = mesh.locate_entities_boundary(domain, fdim, lambda x: np.isclose(x[0], args.Lx))
bottom_facets = mesh.locate_entities_boundary(domain, fdim, lambda x: np.isclose(x[1], 0.0))
top_facets = mesh.locate_entities_boundary(domain, fdim, lambda x: np.isclose(x[1], args.Ly))

# Tag: 1=left, 2=right, 3=bottom, 4=top
facet_indices = np.concatenate([left_facets, right_facets, bottom_facets, top_facets]).astype(np.int32)
facet_tags = np.concatenate([
np.full_like(left_facets, 1, dtype=np.int32),
np.full_like(right_facets, 2, dtype=np.int32),
np.full_like(bottom_facets, 3, dtype=np.int32),
np.full_like(top_facets, 4, dtype=np.int32),
])

# Create facet meshtags
if facet_indices.size == 0:
ft = mesh.meshtags(domain, fdim, np.array([], dtype=np.int32), np.array([], dtype=np.int32))
else:
order = np.argsort(facet_indices)
ft = mesh.meshtags(domain, fdim, facet_indices[order], facet_tags[order])
Comment on lines +58 to +71
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
facet_indices = np.concatenate([left_facets, right_facets, bottom_facets, top_facets]).astype(np.int32)
facet_tags = np.concatenate([
np.full_like(left_facets, 1, dtype=np.int32),
np.full_like(right_facets, 2, dtype=np.int32),
np.full_like(bottom_facets, 3, dtype=np.int32),
np.full_like(top_facets, 4, dtype=np.int32),
])
# Create facet meshtags
if facet_indices.size == 0:
ft = mesh.meshtags(domain, fdim, np.array([], dtype=np.int32), np.array([], dtype=np.int32))
else:
order = np.argsort(facet_indices)
ft = mesh.meshtags(domain, fdim, facet_indices[order], facet_tags[order])
num_facets_local = (
domain.topology.index_map(fdim).size_local
+ domain.topology.index_map(fdim).num_ghosts
)
facet_markers = np.full(num_facets_local, 0, dtype=np.int32)
facet_markers[left_facets] = 1
facet_markers[right_facets] = 2
facet_markers[bottom_facets] = 3
facet_markers[top_facets] = 4
facet_indices = np.flatnonzero(facet_markers)
ft = mesh.meshtags(domain, fdim, facet_indices, facet_markers[facet_indices])

I find this method cleaner


dx = ufl.Measure("dx", domain=domain)
ds = ufl.Measure("ds", domain=domain, subdomain_data=ft)

# --- spaces: CR–P0 (mixed) ---
V_el = element("CR", cell, 1, shape=(gdim,))
Q_el = element("DG", cell, 0)
W = fem.functionspace(domain, mixed_element([V_el, Q_el]))
(u, p) = ufl.TrialFunctions(W)
(v, q) = ufl.TestFunctions(W)

# --- parameters / RHS ---
nu = fem.Constant(domain, PETSc.ScalarType(args.nu))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
nu = fem.Constant(domain, PETSc.ScalarType(args.nu))
nu = fem.Constant(domain, dolfinx.default_scalar_type(args.nu))

Use dolfinx.default_scalar_type rather than PETSc.ScalarType here an in all other places.

eps_p = fem.Constant(domain, PETSc.ScalarType(args.eps_p))
zero = fem.Constant(domain, PETSc.ScalarType(0.0))
f_vec = ufl.as_vector((zero, zero)) # zero body force
Comment on lines +86 to +87
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
zero = fem.Constant(domain, PETSc.ScalarType(0.0))
f_vec = ufl.as_vector((zero, zero)) # zero body force
f_vec = fem.Constant(domain, np.zeros(mesh.geometry.dim, dtype=dolfinx.default_scalar_type))


# --- Picard state (for convection) ---
w = fem.Function(W) # holds (u_k, p_k)
u_k, p_k = w.split()

def build_forms():
n = ufl.FacetNormal(domain)
h = ufl.CellDiameter(domain)
alpha = PETSc.ScalarType(args.alpha)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrap alpha as a dolfinx.fem.Constant to avoid recompilation.


u_in = ufl.as_vector((PETSc.ScalarType(args.uin), PETSc.ScalarType(0.0)))
zero_vec = ufl.as_vector((PETSc.ScalarType(0.0), PETSc.ScalarType(0.0)))

# Core Stokes + tiny pressure mass
F = (
nu * ufl.inner(ufl.grad(u), ufl.grad(v)) * dx
- ufl.div(v) * p * dx
+ q * ufl.div(u) * dx
+ eps_p * p * q * dx
- ufl.inner(f_vec, v) * dx
)

# Add convection if not stokes-only
if not args.stokes_only:
F += ufl.inner(ufl.dot(u_k, ufl.nabla_grad(u)), v) * dx

# -------- Nitsche + boundary consistency terms on Dirichlet parts --------
# Left inlet (tag=1): u = u_in
F += (
- nu * ufl.inner(ufl.grad(u) * n, v) # consistency
- nu * ufl.inner(ufl.grad(v) * n, (u - u_in)) # symmetry
+ alpha * nu / h * ufl.inner(u - u_in, v) # penalty
- ufl.inner(p * n, v) # momentum BC consistency
+ q * ufl.inner(u - u_in, n) # continuity BC consistency: q * ((u-u_in)·n)
) * ds(1)
Comment on lines +116 to +122
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are using the same formulation here and below, I think we should make a convenience function

def nitsche_terms(g, tags):
    return (-nu * ufl.inner(ufl.grad(u)*n, v) - nu * ufl.inner(ufl.inner(grad(v) * n, (u - g)) + alpha * nu / h + ufl.inner(u - g, v) + .... ) * ds(tags)

so that we can do

F += nitsche_terms(zero_vec, (3, 4))
F += nitsche_terms(u_in, (1, ))


# Bottom (3) and Top (4): no-slip u = 0
for tag in (3, 4):
F += (
- nu * ufl.inner(ufl.grad(u) * n, v)
- nu * ufl.inner(ufl.grad(v) * n, (u - zero_vec))
+ alpha * nu / h * ufl.inner(u - zero_vec, v)
- ufl.inner(p * n, v)
+ q * ufl.inner(u - zero_vec, n)
) * ds(tag)
Comment on lines +125 to +132
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for tag in (3, 4):
F += (
- nu * ufl.inner(ufl.grad(u) * n, v)
- nu * ufl.inner(ufl.grad(v) * n, (u - zero_vec))
+ alpha * nu / h * ufl.inner(u - zero_vec, v)
- ufl.inner(p * n, v)
+ q * ufl.inner(u - zero_vec, n)
) * ds(tag)
F += (
- nu * ufl.inner(ufl.grad(u) * n, v)
- nu * ufl.inner(ufl.grad(v) * n, (u - zero_vec))
+ alpha * nu / h * ufl.inner(u - zero_vec, v)
- ufl.inner(p * n, v)
+ q * ufl.inner(u - zero_vec, n)
) * ds((3,4))


# Right (2) left as natural outlet (do-nothing)
return ufl.lhs(F), ufl.rhs(F)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return ufl.lhs(F), ufl.rhs(F)
return ufl.system(F)



def solve_once():
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a specific motivation behind not using dolfinx.fem.petsc.LinearProblem here?
It just means that one needs to do a lot of PETSc object management.

a, L = build_forms()
# No strong BCs with CR → Nitsche handles boundaries
A = petsc.assemble_matrix(fem.form(a), bcs=[])
A.assemble()
b = A.createVecRight(); b.set(0.0)
petsc.assemble_vector(b, fem.form(L))
# (No lifting/BC since bcs=[])
b.ghostUpdate(addv=PETSc.InsertMode.ADD, mode=PETSc.ScatterMode.REVERSE)

if rank == 0:
PETSc.Sys.Print(f" ||b||_2 = {b.norm():.3e}")

# Robust Krylov (no factorization)
ksp = PETSc.KSP().create(comm)
ksp.setOperators(A)
ksp.setType("gmres")
ksp.setTolerances(rtol=1e-8, max_it=1000)
ksp.getPC().setType("jacobi")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ksp.getPC().setType("jacobi")
ksp.getPC().setType("jacobi")
ksp.setErrorIfNotConverged(True)

Add this to ensure that the solver converges.
Note that with your current settings, I get:

Traceback (most recent call last):
  File "/root/shared/chapter2/monolithic_navierstokes_crp0.py", line 197, in <module>
    solve_once()
  File "/root/shared/chapter2/monolithic_navierstokes_crp0.py", line 178, in solve_once
    ksp.solve(b, x)
  File "petsc4py/PETSc/KSP.pyx", line 1782, in petsc4py.PETSc.KSP.solve
petsc4py.PETSc.Error: error code 82
[0] KSPSolve() at /usr/local/petsc/src/ksp/ksp/interface/itfunc.c:1090
[0] KSPSolve_Private() at /usr/local/petsc/src/ksp/ksp/interface/itfunc.c:913
[0] KSPSolve_GMRES() at /usr/local/petsc/src/ksp/ksp/impls/gmres/gmres.c:215
[0] KSPGMRESCycle() at /usr/local/petsc/src/ksp/ksp/impls/gmres/gmres.c:102
[0] Residual norm computed by GMRES recursion formula 7.75924e+12 is far from the computed residual norm 2.50084e+13 at restart, residual norm at start of cycle 1.73923e+13

while by changing options to:

    ksp.setType("preonly")
    ksp.setTolerances(rtol=1e-8, max_it=1000)
    ksp.getPC().setType("lu")
    ksp.getPC().setFactorSolverType("mumps")

I get a sensible solution

Image


# wrap dolfinx vector, solve, copy back
x = PETSc.Vec().createWithArray(w.x.array, comm=comm)
ksp.solve(b, x)
w.x.array[:] = x.getArray()
w.x.scatter_forward()
if rank == 0:
PETSc.Sys.Print(f" ||x||_2 = {x.norm():.3e}")


# --- Picard loop ---
theta = float(args.theta)
tol = float(args.picard_tol)
max_it = int(args.picard_it)

# for residual check (velocity only)
V_sub = W.sub(0)
V0, _ = V_sub.collapse()
u_prev_fun = fem.Function(V0)
u_prev_arr = np.zeros_like(u_prev_fun.x.array)

for it in range(1, max_it + 1):
solve_once()

# velocity from mixed solution
u_view = w.sub(0).collapse()
u_curr = u_view[0] if isinstance(u_view, tuple) else u_view
u_curr_arr = u_curr.x.array.copy()

diff = u_curr_arr - u_prev_arr
err = float(np.linalg.norm(diff)) if np.all(np.isfinite(diff)) else float("inf")

if rank == 0:
PETSc.Sys.Print(f"Picard {it:02d}: ||u - u_prev|| = {err:.3e}")
PETSc.Sys.Print(f" |u| range: [{np.abs(u_curr_arr).min():.3e}, {np.abs(u_curr_arr).max():.3e}]")

if not np.isfinite(err) or err < tol or args.stokes_only:
break

# under-relax linearization state u_k := θ u_curr + (1-θ) u_prev
relaxed = theta * u_curr_arr + (1.0 - theta) * u_prev_arr
u_k.x.array[:len(relaxed)] = relaxed
u_prev_arr = u_curr_arr.copy()

# --- output ---
if not args.no_output:
u_view = w.sub(0).collapse()
p_view = w.sub(1).collapse()
u_fun = u_view[0] if isinstance(u_view, tuple) else u_view
p_fun = p_view[0] if isinstance(p_view, tuple) else p_view

# interpolate to CG1 for cleaner XDMF viewing
Vout = fem.functionspace(domain, element("Lagrange", cell, 1, shape=(gdim,)))
Qout = fem.functionspace(domain, element("Lagrange", cell, 1))
u_out = fem.Function(Vout, name="u"); u_out.interpolate(u_fun)
p_out = fem.Function(Qout, name="p"); p_out.interpolate(p_fun)

with io.XDMFFile(domain.comm, args.outfile, "w") as xdmf:
xdmf.write_mesh(domain)
xdmf.write_function(u_out)
xdmf.write_function(p_out)
Comment on lines +209 to +217
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Vout = fem.functionspace(domain, element("Lagrange", cell, 1, shape=(gdim,)))
Qout = fem.functionspace(domain, element("Lagrange", cell, 1))
u_out = fem.Function(Vout, name="u"); u_out.interpolate(u_fun)
p_out = fem.Function(Qout, name="p"); p_out.interpolate(p_fun)
with io.XDMFFile(domain.comm, args.outfile, "w") as xdmf:
xdmf.write_mesh(domain)
xdmf.write_function(u_out)
xdmf.write_function(p_out)
Vout = fem.functionspace(domain, element("DG", cell, 1, shape=(gdim,)))
Qout = fem.functionspace(domain, element("Lagrange", cell, 1))
u_out = fem.Function(Vout, name="u")
u_out.interpolate(u_fun)
with io.VTXWriter(domain.comm, args.outfile, [u_out, p_fun]) as writer:
writer.write(0.0)

Use VTXWriter for proper outputting

if rank == 0:
PETSc.Sys.Print(f"Wrote {args.outfile}")