Skip to content

Commit 733f4af

Browse files
committed
Choose ib_neighborhood_radius from the thinnest rank, not the widest
ib_neighborhood_radius counts rank hops: a rank keeps an immersed-boundary patch only while the centroid lies inside its subdomain grown by that many hops, and drops it otherwise. The radius therefore has to span the body in every direction, and the hop that needs the most is the one crossing the thinnest rank. The automatic choice assembled its rank width the wrong way round -- each rank reported its widest extent and the minimum was taken across ranks. Where every rank is long in one direction and thin in another, the width reported is the long one and the radius comes out too small. examples/3D_ibm_neighborhood_radius is a thin plate in a long narrow channel, sized so the topology search settles on 16 x 2 x 2 at 64 ranks: x ranks 1.250 wide against y and z at 4.000 and 3.000. The plate's half-extent is 1.3010, so crossing it takes two hops of 1.250 and one of 4.000. MFC prints radius 1 before this change and 2 after; both runs complete. The two production grids that motivated it agree: a gust case at 16 x 2 x 4 moves 1 -> 2, and a flapping wing at 8 x 4 x 4 moves 1 -> 2. The new width is never larger than the old, so the radius never decreases -- the change can only make the neighbourhood more conservative. Cases that set the parameter explicitly are untouched, as is any near-cubic decomposition, where the widest and narrowest extents coincide.
1 parent dc0aec1 commit 733f4af

3 files changed

Lines changed: 216 additions & 3 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Automatic `ib_neighborhood_radius` when ranks are not cubes
2+
3+
`ib_neighborhood_radius` is a count of **rank hops**. A rank keeps an immersed-boundary patch only while the
4+
patch's centroid lies inside its own subdomain grown outward by that many hops
5+
(`s_get_neighbor_bounds`, `f_neighborhood_ranks_own_location`), and drops it otherwise. So the radius has to be
6+
large enough that the body is reachable within that many hops **in every direction** — and the hop that needs
7+
the most is the one stepping across the *thinnest* rank.
8+
9+
When the radius is not set in the case file, MFC chooses it from the body's half-extent divided by a rank
10+
width. The width it used was assembled the wrong way round:
11+
12+
```fortran
13+
local_rank_width = -1._wp
14+
do each direction:
15+
local_rank_width = max(local_rank_width, <this rank's extent in that direction>)
16+
call s_mpi_allreduce_min(local_rank_width, min_rank_width)
17+
```
18+
19+
Each rank reports its **widest** extent, and the minimum is taken over ranks. On a decomposition where every
20+
rank is long in one direction and thin in another, the reported width is the long one, the radius comes out too
21+
small, and ranks that should have kept the patch drop it.
22+
23+
## The case
24+
25+
A thin plate in a long, narrow channel: 20 chords by 8 by 6, with 400 x 50 x 50 cells chosen so MFC's topology
26+
search settles on **16 x 2 x 2** at 64 ranks. That is an ordinary shape for a wake, a jet or a channel — the
27+
flow direction resolved far more finely than the cross-stream ones — and it makes the ranks strongly
28+
anisotropic:
29+
30+
| direction | ranks | extent per rank |
31+
| --- | --- | --- |
32+
| x | 16 | **1.250** |
33+
| y | 2 | 4.000 |
34+
| z | 2 | 3.000 |
35+
36+
The plate is 1.0 x 2.4 x 0.1, so `s_get_ib_bound` (geometry 9, the cuboid's half-diagonal) returns **1.3010**.
37+
Crossing that at 1.250 per hop needs `ceil(1.1 * 1.3010 / 1.250) = 2` hops. The old width of 4.000 gives
38+
`ceil(1.1 * 1.3010 / 4.000) = 1`.
39+
40+
## Running it
41+
42+
```
43+
./mfc.sh run examples/3D_ibm_neighborhood_radius/case.py -n 64
44+
```
45+
46+
and read the line MFC prints at start-up:
47+
48+
```
49+
Automatic choice of ib_neighborhood_radius selected: N
50+
```
51+
52+
| | printed radius |
53+
| --- | --- |
54+
| before | **1** |
55+
| after | **2** |
56+
57+
Both runs complete; the case is 1 M cells and takes a few minutes on two CPU nodes. `SUMMARY=1 python3
58+
case.py` prints the half-extent, the rank extents and the arithmetic above without running anything.
59+
60+
## It is not only this case
61+
62+
The same two production grids that motivated the fix, measured from their own `lustre_*_cb.dat`:
63+
64+
| case | topology | old width | old radius | new width | new radius |
65+
| --- | --- | --- | --- | --- | --- |
66+
| gust encounter, 128 ranks | 16 x 2 x 4 | 2.051 | 1 | 1.052 | **2** |
67+
| flapping wing, 128 ranks | 8 x 4 x 4 | 1.745 | 1 | 1.027 | **2** |
68+
69+
Both pick 1 where 2 is required.
70+
71+
## Scope
72+
73+
The new width is never larger than the old one, so the chosen radius never decreases: the change can only make
74+
the neighbourhood more conservative, at the cost of more hops in the force reduction. Cases that set
75+
`ib_neighborhood_radius` explicitly are untouched, and so is any decomposition whose ranks are close to cubic,
76+
where the widest and narrowest extents coincide — which is why a uniform grid with a balanced topology shows no
77+
difference.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/usr/bin/env python3
2+
"""Automatic ib_neighborhood_radius on a decomposition whose ranks are not cubes.
3+
4+
A thin plate held in a long, narrow channel. The domain is 20 chords long and 8 by 6 across, and the cell
5+
counts (400 x 50 x 50) are chosen so MFC's topology search settles on 16 x 2 x 2 at 64 ranks. That makes the
6+
x ranks 1.25 chords wide while the y and z ranks are 4.0 and 3.0 -- an ordinary situation for a wake, a jet or
7+
a channel, where the flow direction is resolved far more finely than the cross-stream ones.
8+
9+
`ib_neighborhood_radius` is deliberately left unset, so MFC chooses it at start-up and prints
10+
11+
Automatic choice of ib_neighborhood_radius selected: N
12+
13+
The radius counts rank hops, and the body must be reachable within that many hops in *every* direction, so
14+
the hop that matters is the one crossing the thinnest rank. The plate's half-extent is 1.301 chords and the
15+
thinnest rank is 1.250 wide, so it needs 2 hops, not 1.
16+
17+
See README.md for the measured before/after and the argument.
18+
"""
19+
20+
import json
21+
import math
22+
import os
23+
24+
Re, Ma, gamma, U, rho = 1000.0, 0.1, 1.4, 1.0, 1.0
25+
cs = U / Ma
26+
P = rho * cs**2 / gamma
27+
c = 1.0 # chord
28+
SPAN = float(os.environ.get("SPAN", 2.4)) # spanwise length of the plate
29+
THICK = 0.1 * c
30+
31+
M = int(os.environ.get("M", 400)) # cells in x; 400/16 ranks = 25, the stencil minimum
32+
N = int(os.environ.get("N", 50)) # cells in y; 50/2 ranks = 25
33+
PZ = int(os.environ.get("PZ", 50)) # cells in z; 50/2 ranks = 25
34+
TSTOP = float(os.environ.get("TSTOP", 0.5))
35+
36+
x0, x1 = -10.0 * c, 10.0 * c
37+
y0, y1 = -4.0 * c, 4.0 * c
38+
z0, z1 = -3.0 * c, 3.0 * c
39+
40+
case = {
41+
"run_time_info": "T",
42+
"parallel_io": "T",
43+
"prim_vars_wrt": "T",
44+
"ib_state_wrt": "T",
45+
"format": "silo",
46+
"precision": "double",
47+
"x_domain%beg": x0,
48+
"x_domain%end": x1,
49+
"y_domain%beg": y0,
50+
"y_domain%end": y1,
51+
"z_domain%beg": z0,
52+
"z_domain%end": z1,
53+
"m": M - 1,
54+
"n": N - 1,
55+
"p": PZ - 1,
56+
"cyl_coord": "F",
57+
"cfl_adap_dt": "T",
58+
"cfl_target": 0.4,
59+
"n_start": 0,
60+
"t_save": TSTOP,
61+
"t_stop": TSTOP,
62+
"num_patches": 1,
63+
"num_fluids": 1,
64+
"model_eqns": "5eq",
65+
"alt_soundspeed": "F",
66+
"mpp_lim": "F",
67+
"mixture_err": "T",
68+
"time_stepper": "rk3",
69+
"weno_order": 5,
70+
"weno_eps": 1.0e-10,
71+
"weno_Re_flux": "T",
72+
"weno_avg": "T",
73+
"avg_state": "arithmetic",
74+
"mapped_weno": "T",
75+
"null_weights": "F",
76+
"mp_weno": "F",
77+
"riemann_solver": "hllc",
78+
"low_Mach": 2,
79+
"wave_speeds": "direct",
80+
"viscous": "T",
81+
"fd_order": 4,
82+
"patch_icpp(1)%geometry": 9,
83+
"patch_icpp(1)%x_centroid": 0.0,
84+
"patch_icpp(1)%y_centroid": 0.0,
85+
"patch_icpp(1)%z_centroid": 0.0,
86+
"patch_icpp(1)%length_x": x1 - x0,
87+
"patch_icpp(1)%length_y": y1 - y0,
88+
"patch_icpp(1)%length_z": z1 - z0,
89+
"patch_icpp(1)%vel(1)": U,
90+
"patch_icpp(1)%vel(2)": 0.0,
91+
"patch_icpp(1)%vel(3)": 0.0,
92+
"patch_icpp(1)%pres": P,
93+
"patch_icpp(1)%alpha_rho(1)": rho,
94+
"patch_icpp(1)%alpha(1)": 1.0,
95+
"fluid_pp(1)%gamma": 1.0 / (gamma - 1.0),
96+
"fluid_pp(1)%eos": "ideal_gas",
97+
"fluid_pp(1)%Re(1)": Re / (c * U),
98+
"bc_x%beg": -7,
99+
"bc_x%grcbc_in": "T",
100+
"bc_x%vel_in(1)": U,
101+
"bc_x%vel_in(2)": 0.0,
102+
"bc_x%vel_in(3)": 0.0,
103+
"bc_x%pres_in": P,
104+
"bc_x%alpha_rho_in(1)": rho,
105+
"bc_x%alpha_in(1)": 1.0,
106+
"bc_x%end": -8,
107+
"bc_y%beg": -8,
108+
"bc_y%end": -8,
109+
"bc_z%beg": -8,
110+
"bc_z%end": -8,
111+
# ib_neighborhood_radius deliberately NOT set: this case exists to exercise the automatic choice
112+
"ib": "T",
113+
"num_ibs": 1,
114+
"patch_ib(1)%geometry": 9, # cuboid
115+
"patch_ib(1)%x_centroid": 0.0,
116+
"patch_ib(1)%y_centroid": 0.0,
117+
"patch_ib(1)%z_centroid": 0.0,
118+
"patch_ib(1)%length_x": c,
119+
"patch_ib(1)%length_y": SPAN,
120+
"patch_ib(1)%length_z": THICK,
121+
"patch_ib(1)%slip": "F",
122+
"patch_ib(1)%moving_ibm": 0,
123+
}
124+
125+
if __name__ == "__main__":
126+
if os.environ.get("SUMMARY"):
127+
bound = 0.5 * math.sqrt(c**2 + SPAN**2 + THICK**2)
128+
print(f"plate half-extent (s_get_ib_bound, geometry 9): {bound:.4f}")
129+
print(f"rank extents at 64 ranks (16 x 2 x 2): x {(x1 - x0) / 16:.3f}, " f"y {(y1 - y0) / 2:.3f}, z {(z1 - z0) / 2:.3f}")
130+
print(f"hops needed across the thinnest rank: ceil(1.1 * {bound:.4f} / {(x1 - x0) / 16:.3f}) = " f"{max(1, math.ceil(1.1 * bound / ((x1 - x0) / 16)))}")
131+
else:
132+
print(json.dumps(case, indent=4))

src/simulation/m_start_up.fpp

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1542,10 +1542,14 @@ contains
15421542
max_ib_bound = max(max_ib_bound, particle_cloud(k)%radius)
15431543
end do
15441544

1545-
! determine the upper bound on the size
1546-
local_rank_width = -1._wp
1545+
! Narrowest rank extent, over every direction as well as every rank. The radius is a count of rank
1546+
! hops, so the distance one hop covers is the extent of the rank it steps over, and the direction
1547+
! needing the most hops to span the body is the one whose ranks are thinnest. Reducing over each
1548+
! rank's widest extent first reports the wrong number whenever ranks are anisotropic, which is the
1549+
! norm on a stretched grid or an elongated domain.
1550+
local_rank_width = huge(0._wp)
15471551
#:for X, ID, DIM in [('x', 1, 'm'), ('y', 2, 'n'), ('z', 3, 'p')]
1548-
if (num_dims >= ${ID}$) local_rank_width = max(local_rank_width, abs(${X}$_cb(${DIM}$) - ${X}$_cb(-1)))
1552+
if (num_dims >= ${ID}$) local_rank_width = min(local_rank_width, abs(${X}$_cb(${DIM}$) - ${X}$_cb(-1)))
15491553
#:endfor
15501554
call s_mpi_allreduce_min(local_rank_width, min_rank_width)
15511555

0 commit comments

Comments
 (0)