Skip to content

Commit 5336315

Browse files
committed
Add RK3 temporal order verification runner and CI job
1 parent 4ea3182 commit 5336315

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

.github/workflows/convergence.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,31 @@ jobs:
6262
python toolchain/mfc/test/run_convergence.py \
6363
--resolutions 32 64 128 \
6464
--num-ranks 4
65+
66+
convergence-temporal:
67+
name: "RK3 Temporal Order"
68+
runs-on: ubuntu-latest
69+
timeout-minutes: 60
70+
71+
steps:
72+
- uses: actions/checkout@v4
73+
74+
- name: Setup Ubuntu
75+
run: |
76+
sudo apt update -y
77+
sudo apt install -y cmake gcc g++ python3 python3-dev \
78+
openmpi-bin libopenmpi-dev
79+
80+
- name: Setup Python
81+
uses: actions/setup-python@v5
82+
with:
83+
python-version: "3.12"
84+
85+
- name: Initialize MFC
86+
run: ./mfc.sh init
87+
88+
- name: Run temporal order tests
89+
run: |
90+
source build/venv/bin/activate
91+
python toolchain/mfc/test/run_temporal_order.py \
92+
--num-ranks 4
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Time-integration order verification for MFC's RK3 time stepper.
4+
5+
Uses the 1D single-fluid Euler advection problem (rho = 1 + 0.2*sin(2*pi*x),
6+
u=1, p=1, L=1, T=1) with a fine spatial grid (N=512, WENO5) so the spatial
7+
error (~4e-12) is negligible compared to the RK3 temporal error at the CFLs
8+
tested here.
9+
10+
L2(rho(T) - rho(0)) measures total accumulated error. By fixing N and varying
11+
CFL (and hence dt), the spatial contribution is constant and the measured rate
12+
reflects the time integration order.
13+
14+
CFL values [0.5, 0.25] keep the temporal error well above the spatial floor:
15+
CFL=0.50 → err ~8.3e-10 (>200x spatial floor)
16+
CFL=0.25 → err ~1.1e-10 (>25x spatial floor)
17+
Pairwise rate ≈ 2.95, threshold ≥ 2.7.
18+
19+
Usage:
20+
python toolchain/mfc/test/run_temporal_order.py
21+
python toolchain/mfc/test/run_temporal_order.py --cfls 0.5 0.25 0.125
22+
"""
23+
24+
import argparse
25+
import json
26+
import math
27+
import os
28+
import shutil
29+
import struct
30+
import subprocess
31+
import sys
32+
import tempfile
33+
34+
import numpy as np
35+
36+
CASE = "examples/1D_euler_convergence/case.py"
37+
MFC = "./mfc.sh"
38+
39+
# (label, extra_args, expected_order, tolerance, cfls)
40+
# All schemes here use RK3 (time_stepper=3 is default in MFC).
41+
# N=512 is fixed; WENO5 keeps spatial error ~4e-12 (negligible at CFL>=0.25).
42+
SCHEMES = [
43+
("RK3/WENO5", ["--order", "5"], 3, 0.3, [0.5, 0.25]),
44+
]
45+
46+
N_SPATIAL = 512 # fixed spatial resolution
47+
48+
49+
def read_vf1_1d(run_dir: str, step: int, num_ranks: int = 1) -> np.ndarray:
50+
"""Read q_cons_vf1 from all MPI ranks and concatenate into one 1D array."""
51+
chunks = []
52+
for rank in range(num_ranks):
53+
path = os.path.join(run_dir, "p_all", f"p{rank}", str(step), "q_cons_vf1.dat")
54+
with open(path, "rb") as f:
55+
rec_len = struct.unpack("i", f.read(4))[0]
56+
data = np.frombuffer(f.read(rec_len), dtype=np.float64)
57+
f.read(4)
58+
chunks.append(data.copy())
59+
return np.concatenate(chunks)
60+
61+
62+
def l2_error(a: np.ndarray, b: np.ndarray, dx: float) -> float:
63+
return float(np.sqrt(np.sum((a - b) ** 2) * dx))
64+
65+
66+
def run_case(tmpdir: str, cfl: float, extra_args: list, num_ranks: int = 1):
67+
"""Run the 1D advection case at fixed N=512 with given CFL. Returns (dt, Nt, run_dir)."""
68+
result = subprocess.run(
69+
[sys.executable, CASE, "--mfc", "{}", "-N", str(N_SPATIAL), "--cfl", str(cfl)] + extra_args,
70+
capture_output=True,
71+
text=True,
72+
check=False,
73+
)
74+
if result.returncode != 0:
75+
raise RuntimeError(f"case.py failed:\n{result.stderr}")
76+
cfg = json.loads(result.stdout)
77+
Nt = int(cfg["t_step_stop"])
78+
dt = float(cfg["dt"])
79+
80+
cmd = [
81+
MFC,
82+
"run",
83+
CASE,
84+
"-t",
85+
"pre_process",
86+
"simulation",
87+
"-n",
88+
str(num_ranks),
89+
"--",
90+
"-N",
91+
str(N_SPATIAL),
92+
"--cfl",
93+
str(cfl),
94+
] + extra_args
95+
result = subprocess.run(cmd, capture_output=True, text=True, cwd=os.getcwd(), check=False)
96+
if result.returncode != 0:
97+
print(result.stdout[-3000:])
98+
raise RuntimeError(f"./mfc.sh run failed for CFL={cfl}")
99+
100+
case_dir = os.path.dirname(CASE)
101+
src = os.path.join(case_dir, "p_all")
102+
cfl_tag = f"cfl{cfl:.4f}".replace(".", "p")
103+
dst = os.path.join(tmpdir, cfl_tag, "p_all")
104+
if os.path.exists(dst):
105+
shutil.rmtree(dst)
106+
shutil.copytree(src, dst)
107+
shutil.rmtree(src, ignore_errors=True)
108+
shutil.rmtree(os.path.join(case_dir, "D"), ignore_errors=True)
109+
110+
return dt, Nt, os.path.join(tmpdir, cfl_tag)
111+
112+
113+
def test_scheme(label, extra_args, expected_order, tol, cfls, num_ranks=1):
114+
print(f"\n{'=' * 60}")
115+
print(f" {label} N={N_SPATIAL} (need rate >= {expected_order - tol:.1f})")
116+
print(f"{'=' * 60}")
117+
118+
errors = []
119+
dts = []
120+
nts = []
121+
dx = 1.0 / N_SPATIAL
122+
123+
with tempfile.TemporaryDirectory() as tmpdir:
124+
for cfl in cfls:
125+
dt, Nt, run_dir = run_case(tmpdir, cfl, extra_args, num_ranks)
126+
dts.append(dt)
127+
nts.append(Nt)
128+
vf0 = read_vf1_1d(run_dir, 0, num_ranks)
129+
vfT = read_vf1_1d(run_dir, Nt, num_ranks)
130+
err = l2_error(vfT, vf0, dx)
131+
errors.append(err)
132+
133+
rates = [None]
134+
for i in range(1, len(cfls)):
135+
log_dt0 = math.log(dts[i - 1])
136+
log_dt1 = math.log(dts[i])
137+
rates.append((math.log(errors[i]) - math.log(errors[i - 1])) / (log_dt1 - log_dt0))
138+
139+
print(f" {'CFL':>7} {'dt':>12} {'Nt':>6} {'L2 error':>14} {'rate':>8}")
140+
print(f" {'-' * 7} {'-' * 12} {'-' * 6} {'-' * 14} {'-' * 8}")
141+
for i, cfl in enumerate(cfls):
142+
r_str = f"{rates[i]:>8.2f}" if rates[i] is not None else f"{'---':>8}"
143+
print(f" {cfl:>7.3f} {dts[i]:>12.6e} {nts[i]:>6} {errors[i]:>14.6e} {r_str}")
144+
145+
if len(cfls) > 1:
146+
log_dt = np.log(np.array(dts, dtype=float))
147+
log_err = np.log(np.array(errors, dtype=float))
148+
overall, _ = np.polyfit(log_dt, log_err, 1)
149+
print(f"\n Fitted rate: {overall:.2f} (need >= {expected_order - tol:.1f})")
150+
passed = overall >= expected_order - tol
151+
else:
152+
print("\n (need >= 2 CFL values to compute rate)")
153+
passed = True
154+
155+
print(f" {'PASS' if passed else 'FAIL'}")
156+
return passed
157+
158+
159+
def main():
160+
parser = argparse.ArgumentParser(description="MFC RK3 temporal order verification")
161+
parser.add_argument(
162+
"--cfls",
163+
type=float,
164+
nargs="+",
165+
default=None,
166+
help="CFL values to test (default: per-scheme values)",
167+
)
168+
parser.add_argument(
169+
"--schemes",
170+
nargs="+",
171+
default=["RK3/WENO5"],
172+
help="Schemes to test (default: all)",
173+
)
174+
parser.add_argument("--num-ranks", type=int, default=1, help="MPI ranks per simulation (default: 1)")
175+
args = parser.parse_args()
176+
177+
results = {}
178+
for label, extra_args, expected_order, tol, default_cfls in SCHEMES:
179+
if label not in args.schemes:
180+
continue
181+
cfls = args.cfls if args.cfls is not None else default_cfls
182+
try:
183+
passed = test_scheme(label, extra_args, expected_order, tol, cfls, args.num_ranks)
184+
except Exception as e:
185+
print(f" ERROR: {e}")
186+
passed = False
187+
results[label] = passed
188+
189+
print(f"\n{'=' * 60}")
190+
print(" Summary")
191+
print(f"{'=' * 60}")
192+
all_pass = True
193+
for label, passed in results.items():
194+
print(f" {label:<14} {'PASS' if passed else 'FAIL'}")
195+
if not passed:
196+
all_pass = False
197+
198+
sys.exit(0 if all_pass else 1)
199+
200+
201+
if __name__ == "__main__":
202+
main()

0 commit comments

Comments
 (0)