Skip to content
Open
6 changes: 6 additions & 0 deletions docs/documentation/case.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ This is enabled by adding ``'elliptic_smoothing': "T",`` and ``'elliptic_smoothi
| `num_particle_clouds` | Integer | Number of particle bed specifications to generate immersed boundary patches from |
| `ib_neighborhood_radius` | Integer | Parameter that controls the neighborhood size for IB detection. |
| `many_ib_patch_parallelism` | Logical | Parallelize over IB patches instead of grid cells (better for many small patches). |
| `ib_second_order_vel` | Logical | Extrapolate the ghost-point velocity linearly through the boundary intercept. |
| `ib_ip_min_dist` | Real | Minimum boundary-to-image-point distance, in local cell widths, for `ib_second_order_vel`. |
| `geometry` | Integer | Geometry configuration of the patch.|
| `x[y,z]_centroid` | Real | Centroid of the applied geometry in the [x,y,z]-direction. |
| `length_x[y,z]` | Real | Length, if applicable, in the [x,y,z]-direction. |
Expand Down Expand Up @@ -450,6 +452,10 @@ Additional details on this specification can be found in [NACA airfoil](https://

- `ib_coefficient_of_friction` is the coefficient of friction used in IB collisions.

- `ib_second_order_vel` replaces the default ghost-point velocity, which imposes the wall velocity at the ghost point, with the linear extrapolation of Mittal et al. (2008) through the boundary intercept and replaces the interpolation scheme with a bilinear weight interpolation.

- `ib_ip_min_dist` sets \f$d_{IP} = \max(d_{GP}, \text{ib\_ip\_min\_dist}\,\Delta)\f$, with \f$\Delta\f$ the smallest local cell width, so image points of near-wall ghost points interpolate from fluid cells. The default 0 is the pure mirror (\f$d_{IP} = d_{GP}\f$). About \f$\sqrt{2}\f$ in 2D (\f$\sqrt{3}\f$ in 3D) keeps the stencil off a locally planar wall.

- `ib_neighborhood_radius` controls the size of the neighborhood size. A value of $r$ indicates that any given rank is aware of IBs up to $r$ ranks away. This value defaults to 0, which leaves the radius unset so that it is selected automatically. This parameter is required to strong-scale a case when IBs eventually grow to be larger than one full processor domain wide.

#### Particle Clouds
Expand Down
21 changes: 21 additions & 0 deletions docs/references.bib
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,27 @@ @article{Mittal05
doi = {10.1146/annurev.fluid.37.061903.175743}
}

% second order IBM
@article{Mittal08,
author = {R. Mittal and H. Dong and M. Bozkurttas and F. M. Najjar and A. Vargas and A. von Loebbecke},
title = {A versatile sharp interface immersed boundary method for incompressible flows with complex boundaries},
journal = {Journal of Computational Physics},
volume = {227},
number = {10},
pages = {4825--4852},
year = {2008}
}

@article{Chaudhuri11,
author = {A. Chaudhuri and A. Hadjadj and A. Chinnayya},
title = {On the use of immersed boundary methods for shock/obstacle interactions},
journal = {Journal of Computational Physics},
volume = {230},
number = {5},
pages = {1731--1748},
year = {2011}
}

% --- Low Mach corrections ---

@article{Thornber08,
Expand Down
119 changes: 119 additions & 0 deletions examples/2D_ibm_second_order_airfoil/case.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
NACA 0012 at M = 0.3, alpha = 2 deg, 200 cells per chord: the 2D_ibm_airfoil_surface_pressure example
switched to a viscous no-slip wall (Re = 1000 per chord), with frequent snapshots for time averaging.
Env: RE, T_STOP, T_SAVE, SECOND_ORDER=1 (PR branch only: second-order IB velocities).
"""

import json
import math
import os

Ma = 0.3
alpha_deg = 2.0
gamma = 1.4
rho_inf, U_inf, chord = 1.0, 1.0, 1.0
Re = float(os.environ.get("RE", 1000.0))
P_inf = rho_inf * U_inf**2 / (gamma * Ma**2)

case = {
# --- Output ---
"run_time_info": "T",
"format": 2,
"precision": 2,
"parallel_io": "T",
"prim_vars_wrt": "T",
# --- Domain: pre-stretch extents; the stretching maps them outward ---
"x_domain%beg": -3.0,
"x_domain%end": 4.0,
"y_domain%beg": -3.0,
"y_domain%end": 3.0,
"m": 1399,
"n": 1199,
"p": 0,
"cyl_coord": "F",
"stretch_x": "T",
"a_x": 15.0,
"x_a": -0.8,
"x_b": 1.8,
"loops_x": 2,
"stretch_y": "T",
"a_y": 15.0,
"y_a": -0.7,
"y_b": 0.7,
"loops_y": 2,
# --- Time stepping ---
"cfl_adap_dt": "T",
"cfl_target": 0.5,
"n_start": 0,
"t_save": 0.2,
"t_stop": 6.0,
# --- Numerics ---
"num_patches": 1,
"num_fluids": 1,
"model_eqns": 2,
"alt_soundspeed": "F",
"mpp_lim": "F",
"mixture_err": "T",
"time_stepper": 3,
"weno_order": 5,
"weno_eps": 1.0e-10,
"weno_Re_flux": "F",
"weno_avg": "T",
"avg_state": 2,
"mapped_weno": "T",
"null_weights": "F",
"mp_weno": "F",
"riemann_solver": 2,
"low_Mach": 2,
"wave_speeds": 1,
"viscous": "T",
"fd_order": 4,
# --- Uniform freestream ---
"patch_icpp(1)%geometry": 3,
"patch_icpp(1)%x_centroid": 0.0,
"patch_icpp(1)%y_centroid": 0.0,
"patch_icpp(1)%length_x": 1.0e3,
"patch_icpp(1)%length_y": 1.0e3,
"patch_icpp(1)%vel(1)": U_inf,
"patch_icpp(1)%vel(2)": 0.0,
"patch_icpp(1)%pres": P_inf,
"patch_icpp(1)%alpha_rho(1)": rho_inf,
"patch_icpp(1)%alpha(1)": 1.0,
"fluid_pp(1)%gamma": 1.0 / (gamma - 1.0),
"fluid_pp(1)%eos": "ideal_gas",
"fluid_pp(1)%Re(1)": Re,
# --- Characteristic far-field boundaries ---
"bc_x%beg": -7,
"bc_x%grcbc_in": "T",
"bc_x%vel_in(1)": U_inf,
"bc_x%vel_in(2)": 0.0,
"bc_x%pres_in": P_inf,
"bc_x%alpha_rho_in(1)": rho_inf,
"bc_x%alpha_in(1)": 1.0,
"bc_x%end": -8,
"bc_x%grcbc_out": "T",
"bc_x%pres_out": P_inf,
"bc_y%beg": -9,
"bc_y%end": -9,
# --- IB: NACA 0012 (m must be > 0, so a negligible camber stands in for 0) ---
"ib": "T",
"num_ibs": 1,
"patch_ib(1)%geometry": 4,
"patch_ib(1)%x_centroid": 0.0,
"patch_ib(1)%y_centroid": 0.0,
"patch_ib(1)%airfoil_id": 1,
"patch_ib(1)%angles(3)": -math.radians(alpha_deg),
"patch_ib(1)%slip": "F",
"patch_ib(1)%moving_ibm": 0,
"ib_airfoil(1)%c": chord,
"ib_airfoil(1)%t": 0.12,
"ib_airfoil(1)%p": 0.4,
"ib_airfoil(1)%m": 1.0e-9,
}

# the parameters for second-order IBM
case.update({"ib_second_order_vel": "T", "ib_ip_min_dist": 1.5})


print(json.dumps(case, indent=4))
1 change: 1 addition & 0 deletions src/common/m_derived_types.fpp
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ module m_derived_types
type ghost_point
integer, dimension(3) :: loc !< Physical location of the ghost point
real(wp), dimension(3) :: ip_loc !< Physical location of the image point
real(wp) :: ip_dist !< Distance from the boundary intercept to the image point
integer, dimension(3) :: ip_grid !< Top left grid point of IP
real(wp), dimension(2, 2, 2) :: interp_coeffs !< Interpolation Coefficients of image point
logical :: interp_valid !< .false. if every image point stencil cell lies inside an IB
Expand Down
2 changes: 2 additions & 0 deletions src/simulation/m_global_parameters.fpp
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,8 @@ contains
ib_force_wrt = .false.
ib_force_stride = 1
many_ib_patch_parallelism = .false.
ib_second_order_vel = .false.
ib_ip_min_dist = 0._wp

! Bubble modeling (sim-specific)
bubble_model = 1
Expand Down
130 changes: 75 additions & 55 deletions src/simulation/m_ibm.fpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ module m_ibm
implicit none

private :: s_compute_image_points, s_compute_interpolation_coeffs, s_interpolate_image_point, s_find_ghost_points, &
& s_find_num_ghost_points, s_compute_ghost_point_pressure, s_compute_ghost_point_velocity
& s_find_num_ghost_points, s_compute_ghost_point_pressure, s_compute_ghost_point_velocity, s_interpolation_weights
; public :: s_initialize_ibm_module, s_ibm_setup, s_ibm_correct_state, s_finalize_ibm_module, s_report_ibm_surface

!> Ghost points at which the reacting-surface Newton solve did not reach its tolerance, so the point fell back to a chemically
Expand Down Expand Up @@ -83,6 +83,7 @@ contains
@:ACC_SETUP_SFs(corrected_gps)

$:GPU_ENTER_DATA(copyin='[num_gps]')
$:GPU_UPDATE(device='[ib_second_order_vel, ib_ip_min_dist]')

if (collision_model > 0) call s_initialize_collisions_module()

Expand Down Expand Up @@ -195,7 +196,7 @@ contains
real(wp), intent(in) :: rho, pres_IP
real(wp), intent(out) :: pres_GP

pres_GP = pres_IP/min(max(1._wp - 2._wp*abs(gp%levelset) &
pres_GP = pres_IP/min(max(1._wp - (abs(gp%levelset) + gp%ip_dist) &
& *rho/pres_IP*dot_product(patch_ib(gp_patch_id)%force/patch_ib(gp_patch_id)%mass, &
& gp%levelset_norm), 5.e-1_wp), 2._wp)

Expand Down Expand Up @@ -259,6 +260,9 @@ contains
if (buf > 0._wp) vel_GP = vel_GP + v_blow_eff*norm/buf
end if

! extrapolate linearly through the boundary intercept, Mittal et al. (2008)
if (ib_second_order_vel) vel_GP = vel_GP - abs(gp%levelset)/max(gp%ip_dist, sgm_eps)*(vel_IP - vel_GP)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use linear-exact interpolation for second-order velocity

For an image point that is not symmetrically placed among its stencil centers, vel_IP comes from s_compute_interpolation_coeffs, whose normalized 1/dist**2 weights at lines 1017-1028 do not reproduce even a linear velocity field. This new no-slip extrapolation therefore propagates an O(h) image-velocity error into the ghost value and cannot provide the advertised second-order surface velocity; use bilinear/trilinear linear-exact weights or an equivalent correction. The added Circle -> second order vel goldens use only one n=49 grid, so they cannot verify the claimed convergence order.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Evaluate rotating wall velocity at the boundary intercept

When ib_second_order_vel is used on a rotating no-slip moving IB (angular_vel != 0), the value in vel_GP before this line is the rigid-body velocity at the ghost center because radial_vector is built from physical_loc at lines 485-492. This extrapolation treats that value as u_BI, so it enforces Ω×(x_GP-C) at the intercept instead of Ω×(x_BI-C), leaving an O(h) tangential slip; build the rotation velocity from the boundary-intercept location before extrapolating. The registered second-order test leaves the body static, so this path is unverified.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with 👍 / 👎.


end subroutine s_compute_ghost_point_velocity

!> Update the conservative variables at the ghost points
Expand Down Expand Up @@ -708,6 +712,7 @@ contains
impure subroutine s_compute_image_points()

real(wp) :: dist
real(wp) :: min_cell_width
real(wp), dimension(3) :: norm
real(wp), dimension(3) :: physical_loc
real(wp) :: temp_loc
Expand All @@ -723,8 +728,8 @@ contains

bounds_error = .false.

$:GPU_PARALLEL_LOOP(private='[q, gp, i, j, k, physical_loc, patch_id, dist, norm, dim, bound, dir, index, temp_loc, &
& s_cc]', copy='[bounds_error]', present='[ghost_points]')
$:GPU_PARALLEL_LOOP(private='[q, gp, i, j, k, physical_loc, patch_id, dist, min_cell_width, norm, dim, bound, dir, index, &
& temp_loc, s_cc]', copy='[bounds_error]', present='[ghost_points]')
do q = 1, num_gps
gp = ghost_points(q)
i = gp%loc(1)
Expand All @@ -742,7 +747,14 @@ contains
patch_id = gp%ib_patch_id
dist = abs(real(gp%levelset, kind=wp))
norm(:) = gp%levelset_norm
ghost_points(q)%ip_loc(:) = physical_loc(:) + 2*dist*norm(:)
ghost_points(q)%ip_dist = dist
! keep the image point stencil off the wall
if (ib_second_order_vel) then
min_cell_width = min(dx(i), dy(j))
if (p > 0) min_cell_width = min(min_cell_width, dz(k))
ghost_points(q)%ip_dist = max(dist, ib_ip_min_dist*min_cell_width)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Carry the image distance through surface reconstruction

When ib_ip_min_dist*Δ exceeds abs(levelset) in a chemistry run with a prescribed-temperature or reacting IB, this assignment makes the gas-side image-point distance differ from the ghost-point distance, but s_ibm_correct_state still passes abs(gp%levelset) to the surface-flux solve at line 407 and s_blend_ghost_state extrapolates temperature and species with a unit distance ratio at lines 2204-2206. The resulting flux and ghost state no longer recover the requested wall state; use gp%ip_dist for IP-to-wall gradients and d_GP/d_IP for the ghost extrapolation, including the Stefan velocity. The registered second-order cases do not enable chemistry, so this accepted combination is currently unverified.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

end if
ghost_points(q)%ip_loc(:) = physical_loc(:) + (dist + ghost_points(q)%ip_dist)*norm(:)

! Find the closest grid point to the image point
do dim = 1, num_dims
Expand Down Expand Up @@ -933,16 +945,12 @@ contains
subroutine s_compute_interpolation_coeffs()

real(wp), dimension(2, 2, 2) :: dist
real(wp), dimension(2, 2, 2) :: alpha
real(wp), dimension(2, 2, 2) :: fluid_mask
real(wp), dimension(2, 2, 2) :: interp_coeffs
real(wp) :: buf
real(wp), dimension(2, 2, 2) :: eta
type(ghost_point) :: gp
integer :: q, i, j, k, ii, jj, kk !< Grid indexes and iterators
logical :: is_cell_center

$:GPU_PARALLEL_LOOP(private='[q, i, j, k, ii, jj, kk, dist, buf, gp, interp_coeffs, eta, alpha, is_cell_center]', &
& present='[ghost_points]')
$:GPU_PARALLEL_LOOP(private='[q, i, j, k, ii, jj, kk, dist, gp, interp_coeffs, fluid_mask]', present='[ghost_points]')
do q = 1, num_gps
gp = ghost_points(q)
! Get the interpolation points
Expand All @@ -956,7 +964,6 @@ contains

! get the distance to a cell in each direction
dist = 0._wp
buf = 1._wp
do ii = 0, 1
do jj = 0, 1
if (p == 0) then
Expand All @@ -971,51 +978,17 @@ contains
end do
end do

! check if we are arbitrarily close to a cell center
interp_coeffs = 0._wp
is_cell_center = .false.
check_is_cell_center: do ii = 0, 1
! flag the fluid cells of the stencil
fluid_mask = 0._wp
do kk = 0, min(p, 1)
do jj = 0, 1
! A coincident solid cell leaves the point invalid: falling through to the eta
! branch with dist = 0 would give alpha*eta = 0*Inf = NaN.
if (dist(ii + 1, jj + 1, 1) <= 1.e-16_wp) then
if (ib_markers%sf(i + ii, j + jj, k) == 0) interp_coeffs(ii + 1, jj + 1, 1) = 1._wp
is_cell_center = .true.
exit check_is_cell_center
else
if (p /= 0) then
if (dist(ii + 1, jj + 1, 2) <= 1.e-16_wp) then
if (ib_markers%sf(i + ii, j + jj, k + 1) == 0) interp_coeffs(ii + 1, jj + 1, 2) = 1._wp
is_cell_center = .true.
exit check_is_cell_center
end if
end if
end if
do ii = 0, 1
if (ib_markers%sf(i + ii, j + jj, k + kk) == 0) fluid_mask(ii + 1, jj + 1, kk + 1) = 1._wp
end do
end do
end do check_is_cell_center

if (.not. is_cell_center) then
! if we are not arbitrarily close, interpolate
alpha = 1._wp
if (ib_markers%sf(i, j, k) /= 0) alpha(1, 1, 1) = 0._wp
if (ib_markers%sf(i + 1, j, k) /= 0) alpha(2, 1, 1) = 0._wp
if (ib_markers%sf(i, j + 1, k) /= 0) alpha(1, 2, 1) = 0._wp
if (ib_markers%sf(i + 1, j + 1, k) /= 0) alpha(2, 2, 1) = 0._wp

if (p == 0) then
eta(:,:,1) = 1._wp/dist(:,:,1)**2
buf = sum(alpha(:,:,1)*eta(:,:,1))
if (buf > 0._wp) interp_coeffs(:,:,1) = alpha(:,:,1)*eta(:,:,1)/buf
else
if (ib_markers%sf(i, j, k + 1) /= 0) alpha(1, 1, 2) = 0._wp
if (ib_markers%sf(i + 1, j, k + 1) /= 0) alpha(2, 1, 2) = 0._wp
if (ib_markers%sf(i, j + 1, k + 1) /= 0) alpha(1, 2, 2) = 0._wp
if (ib_markers%sf(i + 1, j + 1, k + 1) /= 0) alpha(2, 2, 2) = 0._wp
eta = 1._wp/dist**2
buf = sum(alpha*eta)
if (buf > 0._wp) interp_coeffs = alpha*eta/buf
end if
end if
end do

call s_interpolation_weights(gp, i, j, k, dist, fluid_mask, interp_coeffs)

! An image point buried in a neighboring IB gets no weights at all: its ghost point is
! averaged from its own neighbors instead, in s_ibm_correct_state.
Expand All @@ -1027,6 +1000,53 @@ contains

end subroutine s_compute_interpolation_coeffs

!> Fluid-masked image-point weights: bilinear for second-order IBM, else inverse distance squared
subroutine s_interpolation_weights(gp, i, j, k, dist, fluid_mask, interp_coeffs)

$:GPU_ROUTINE(parallelism='[seq]')

type(ghost_point), intent(in) :: gp
integer, intent(in) :: i, j, k !< lower corner of the stencil
real(wp), dimension(2, 2, 2), intent(in) :: dist, fluid_mask
real(wp), dimension(2, 2, 2), intent(out) :: interp_coeffs
real(wp), dimension(2, 2, 2) :: weights
real(wp), dimension(2, 3) :: axis_weight !< (stencil side, dimension) linear weights
real(wp) :: weight_sum
integer :: ii, jj, kk, num_z_layers

num_z_layers = min(p, 1) + 1

if (ib_second_order_vel) then
! bilinear weights, Mittal et al. (2008)
axis_weight(2, 1) = (gp%ip_loc(1) - x_cc(i))/(x_cc(i + 1) - x_cc(i))
axis_weight(2, 2) = (gp%ip_loc(2) - y_cc(j))/(y_cc(j + 1) - y_cc(j))
axis_weight(2, 3) = 0._wp
if (p /= 0) axis_weight(2, 3) = (gp%ip_loc(3) - z_cc(k))/(z_cc(k + 1) - z_cc(k))
axis_weight(2,:) = min(max(axis_weight(2,:), 0._wp), 1._wp) ! clamp to the cell edges
axis_weight(1,:) = 1._wp - axis_weight(2,:)
do kk = 1, num_z_layers
do jj = 1, 2
do ii = 1, 2
weights(ii, jj, kk) = axis_weight(ii, 1)*axis_weight(jj, 2)*axis_weight(kk, 3)
end do
end do
end do
else
! inverse distance squared, Chaudhuri et al. JCP (2011)
if (minval(dist(:,:,1:num_z_layers)) <= 1.e-16_wp) then
weights(:,:,1:num_z_layers) = merge(1._wp, 0._wp, dist(:,:,1:num_z_layers) <= 1.e-16_wp)
else
weights(:,:,1:num_z_layers) = 1._wp/dist(:,:,1:num_z_layers)**2
end if
end if

interp_coeffs = 0._wp
weights(:,:,1:num_z_layers) = fluid_mask(:,:,1:num_z_layers)*weights(:,:,1:num_z_layers)
weight_sum = sum(weights(:,:,1:num_z_layers))
if (weight_sum > 0._wp) interp_coeffs(:,:,1:num_z_layers) = weights(:,:,1:num_z_layers)/weight_sum

end subroutine s_interpolation_weights

!> Interpolate primitive variables to a ghost point's image point using bilinear or trilinear interpolation
subroutine s_interpolate_image_point(q_prim_vf, gp, alpha_rho_IP, alpha_IP, pres_IP, vel_IP, c_IP, r_IP, v_IP, pb_IP, mv_IP, &
& nmom_IP, pb_in, mv_in, presb_IP, massv_IP, Ys_IP)
Expand Down
Loading
Loading