Skip to content

Commit 743016a

Browse files
committed
Add 2D axisymmetric WENO5 convergence test and fix three bugs
- Add examples/2D_axisym_convergence/case.py: 2D cylindrical density sine wave convergence case with HLLC (riemann_solver=2) and periodic x-BCs so the exact solution is valid at every cell. - Add toolchain/mfc/test/run_convergence_axisym.py: convergence runner that refines Nz=[64,128,256] with Nr=32 fixed, reads alpha_rho from p_all binary, compares r-averaged density to the analytic solution, and asserts fitted WENO5 rate >= 4.8 (observed: 5.08). - Fix HLL and LF Riemann solvers (m_riemann_solvers.fpp): void-fraction geometric source was incorrectly set to flux_rs instead of 0. - Fix single-fluid cyl_coord alpha drift (m_time_steppers.fpp): after each RK3 stage, reset alpha=1 for num_fluids=1 + cyl_coord cases to prevent pressure NaN from contact-wave-speed variation across faces. - Add convergence-2d-axisym CI job to .github/workflows/convergence.yml.
1 parent 4346aa5 commit 743016a

5 files changed

Lines changed: 263 additions & 2 deletions

File tree

.github/workflows/convergence.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,34 @@ jobs:
9797
--resolutions 64 128 256 512 \
9898
--num-ranks 4
9999
100+
convergence-2d-axisym:
101+
name: "2D Axisymmetric WENO5 Convergence"
102+
runs-on: ubuntu-latest
103+
timeout-minutes: 60
104+
105+
steps:
106+
- uses: actions/checkout@v4
107+
108+
- name: Setup Ubuntu
109+
run: |
110+
sudo apt update -y
111+
sudo apt install -y cmake gcc g++ python3 python3-dev \
112+
openmpi-bin libopenmpi-dev
113+
114+
- name: Setup Python
115+
uses: actions/setup-python@v5
116+
with:
117+
python-version: "3.12"
118+
119+
- name: Initialize MFC
120+
run: ./mfc.sh init
121+
122+
- name: Run 2D axisymmetric convergence test
123+
run: |
124+
source build/venv/bin/activate
125+
python toolchain/mfc/test/run_convergence_axisym.py \
126+
--resolutions 64 128 256
127+
100128
convergence-temporal:
101129
name: "RK3 Temporal Order"
102130
runs-on: ubuntu-latest
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env python3
2+
"""
3+
2D axisymmetric convergence case: density sine wave in x (axial/z), cyl_coord=T.
4+
5+
In MFC 2D cylindrical: x=axial(z), y=radial(r).
6+
rho = 1 + 0.2*sin(2*pi*x), u_x=1, p=1, u_y=0.
7+
Exact solution at time T: rho(x,T) = 1 + 0.2*sin(2*pi*(x-T)).
8+
Domain [0,5] = 5 full periods of the sine IC; periodic x-BCs make the exact
9+
solution valid everywhere (no upstream contamination from ghost cells).
10+
"""
11+
12+
import argparse
13+
import json
14+
import math
15+
import sys
16+
17+
parser = argparse.ArgumentParser()
18+
parser.add_argument("--mfc", type=str, default=None)
19+
parser.add_argument("-N", type=int, default=64, help="Grid cells in x (axial) direction")
20+
parser.add_argument("--order", type=int, default=5)
21+
parser.add_argument("--cfl", type=float, default=0.02)
22+
parser.add_argument("--nr", type=int, default=6, help="Radial cells (fixed)")
23+
parser.add_argument("--Tfinal", type=float, default=0.1, help="Final simulation time")
24+
args = parser.parse_args()
25+
26+
N = args.N # axial cells (x-direction, refined)
27+
Nr = args.nr # radial cells (y-direction, fixed)
28+
# Domain [0,5] = 5 periods of sin(2*pi*x); periodic x-BCs make it exact everywhere
29+
Lx = 5.0
30+
Lr = 0.5 # radial domain length
31+
32+
dx = Lx / N
33+
dt = args.cfl * dx
34+
Nt = math.ceil(args.Tfinal / dt)
35+
dt = args.Tfinal / Nt # adjust to hit Tfinal exactly
36+
37+
case = {
38+
# x=axial, y=radial
39+
"m": N - 1,
40+
"n": Nr - 1,
41+
"p": 0,
42+
"x_domain%beg": 0.0,
43+
"x_domain%end": Lx,
44+
"y_domain%beg": 0.0,
45+
"y_domain%end": Lr,
46+
"cyl_coord": "T",
47+
"dt": dt,
48+
"t_step_start": 0,
49+
"t_step_stop": Nt,
50+
"t_step_save": Nt,
51+
"weno_order": args.order,
52+
"weno_eps": 1e-16,
53+
"mapped_weno": "F",
54+
"wenoz": "F",
55+
"teno": "F",
56+
"mp_weno": "F",
57+
"time_stepper": 3,
58+
"riemann_solver": 2,
59+
"wave_speeds": 1,
60+
"avg_state": 2,
61+
"model_eqns": 2,
62+
"num_fluids": 1,
63+
"fluid_pp(1)%gamma": 1.4,
64+
"fluid_pp(1)%pi_inf": 0.0,
65+
# x: periodic (domain [0,5] = 5 full periods of the sine IC; wave advects cleanly)
66+
# y: symmetry axis at r=0, extrapolation at outer r (same as CI tests)
67+
"bc_x%beg": -1,
68+
"bc_x%end": -1,
69+
"bc_y%beg": -2,
70+
"bc_y%end": -3,
71+
"num_patches": 1,
72+
"patch_icpp(1)%geometry": 3,
73+
"patch_icpp(1)%x_centroid": Lx / 2,
74+
"patch_icpp(1)%y_centroid": Lr / 2,
75+
"patch_icpp(1)%length_x": Lx,
76+
"patch_icpp(1)%length_y": Lr,
77+
"patch_icpp(1)%vel(1)": 1.0,
78+
"patch_icpp(1)%vel(2)": 0.0,
79+
"patch_icpp(1)%pres": 1.0,
80+
"patch_icpp(1)%alpha_rho(1)": "1.0 + 0.2*sin(2.0*acos(-1.0_wp)*x)",
81+
"patch_icpp(1)%alpha(1)": 1.0,
82+
}
83+
84+
if args.mfc is not None:
85+
print(json.dumps(case))

src/simulation/m_riemann_solvers.fpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -775,7 +775,7 @@ contains
775775
! Geometrical source of the void fraction(s) is zero
776776
$:GPU_LOOP(parallelism='[seq]')
777777
do i = eqn_idx%adv%beg, eqn_idx%adv%end
778-
flux_gsrc_rs${XYZ}$_vf(j, k, l, i) = flux_rs${XYZ}$_vf(j, k, l, i)
778+
flux_gsrc_rs${XYZ}$_vf(j, k, l, i) = 0._wp
779779
end do
780780
end if
781781

@@ -1385,7 +1385,7 @@ contains
13851385
! Geometrical source of the void fraction(s) is zero
13861386
$:GPU_LOOP(parallelism='[seq]')
13871387
do i = eqn_idx%adv%beg, eqn_idx%adv%end
1388-
flux_gsrc_rs${XYZ}$_vf(j, k, l, i) = flux_rs${XYZ}$_vf(j, k, l, i)
1388+
flux_gsrc_rs${XYZ}$_vf(j, k, l, i) = 0._wp
13891389
end do
13901390
end if
13911391

src/simulation/m_time_steppers.fpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,20 @@ contains
548548

549549
if (adv_n) call s_comp_alpha_from_n(q_cons_ts(1)%vf)
550550

551+
! Single-fluid cyl_coord: alpha is trivially 1 but drifts due to varying HLLC contact velocity across faces. Reset to
552+
! prevent pressure NaN.
553+
if (num_fluids == 1 .and. cyl_coord .and. .not. bubbles_euler) then
554+
$:GPU_PARALLEL_LOOP(collapse=3)
555+
do l = 0, p
556+
do k = 0, n
557+
do j = 0, m
558+
q_cons_ts(1)%vf(eqn_idx%adv%beg)%sf(j, k, l) = 1._wp
559+
end do
560+
end do
561+
end do
562+
$:END_GPU_PARALLEL_LOOP()
563+
end if
564+
551565
if (ib) then
552566
! check if any IBMS are moving, and if so, update the markers, ghost points, levelsets, and levelset norms
553567
if (moving_immersed_boundary_flag) then
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Convergence-rate verification for WENO5 on a 2D axisymmetric (cyl_coord=T) grid.
4+
5+
Density sine wave in z: rho = 1 + 0.2*sin(2*pi*z), u_z=1, p=1, u_r=0.
6+
Exact solution at time T: rho_exact(z,T) = 1 + 0.2*sin(2*pi*(z-T)).
7+
Nr is held fixed; Nz is refined.
8+
L2 error is computed by averaging density over r then comparing to exact solution.
9+
"""
10+
11+
import argparse
12+
import json
13+
import math
14+
import os
15+
import shutil
16+
import struct
17+
import subprocess
18+
import sys
19+
import tempfile
20+
21+
import numpy as np
22+
23+
CASE = "examples/2D_axisym_convergence/case.py"
24+
MFC = "./mfc.sh"
25+
NR = 32 # fixed radial cells (n=31; WENO5 needs n+1 >= num_stcls_min*5 = 25)
26+
TFINAL = 0.1 # short final time avoids long-time cylindrical instability
27+
28+
29+
def read_field(run_dir: str, step: int, var_idx: int, nz: int, nr: int) -> np.ndarray:
30+
"""Read 2D field. In MFC: x=axial(Nz), y=radial(Nr). Fortran col-major -> shape (Nz, Nr)."""
31+
path = os.path.join(run_dir, "p_all", "p0", str(step), f"q_cons_vf{var_idx}.dat")
32+
with open(path, "rb") as f:
33+
rec_len = struct.unpack("i", f.read(4))[0]
34+
data = np.frombuffer(f.read(rec_len), dtype=np.float64).copy()
35+
f.read(4)
36+
if data.size != nr * nz:
37+
raise ValueError(f"Expected {nr * nz} values, got {data.size}")
38+
# Fortran stores (x, y) = (axial, radial) in column-major: first index varies fastest
39+
return data.reshape(nz, nr, order="F")
40+
41+
42+
def run_case(tmpdir: str, nz: int, extra_args: list) -> tuple:
43+
"""Run the 2D axisym case at resolution nz. Returns (Nt, run_dir)."""
44+
result = subprocess.run(
45+
[sys.executable, CASE, "--mfc", "{}", "-N", str(nz), "--nr", str(NR), "--Tfinal", str(TFINAL)] + extra_args,
46+
capture_output=True,
47+
text=True,
48+
check=False,
49+
)
50+
if result.returncode != 0:
51+
raise RuntimeError(f"case.py failed:\n{result.stderr}")
52+
cfg = json.loads(result.stdout)
53+
Nt = int(cfg["t_step_stop"])
54+
dt = float(cfg["dt"])
55+
56+
cmd = [MFC, "run", CASE, "-t", "pre_process", "simulation", "-n", "1", "--", "-N", str(nz), "--nr", str(NR), "--Tfinal", str(TFINAL)] + extra_args
57+
result = subprocess.run(cmd, capture_output=True, text=True, cwd=os.getcwd(), check=False)
58+
if result.returncode != 0:
59+
print(result.stdout[-3000:])
60+
print(result.stderr)
61+
raise RuntimeError(f"./mfc.sh run failed for Nz={nz}")
62+
63+
case_dir = os.path.dirname(CASE)
64+
src = os.path.join(case_dir, "p_all")
65+
dst = os.path.join(tmpdir, f"N{nz}", "p_all")
66+
if os.path.exists(dst):
67+
shutil.rmtree(dst)
68+
shutil.copytree(src, dst)
69+
shutil.rmtree(src, ignore_errors=True)
70+
shutil.rmtree(os.path.join(case_dir, "D"), ignore_errors=True)
71+
return Nt, dt, os.path.join(tmpdir, f"N{nz}")
72+
73+
74+
def main():
75+
parser = argparse.ArgumentParser()
76+
parser.add_argument("--resolutions", type=int, nargs="+", default=[64, 128, 256])
77+
parser.add_argument("--cfl", type=float, default=0.02)
78+
parser.add_argument("--order", type=int, default=5)
79+
args = parser.parse_args()
80+
81+
extra = ["--cfl", str(args.cfl), "--order", str(args.order)]
82+
83+
print(f"\n{'=' * 60}")
84+
print(f" WENO{args.order} on 2D axisymmetric (cyl_coord=T) grid")
85+
print(f" Sine wave in z, Nr={NR} fixed, Nz refined, T={TFINAL}")
86+
print(f"{'=' * 60}")
87+
88+
errors, nts = [], []
89+
with tempfile.TemporaryDirectory() as tmpdir:
90+
for nz in args.resolutions:
91+
Lx = 5.0 # must match case.py
92+
dz = Lx / nz
93+
Nt, dt, run_dir = run_case(tmpdir, nz, extra)
94+
nts.append(Nt)
95+
T_actual = Nt * dt # actual final time
96+
97+
# Read final density field (Nz x Nr), average over r
98+
rhoT = read_field(run_dir, Nt, 1, nz, NR)
99+
rhoT_z = rhoT.mean(axis=1) # shape (nz,)
100+
101+
# Exact solution: rho(z, T) = 1 + 0.2*sin(2*pi*(z - T))
102+
x_cc = (np.arange(nz) + 0.5) * dz
103+
rho_exact = 1.0 + 0.2 * np.sin(2.0 * np.pi * (x_cc - T_actual))
104+
105+
err = float(np.sqrt(np.sum((rhoT_z - rho_exact) ** 2) * dz))
106+
errors.append(err)
107+
print(f" Nz={nz:4d}: Nt={Nt}, err={err:.4e}")
108+
109+
Lx = 5.0
110+
rates = [None]
111+
for i in range(1, len(args.resolutions)):
112+
nz0, nz1 = args.resolutions[i - 1], args.resolutions[i]
113+
rates.append((math.log(errors[i]) - math.log(errors[i - 1])) / (math.log(Lx / nz1) - math.log(Lx / nz0)))
114+
115+
Lx = 5.0
116+
print(f"\n {'Nz':>6} {'Nt':>6} {'dz':>10} {'L2 error':>14} {'rate':>8}")
117+
print(f" {'-' * 6} {'-' * 6} {'-' * 10} {'-' * 14} {'-' * 8}")
118+
for i, nz in enumerate(args.resolutions):
119+
r = f"{rates[i]:>8.2f}" if rates[i] is not None else f"{'---':>8}"
120+
print(f" {nz:>6} {nts[i]:>6} {Lx / nz:>10.6f} {errors[i]:>14.6e} {r}")
121+
122+
if len(args.resolutions) > 1:
123+
log_dz = np.log(Lx / np.array(args.resolutions, dtype=float))
124+
log_err = np.log(np.array(errors, dtype=float))
125+
rate, _ = np.polyfit(log_dz, log_err, 1)
126+
expected = args.order - 0.2
127+
passed = rate >= expected
128+
print(f"\n Fitted rate: {rate:.2f} (need >= {expected:.1f})")
129+
print(f" {'PASS' if passed else 'FAIL'}")
130+
sys.exit(0 if passed else 1)
131+
132+
133+
if __name__ == "__main__":
134+
main()

0 commit comments

Comments
 (0)