FOANSS, i.e. Fadoobaba's Open Advanced Navier-Stokes 🌬 Solver is now capable to simulate flow around curved geometries. In this example, flow around a circular cylinder ⭕ is presented. The main challenge in handling the curved boundaries 🗻 in discrete world is application of the Neumann boundary condition for pressure. The method employed in FOANSS is to use polar ⭗ coordinate system for normal derivative calculation. The idea came while yours's truly was developing FOAMNE i.e. Fadoobaba's Open Advanced 🔨 Mechanics 🧠 Neural Engine. More details can be read here.
In the CFD code, ∂p/dn is replaced by ∂p/dx * cos(θ) + ∂p/dy * sin(θ). After some trickery, the pressure on the curved surface can be represented by equation 1. Within equation 1, p is the pressure at current time-step. i and j are nodes in the x and y axis, respectively. cos_theta and sin_theta represent radial angles of the polar coordinate system (r θ).
((p[i + 1, j] + p[i - 1, j]) * cos_theta + (p[i, j + 1] + p[i, j - 1] * sin_theta)) / (2 * (cos_theta + sin_theta)) (1)
At first, flow around a circular cylinder at Reynolds number 100 is simulated. The accuracy of the results is measured using the Strouhal number. For Reynolds number 100, Strouhal number for flow around a circular cylinder is 0.16. The Strouhal number obtained from the code on a coarse mesh, i.e. only 50 cells per cylinder diameter, is 0.17. This is within 8% of the published data. The Strouhal number will never reach 0.16 as the mesh is cartesian and the cylinder surface is represented as a stairstep. Still, very good for teaching purposes! The results are shown in Fig. 1. It can be seen that the vortex shedding phenomenon is calculated relatively accurately. A remainder that you are reading a blog, not a Q1 journal 😃. The vorticity plot is shown in Fig. 2.
#%% import libraries
import numpy as np
import matplotlib.pyplot as plt
#%% define spatial and temporal grids
l_square = 1 # length of square or diamater of circle
h = l_square / 50 # grid spacing
dt = 0.0001 # time step
L = 15 # domain length
D = 10 # domain depth
Nx = round(L / h) + 1 # grid points in x-axis
Ny = round(D / h) + 1 # grid points in y-axis
nu = 1 / 100 # kinematic viscosity
Uinf = 1 # free stream velocity / inlet velocity / lid velocity
cfl = dt * Uinf / h # cfl number
travel = 2 # times the disturbance travels entire length of computational domain
TT = travel * L / Uinf # total time
ns = int(TT / dt) # number of time steps
Re = round(l_square * Uinf / nu) # Reynolds number
rho = 1 # fluid density
#%% initialize flowfield
u = np.ones((Nx, Ny)) # x-velocity
v = np.zeros((Nx, Ny)) # y-velocity
p = np.zeros((Nx, Ny)) # pressure
x = np.linspace(0, L, Nx) # x-axis vector
y = np.linspace(0, D, Ny) # y-axis vector
XX, YY = np.meshgrid(x, y, indexing="ij") # grid for circle boundary
#%% create circle
x_c = 5 # circle center y
y_c = 5 # circle center y
radius = 0.5 # circle radius
mask = (XX - x_c)**2 + (YY - y_c)**2 <= radius**2 # circular region
tol = 0.125 * h # tolerance for boundary points
circle_boundary = np.abs(np.sqrt((XX - x_c)**2 + (YY - y_c)**2) - radius) <= tol # boundary mask
boundary_indices = np.argwhere(circle_boundary) # circle boundary
# compute radial distances and angles (polar coordinates)
r_values = []
cos_theta_values = []
sin_theta_values = []
for idx in boundary_indices:
i, j = idx
r = np.sqrt((XX[i, j] - x_c)**2 + (YY[i, j] - y_c)**2)
cos_theta = (XX[i, j] - x_c) / r
sin_theta = (YY[i, j] - y_c) / r
r_values.append(r)
cos_theta_values.append(cos_theta)
sin_theta_values.append(sin_theta)
# define quadrant masks (circle is divided in 4 parts) and corner points
quadrant1 = (XX > x_c) & (YY > y_c) & circle_boundary
quadrant2 = (XX > x_c) & (YY < y_c) & circle_boundary
quadrant3 = (XX < x_c) & (YY < y_c) & circle_boundary
quadrant4 = (XX < x_c) & (YY > y_c) & circle_boundary
top_pt= (XX == x_c) & (YY > y_c) & circle_boundary
bottom_pt = (XX == x_c) & (YY < y_c) & circle_boundary
left_pt = (XX < x_c) & (YY == y_c) & circle_boundary
right_pt = (XX > x_c) & (YY == y_c) & circle_boundary
#%% Solve 2D Navier-Stokes equations
for nt in range(ns):
pn = p.copy()
p[1:-1, 1:-1] = (pn[2:, 1:-1] + pn[:-2, 1:-1] + pn[1:-1, 2:] + pn[1:-1, :-2]) / 4 - h * rho / (8 * dt) * (u[2:, 1:-1] - u[:-2, 1:-1] + v[1:-1, 2:] - v[1:-1, :-2]) # pressure
# boundary conditions for pressure
p[0, :] = p[1, :] # dp/dx = 0 at x = 0
p[-1, :] = 0 # p = 0 at x = L
p[:, 0] = p[:, 1] # dp/dy = 0 at y = 0
p[:, -1] = p[:, -2] # dp/dy = 0 at y = D
p[mask] = 0 # circle
# apply Neumann boundary condition for pressure in polar coordinates
p[quadrant1] = (((p[i + 1, j] + p[i - 1, j]) * cos_theta) + ((p[i, j + 1] + p[i, j - 1]) * sin_theta)) / (2 * (cos_theta + sin_theta))
p[quadrant2] = (((p[i + 1, j] + p[i - 1, j]) * cos_theta) + ((p[i, j + 1] + p[i, j - 1]) * sin_theta)) / (2 * (cos_theta + sin_theta))
p[quadrant3] = (((p[i + 1, j] + p[i - 1, j]) * cos_theta) + ((p[i, j + 1] + p[i, j - 1]) * sin_theta)) / (2 * (cos_theta + sin_theta))
p[quadrant4] = (((p[i + 1, j] + p[i - 1, j]) * cos_theta) + ((p[i, j + 1] + p[i, j - 1]) * sin_theta)) / (2 * (cos_theta + sin_theta))
p[top_pt] = p[i, j + 1]
p[bottom_pt] = p[i, j - 1]
p[left_pt] = p[i - 1, j]
p[right_pt] = p[i + 1, j]
un = u.copy()
vn = v.copy()
u[1:-1, 1:-1] = (un[1:-1, 1:-1] - dt / (2 * h) * (un[1:-1, 1:-1] * (un[2:, 1:-1] - un[:-2, 1:-1]) + vn[1:-1, 1:-1] * (un[1:-1, 2:] - un[1:-1, :-2])) - dt / (2 * rho * h) * (p[2:, 1:-1] - p[:-2, 1:-1]) + nu * dt / h**2 * (un[2:, 1:-1] + un[:-2, 1:-1] + un[1:-1, 2:] + un[1:-1, :-2] - 4 * un[1:-1, 1:-1])) # x momentum
# boundary conditions for x-velocity
u[0, :] = Uinf # u = Uinf at x = 0
u[-1, :] = u[-2, :] # du/dx = 0 at x = L
u[:, 0] = Uinf # u = 0 at y = 0
u[:, -1] = Uinf # u = 0 at y = D
u[mask] = 0 # circle
u[circle_boundary] = 0 # circle
v[1:-1, 1:-1] = (vn[1:-1, 1:-1] - dt / (2 * h) * (un[1:-1, 1:-1] * (vn[2:, 1:-1] - vn[:-2, 1:-1]) + vn[1:-1, 1:-1] * (vn[1:-1, 2:] - vn[1:-1, :-2])) - dt / (2 * rho * h) * (p[1:-1, 2:] - p[1:-1, :-2]) + nu * dt / h**2 * (vn[2:, 1:-1] + vn[:-2, 1:-1] + vn[1:-1, 2:] + vn[1:-1, :-2] - 4 * vn[1:-1, 1:-1])) # y momentum
# boundary conditions for y-velocity
v[0, :] = 0 # v = 0 at x = 0
v[-1, :] = v[-2, :] # dv/dx = 0 at x = L
v[:, 0] = 0 # v = 0 at y = 0
v[:, -1] = 0 # v = 0 at y = D
v[mask] = 0 # circle
v[circle_boundary] = 0 # circle
#%% post-processing
velocity_magnitude = np.sqrt(u**2 + v**2) # velocity magnitude
X, Y = np.meshgrid(np.linspace(0, L, Nx), np.linspace(0, D, Ny)) # spatial grid
# visualize velocity vectors and pressure contours
plt.figure(dpi = 500)
plt.contourf(X, Y, u.T, 128, cmap = 'jet')
# plt.colorbar()
plt.gca().set_aspect('equal', adjustable='box')
plt.xticks([0, L])
plt.yticks([0, D])
plt.xlabel('x [m]')
plt.ylabel('y [m]')
plt.colorbar(orientation='vertical')
plt.show()
plt.figure(dpi = 500)
plt.contourf(X, Y, v.T, 128, cmap = 'jet')
# plt.colorbar()
plt.gca().set_aspect('equal', adjustable='box')
plt.xticks([0, L])
plt.yticks([0, D])
plt.xlabel('x [m]')
plt.ylabel('y [m]')
plt.colorbar(orientation='vertical')
plt.show()
plt.figure(dpi = 500)
plt.contourf(X, Y, p.T, 128, cmap = 'jet')
# plt.colorbar()
plt.gca().set_aspect('equal', adjustable='box')
plt.xticks([0, L])
plt.yticks([0, D])
plt.xlabel('x [m]')
plt.ylabel('y [m]')
plt.colorbar(orientation='vertical')
plt.show()
plt.figure(dpi = 500) # make a nice crisp image :)
plt.streamplot(X, Y, u.T, v.T, color = 'black', cmap = 'jet', density = 2, linewidth = 0.5,\
arrowstyle='->', arrowsize = 1) # plot streamlines
plt.gca().set_aspect('equal', adjustable = 'box')
plt.axis('off')
plt.show()
Thank you very much for reading! If you want to hire me as your new shinning post-doc or want to collaborate on the research, please reach out! The code might be available soon!