RasmahRasmah

Two-Phase Flow: Level Set and Surface Tension

println("Julia ", VERSION)
Julia 1.12.6

Overview

A two-phase flow carries two immiscible fluids separated by an interface — an air bubble in water, an oil droplet in a carrier, a rising gas slug. Rasmah models it with a level-set method: a signed-distance field φ marks which phase a point is in (φ < 0 inside the droplet, φ > 0 outside), and the continuum surface force (CSF) turns the interfacial surface tension into a body force f_σ = −σ κ ∇φ δ(φ) concentrated on the interface.

Here σ is the surface-tension coefficient, κ = ∇·(∇φ/|∇φ|) the interface curvature, and δ a smoothed Dirac delta localising the force to the interface.

The Laplace pressure jump

The classic test for any surface-tension model is a static droplet: with no flow, the two momentum terms −∇p and f_σ must balance, so the pressure jumps across the interface by exactly

\[\Delta p = p_{\text{in}} - p_{\text{out}} = \frac{\sigma}{R}\]

for a 2D circle of radius R (Laplace–Young; the 3D sphere is 2σ/R). This is an exact prediction, so it’s the perfect verification.

Building the droplet problem

droplet_levelset(xc, R) gives the analytic signed distance of a circle, and two_phase_operator assembles the (u, p) operator from the two fluids’ densities/viscosities and the surface tension. The droplet is stationary, so u = 0 on the boundary and the convective term vanishes:

using Rasmah
using LinearAlgebra

function square(nx, ny)
    nv = (nx + 1) * (ny + 1)
    verts = Matrix{Float64}(undef, 3, nv)
    for j in 0:ny, i in 0:nx
        idx = i + j * (nx + 1) + 1
        verts[1, idx] = i / nx; verts[2, idx] = j / ny
    end
    faces = Matrix{Int}(undef, 3, 2nx * ny)
    k = 0
    for j in 0:(ny - 1), i in 0:(nx - 1)
        a = i + j * (nx + 1) + 1; b = a + 1; c = a + nx + 1; d = c + 1
        k += 1; faces[:, k] = [a, b, c]
        k += 1; faces[:, k] = [b, d, c]
    end
    TriangleMesh(verts, faces)
end

m  = square(24, 24)
U  = FESpace(m, ReferenceFE(2, 2); valuetype = VectorValue{2})   # velocity
P  = FESpace(m, ReferenceFE(2, 1))                               # pressure

R, σ = 0.2, 1.0
op = two_phase_operator(U, P, 1.0, 1.0, 0.1, 0.1, σ, droplet_levelset([0.5, 0.5], R))
FEOperator{MultiFieldFESpace{Tuple{FESpace{TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}, ReferenceFE, VectorValue{2}, Float64}, FESpace{TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}, ReferenceFE, ScalarValue, Float64}}, BlockStyle, Tuple{Matrix{Int64}, Matrix{Int64}}}, MultiFieldFESpace{Tuple{FESpace{TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}, ReferenceFE, VectorValue{2}, Float64}, FESpace{TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}, ReferenceFE, ScalarValue, Float64}}, BlockStyle, Tuple{Matrix{Int64}, Matrix{Int64}}}, Vector{Rasmah.ResidualTerm}, Nothing}(MultiFieldFESpace{Tuple{FESpace{TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}, ReferenceFE, VectorValue{2}, Float64}, FESpace{TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}, ReferenceFE, ScalarValue, Float64}}, BlockStyle, Tuple{Matrix{Int64}, Matrix{Int64}}}((FESpace{TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}, ReferenceFE, VectorValue{2}, Float64}(TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}([0.0 0.041666666666666664 … 0.9583333333333334 1.0; 0.0 0.0 … 1.0 1.0; 0.0 5.0e-324 … 1.83987082e-315 0.0], [1 2 … 599 600; 2 27 … 600 625; 26 26 … 624 624], nothing), ReferenceFE(2, :simplex, 2), VectorValue{2}, [1 2 … 599 600; 2 27 … 600 625; … ; 627 628 … 2397 2399; 628 630 … 2399 2401], [1 3 … 1197 1199; 2 4 … 1198 1200; … ; 1255 1259 … 4797 4801; 1256 1260 … 4798 4802], [0.0 0.041666666666666664 … 1.0 0.9791666666666667; 0.0 0.0 … 0.9791666666666667 1.0], 2401), FESpace{TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}, ReferenceFE, ScalarValue, Float64}(TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}([0.0 0.041666666666666664 … 0.9583333333333334 1.0; 0.0 0.0 … 1.0 1.0; 0.0 5.0e-324 … 1.83987082e-315 0.0], [1 2 … 599 600; 2 27 … 600 625; 26 26 … 624 624], nothing), ReferenceFE(2, :simplex, 1), ScalarValue, [1 2 … 599 600; 2 27 … 600 625; 26 26 … 624 624], [1 2 … 599 600; 2 27 … 600 625; 26 26 … 624 624], [0.0 0.041666666666666664 … 0.9583333333333334 1.0; 0.0 0.0 … 1.0 1.0], 625)), BlockStyle(), [0, 4802, 5427], Int64[], 5427, ([1 3 … 1197 1199; 2 4 … 1198 1200; … ; 1255 1259 … 4797 4801; 1256 1260 … 4798 4802], [4803 4804 … 5401 5402; 4804 4829 … 5402 5427; 4828 4828 … 5426 5426])), MultiFieldFESpace{Tuple{FESpace{TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}, ReferenceFE, VectorValue{2}, Float64}, FESpace{TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}, ReferenceFE, ScalarValue, Float64}}, BlockStyle, Tuple{Matrix{Int64}, Matrix{Int64}}}((FESpace{TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}, ReferenceFE, VectorValue{2}, Float64}(TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}([0.0 0.041666666666666664 … 0.9583333333333334 1.0; 0.0 0.0 … 1.0 1.0; 0.0 5.0e-324 … 1.83987082e-315 0.0], [1 2 … 599 600; 2 27 … 600 625; 26 26 … 624 624], nothing), ReferenceFE(2, :simplex, 2), VectorValue{2}, [1 2 … 599 600; 2 27 … 600 625; … ; 627 628 … 2397 2399; 628 630 … 2399 2401], [1 3 … 1197 1199; 2 4 … 1198 1200; … ; 1255 1259 … 4797 4801; 1256 1260 … 4798 4802], [0.0 0.041666666666666664 … 1.0 0.9791666666666667; 0.0 0.0 … 0.9791666666666667 1.0], 2401), FESpace{TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}, ReferenceFE, ScalarValue, Float64}(TriangleMesh{Matrix{Float64}, Matrix{Int64}, Nothing}([0.0 0.041666666666666664 … 0.9583333333333334 1.0; 0.0 0.0 … 1.0 1.0; 0.0 5.0e-324 … 1.83987082e-315 0.0], [1 2 … 599 600; 2 27 … 600 625; 26 26 … 624 624], nothing), ReferenceFE(2, :simplex, 1), ScalarValue, [1 2 … 599 600; 2 27 … 600 625; 26 26 … 624 624], [1 2 … 599 600; 2 27 … 600 625; 26 26 … 624 624], [0.0 0.041666666666666664 … 0.9583333333333334 1.0; 0.0 0.0 … 1.0 1.0], 625)), BlockStyle(), [0, 4802, 5427], Int64[], 5427, ([1 3 … 1197 1199; 2 4 … 1198 1200; … ; 1255 1259 … 4797 4801; 1256 1260 … 4798 4802], [4803 4804 … 5401 5402; 4804 4829 … 5402 5427; 4828 4828 … 5426 5426])), Rasmah.ResidualTerm[Rasmah.ResidualTerm{Rasmah.var"#two_phase_residual##0#two_phase_residual##1"{Float64, Float64, Float64, Float64, Float64, Rasmah.var"#droplet_levelset##0#droplet_levelset##1"{Vector{Float64}, Float64}, Float64}}(1, Rasmah.var"#two_phase_residual##0#two_phase_residual##1"{Float64, Float64, Float64, Float64, Float64, Rasmah.var"#droplet_levelset##0#droplet_levelset##1"{Vector{Float64}, Float64}, Float64}(1.0, 1.0, 0.1, 0.1, 1.0, Rasmah.var"#droplet_levelset##0#droplet_levelset##1"{Vector{Float64}, Float64}([0.5, 0.5], 0.2), 0.01)), Rasmah.ResidualTerm{Rasmah.var"#two_phase_residual##2#two_phase_residual##3"}(2, Rasmah.var"#two_phase_residual##2#two_phase_residual##3"())], nothing)

Fix u = 0 on the boundary and pin one pressure degree of freedom (to set the pressure level), then Newton-solve the static droplet:

walls = vcat(dofs(U, BoundingBox([0.0, 0.0], [1.0, 0.0])),
             dofs(U, BoundingBox([0.0, 0.0], [0.0, 1.0])),
             dofs(U, BoundingBox([1.0, 0.0], [1.0, 1.0])),
             dofs(U, BoundingBox([0.0, 1.0], [1.0, 1.0])))
nU = ndofs(U)
fixed = vcat(walls, nU .+ [1])
u = solve(op, DofBC(fixed; g = zeros(length(fixed))), zeros(nU + ndofs(P)); method = :newton)
p = u[(nU + 1):end]
625-element Vector{Float64}:
  0.0
  0.0001582111883969454
  0.0005184658151742731
  0.0005770999035259059
  0.0002698684336812754
 -0.0003542207954214097
 -0.001176533452733736
 -0.002027550250854811
 -0.0027068981586691136
 -0.0030146936901211435
  ⋮
 -0.020167932580407427
 -0.019768929934626114
 -0.01949709338579158
 -0.019309719109108076
 -0.019219804603866707
 -0.019248548783790474
 -0.01939124104021701
 -0.019597955647172042
 -0.01965183392984351

Verifying the Laplace jump

The pressure dofs are the mesh vertices (P1), so we read p at the vertex nearest the droplet centre (inside) and one far from it (outside):

center = argmin([sum(abs2, m.vertices[1:2, i] .- [0.5, 0.5]) for i in 1:size(m.vertices, 2)])
corner = argmin([sum(abs2, m.vertices[1:2, i] .- [0.05, 0.05]) for i in 1:size(m.vertices, 2)])
println("p_in − p_out = ", round(p[center] - p[corner], digits = 4))
println("σ / R        = ", σ / R)
p_in − p_out = 4.9237
σ / R        = 5.0

The computed jump converges to σ/R as the mesh refines and the interface width shrinks. You can visualise the pressure field with vtk(m; field = p).

Next steps

  • Navier–Stokes — the single-phase limit (σ = 0) this model generalises.
  • Stokes flow — the creeping-flow saddle system under the two-phase operator.