Skip to content

Commit 8d19a79

Browse files
sbryngelsonclaude
andcommitted
Add direction symmetry, MPI consistency, restart roundtrip, and kernel tests
Four new test categories to catch edge-case bugs: 1. Direction symmetry: 3D shock tube tests with shock propagating in x and y directions (default 3D tests use z), catching direction-specific bugs in reconstruction and gradient calculations. 2. MPI consistency: ppn=2 tests for bubbles, viscous flows, and hypoelasticity, catching broadcast/reduction bugs in MPI-sensitive physics modules. 3. Restart roundtrip: 1D and 3D tests that run a straight simulation, then restart from the midpoint and compare the restarted output against the straight run output to verify restart I/O fidelity. 4. Kernel golden values: polydisperse bubble test (nb=3 with poly_sigma distribution) exercising bubble array indexing code paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 84c46e0 commit 8d19a79

File tree

3 files changed

+286
-4
lines changed

3 files changed

+286
-4
lines changed

toolchain/mfc/test/case.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,13 @@ class TestCase(case.Case):
108108
ppn: int
109109
trace: str
110110
override_tol: Optional[float] = None
111+
restart_check: bool = False
111112

112-
def __init__(self, trace: str, mods: dict, ppn: int = None, override_tol: float = None) -> None:
113+
def __init__(self, trace: str, mods: dict, ppn: int = None, override_tol: float = None, restart_check: bool = False) -> None:
113114
self.trace = trace
114115
self.ppn = ppn or 1
115116
self.override_tol = override_tol
117+
self.restart_check = restart_check
116118
super().__init__({**BASE_CFG.copy(), **mods})
117119

118120
def run(self, targets: List[Union[str, MFCTarget]], gpus: Set[int]) -> subprocess.CompletedProcess:
@@ -140,6 +142,36 @@ def run(self, targets: List[Union[str, MFCTarget]], gpus: Set[int]) -> subproces
140142

141143
return common.system(command, print_cmd=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
142144

145+
def run_restart(self, targets, gpus):
146+
"""Run a restart roundtrip: simulate to midpoint, then restart to end."""
147+
mid_step = self.params['t_step_stop'] // 2
148+
orig = dict(self.params)
149+
150+
try:
151+
self.delete_output()
152+
153+
# Phase 1: Run to midpoint (generates restart data)
154+
self.params = {**orig, 't_step_stop': mid_step, 't_step_save': mid_step}
155+
self.create_directory()
156+
result1 = self.run(targets, gpus)
157+
if result1.returncode != 0:
158+
return result1
159+
160+
# Delete output data but keep restart_data/
161+
dirpath = self.get_dirpath()
162+
common.delete_directory(os.path.join(dirpath, "D"))
163+
common.delete_directory(os.path.join(dirpath, "p_all"))
164+
common.delete_directory(os.path.join(dirpath, "silo_hdf5"))
165+
166+
# Phase 2: Restart from midpoint
167+
self.params = {**orig, 'old_ic': 'T', 'old_grid': 'T',
168+
't_step_start': mid_step,
169+
't_step_save': orig['t_step_stop'] - mid_step}
170+
self.create_directory()
171+
return self.run(targets, gpus)
172+
finally:
173+
self.params = orig
174+
143175
def get_trace(self) -> str:
144176
return self.trace
145177

@@ -278,6 +310,7 @@ class TestCaseBuilder:
278310
ppn: int
279311
functor: Optional[Callable]
280312
override_tol: Optional[float] = None
313+
restart_check: bool = False
281314

282315
def get_uuid(self) -> str:
283316
return trace_to_uuid(self.trace)
@@ -302,7 +335,7 @@ def to_case(self) -> TestCase:
302335
if self.functor:
303336
self.functor(dictionary)
304337

305-
return TestCase(self.trace, dictionary, self.ppn, self.override_tol)
338+
return TestCase(self.trace, dictionary, self.ppn, self.override_tol, self.restart_check)
306339

307340

308341
@dataclasses.dataclass
@@ -330,7 +363,7 @@ def define_case_f(trace: str, path: str, args: List[str] = None, ppn: int = None
330363

331364

332365
# pylint: disable=too-many-arguments, too-many-positional-arguments
333-
def define_case_d(stack: CaseGeneratorStack, newTrace: str, newMods: dict, ppn: int = None, functor: Callable = None, override_tol: float = None) -> TestCaseBuilder:
366+
def define_case_d(stack: CaseGeneratorStack, newTrace: str, newMods: dict, ppn: int = None, functor: Callable = None, override_tol: float = None, restart_check: bool = False) -> TestCaseBuilder:
334367
mods: dict = {}
335368

336369
for mod in stack.mods:
@@ -346,7 +379,7 @@ def define_case_d(stack: CaseGeneratorStack, newTrace: str, newMods: dict, ppn:
346379
if not common.isspace(trace):
347380
traces.append(trace)
348381

349-
return TestCaseBuilder(' -> '.join(traces), mods, None, None, ppn or 1, functor, override_tol)
382+
return TestCaseBuilder(' -> '.join(traces), mods, None, None, ppn or 1, functor, override_tol, restart_check)
350383

351384
def input_bubbles_lagrange(self):
352385
if "lagrange_bubblescreen" in self.trace:

toolchain/mfc/test/cases.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,6 +1109,227 @@ def chemistry_cases():
11091109

11101110
chemistry_cases()
11111111

1112+
def direction_symmetry_tests():
1113+
"""3D tests with shock propagating in x and y directions.
1114+
1115+
Default 3D tests have the shock along z. These test x and y
1116+
code paths to catch direction-specific bugs in reconstruction,
1117+
Riemann solvers, and gradient calculations.
1118+
"""
1119+
for direction in ['x', 'y']:
1120+
others = [d for d in ['x', 'y', 'z'] if d != direction]
1121+
mods = {
1122+
'm': 24, 'n': 24, 'p': 24,
1123+
'x_domain%beg': 0.E+00, 'x_domain%end': 1.E+00,
1124+
'y_domain%beg': 0.E+00, 'y_domain%end': 1.E+00,
1125+
'z_domain%beg': 0.E+00, 'z_domain%end': 1.E+00,
1126+
'bc_x%beg': -3, 'bc_x%end': -3,
1127+
'bc_y%beg': -3, 'bc_y%end': -3,
1128+
'bc_z%beg': -3, 'bc_z%end': -3,
1129+
}
1130+
1131+
centroids = [0.05, 0.45, 0.9]
1132+
lengths = [0.1, 0.7, 0.2]
1133+
1134+
for patchID in range(1, 4):
1135+
mods[f'patch_icpp({patchID})%geometry'] = 9
1136+
mods[f'patch_icpp({patchID})%vel(1)'] = 0.0
1137+
mods[f'patch_icpp({patchID})%vel(2)'] = 0.0
1138+
mods[f'patch_icpp({patchID})%vel(3)'] = 0.0
1139+
mods[f'patch_icpp({patchID})%{direction}_centroid'] = centroids[patchID - 1]
1140+
mods[f'patch_icpp({patchID})%length_{direction}'] = lengths[patchID - 1]
1141+
for od in others:
1142+
mods[f'patch_icpp({patchID})%{od}_centroid'] = 0.5
1143+
mods[f'patch_icpp({patchID})%length_{od}'] = 1
1144+
1145+
stack.push(f'3D Direction Symmetry -> Shock in {direction.upper()}', mods)
1146+
cases.append(define_case_d(stack, '', {}))
1147+
stack.pop()
1148+
1149+
direction_symmetry_tests()
1150+
1151+
def mpi_consistency_tests():
1152+
"""ppn=2 tests for physics sensitive to MPI decomposition.
1153+
1154+
Exercises bubble dynamics, viscous flows, and hypoelasticity
1155+
with 2 MPI ranks to catch broadcast/reduction bugs.
1156+
"""
1157+
base_3d = {
1158+
'm': 29, 'n': 29, 'p': 49,
1159+
'x_domain%beg': 0.E+00, 'x_domain%end': 1.E+00,
1160+
'y_domain%beg': 0.E+00, 'y_domain%end': 1.E+00,
1161+
'z_domain%beg': 0.E+00, 'z_domain%end': 1.E+00,
1162+
'bc_x%beg': -3, 'bc_x%end': -3,
1163+
'bc_y%beg': -3, 'bc_y%end': -3,
1164+
'bc_z%beg': -3, 'bc_z%end': -3,
1165+
}
1166+
1167+
for patchID in range(1, 4):
1168+
base_3d[f'patch_icpp({patchID})%geometry'] = 9
1169+
base_3d[f'patch_icpp({patchID})%vel(1)'] = 0.0
1170+
base_3d[f'patch_icpp({patchID})%vel(2)'] = 0.0
1171+
base_3d[f'patch_icpp({patchID})%vel(3)'] = 0.0
1172+
base_3d[f'patch_icpp({patchID})%x_centroid'] = 0.5
1173+
base_3d[f'patch_icpp({patchID})%length_x'] = 1
1174+
base_3d[f'patch_icpp({patchID})%y_centroid'] = 0.5
1175+
base_3d[f'patch_icpp({patchID})%length_y'] = 1
1176+
base_3d.update({
1177+
'patch_icpp(1)%z_centroid': 0.05, 'patch_icpp(1)%length_z': 0.1,
1178+
'patch_icpp(2)%z_centroid': 0.45, 'patch_icpp(2)%length_z': 0.7,
1179+
'patch_icpp(3)%z_centroid': 0.9, 'patch_icpp(3)%length_z': 0.2,
1180+
})
1181+
1182+
# Bubbles with 2 MPI ranks
1183+
stack.push('MPI Consistency -> 3D -> Bubbles', {**base_3d,
1184+
'dt': 1e-06,
1185+
'bubbles_euler': 'T', 'nb': 1, 'polytropic': 'T', 'bubble_model': 2,
1186+
'fluid_pp(1)%gamma': 0.16, 'fluid_pp(1)%pi_inf': 3515.0,
1187+
'bub_pp%R0ref': 1.0, 'bub_pp%p0ref': 1.0, 'bub_pp%rho0ref': 1.0, 'bub_pp%T0ref': 1.0,
1188+
'bub_pp%ss': 0.07179866765358993, 'bub_pp%pv': 0.02308216136195411,
1189+
'bub_pp%vd': 0.2404125083932959,
1190+
'bub_pp%mu_l': 0.009954269975623244, 'bub_pp%mu_v': 8.758168074360729e-05,
1191+
'bub_pp%mu_g': 0.00017881922111898042, 'bub_pp%gam_v': 1.33, 'bub_pp%gam_g': 1.4,
1192+
'bub_pp%M_v': 18.02, 'bub_pp%M_g': 28.97, 'bub_pp%k_v': 0.5583395141263873,
1193+
'bub_pp%k_g': 0.7346421281308791, 'bub_pp%R_v': 1334.8378710170155,
1194+
'bub_pp%R_g': 830.2995663005393,
1195+
'patch_icpp(1)%alpha_rho(1)': 0.96, 'patch_icpp(1)%alpha(1)': 4e-02,
1196+
'patch_icpp(2)%alpha_rho(1)': 0.96, 'patch_icpp(2)%alpha(1)': 4e-02,
1197+
'patch_icpp(3)%alpha_rho(1)': 0.96, 'patch_icpp(3)%alpha(1)': 4e-02,
1198+
'patch_icpp(1)%pres': 1.0, 'patch_icpp(2)%pres': 1.0, 'patch_icpp(3)%pres': 1.0,
1199+
})
1200+
cases.append(define_case_d(stack, '', {}, ppn=2))
1201+
stack.pop()
1202+
1203+
# Viscous with 2 MPI ranks
1204+
stack.push('MPI Consistency -> 3D -> Viscous', {**base_3d,
1205+
'dt': 1e-11,
1206+
'fluid_pp(1)%Re(1)': 0.0001, 'viscous': 'T',
1207+
'patch_icpp(1)%vel(1)': 1.0,
1208+
'patch_icpp(2)%vel(1)': 1.0,
1209+
'patch_icpp(3)%vel(1)': 1.0,
1210+
})
1211+
cases.append(define_case_d(stack, '', {}, ppn=2))
1212+
stack.pop()
1213+
1214+
# Hypoelasticity with 2 MPI ranks
1215+
stack.push('MPI Consistency -> 3D -> Hypoelasticity', {**base_3d,
1216+
'hypoelasticity': 'T', 'riemann_solver': 1, 'fd_order': 4,
1217+
'fluid_pp(1)%gamma': 0.3, 'fluid_pp(1)%pi_inf': 7.8E+05,
1218+
'fluid_pp(1)%G': 1.E+05,
1219+
'patch_icpp(1)%pres': 1.E+06, 'patch_icpp(1)%alpha_rho(1)': 1000.E+00,
1220+
'patch_icpp(2)%pres': 1.E+05, 'patch_icpp(2)%alpha_rho(1)': 1000.E+00,
1221+
'patch_icpp(3)%pres': 5.E+05, 'patch_icpp(3)%alpha_rho(1)': 1000.E+00,
1222+
'patch_icpp(1)%tau_e(1)': 0.E+00, 'patch_icpp(2)%tau_e(1)': 0.E+00,
1223+
'patch_icpp(3)%tau_e(1)': 0.E+00,
1224+
'patch_icpp(1)%tau_e(2)': 0.E+00, 'patch_icpp(1)%tau_e(3)': 0.E+00,
1225+
'patch_icpp(2)%tau_e(2)': 0.E+00, 'patch_icpp(2)%tau_e(3)': 0.E+00,
1226+
'patch_icpp(3)%tau_e(2)': 0.E+00, 'patch_icpp(3)%tau_e(3)': 0.E+00,
1227+
'patch_icpp(1)%tau_e(4)': 0.E+00, 'patch_icpp(1)%tau_e(5)': 0.E+00,
1228+
'patch_icpp(1)%tau_e(6)': 0.E+00,
1229+
'patch_icpp(2)%tau_e(4)': 0.E+00, 'patch_icpp(2)%tau_e(5)': 0.E+00,
1230+
'patch_icpp(2)%tau_e(6)': 0.E+00,
1231+
'patch_icpp(3)%tau_e(4)': 0.E+00, 'patch_icpp(3)%tau_e(5)': 0.E+00,
1232+
'patch_icpp(3)%tau_e(6)': 0.E+00,
1233+
})
1234+
cases.append(define_case_d(stack, '', {}, ppn=2))
1235+
stack.pop()
1236+
1237+
mpi_consistency_tests()
1238+
1239+
def restart_roundtrip_tests():
1240+
"""Tests that verify save-restart roundtrip fidelity.
1241+
1242+
Each test runs a straight simulation, then a restart from the
1243+
midpoint. The restarted output is compared against the straight
1244+
run output to verify restart I/O doesn't introduce drift.
1245+
"""
1246+
# 1D restart
1247+
stack.push('Restart Roundtrip -> 1D', {
1248+
'm': 299, 'n': 0, 'p': 0,
1249+
'x_domain%beg': 0.E+00, 'x_domain%end': 1.E+00,
1250+
'bc_x%beg': -3, 'bc_x%end': -3,
1251+
'patch_icpp(1)%geometry': 1, 'patch_icpp(2)%geometry': 1,
1252+
'patch_icpp(3)%geometry': 1,
1253+
'patch_icpp(1)%x_centroid': 0.05, 'patch_icpp(1)%length_x': 0.1,
1254+
'patch_icpp(2)%x_centroid': 0.45, 'patch_icpp(2)%length_x': 0.7,
1255+
'patch_icpp(3)%x_centroid': 0.9, 'patch_icpp(3)%length_x': 0.2,
1256+
'patch_icpp(1)%vel(1)': 0.0, 'patch_icpp(2)%vel(1)': 0.0,
1257+
'patch_icpp(3)%vel(1)': 0.0,
1258+
})
1259+
cases.append(define_case_d(stack, '', {}, restart_check=True))
1260+
stack.pop()
1261+
1262+
# 3D restart
1263+
base_3d = {
1264+
'm': 24, 'n': 24, 'p': 24,
1265+
'x_domain%beg': 0.E+00, 'x_domain%end': 1.E+00,
1266+
'y_domain%beg': 0.E+00, 'y_domain%end': 1.E+00,
1267+
'z_domain%beg': 0.E+00, 'z_domain%end': 1.E+00,
1268+
'bc_x%beg': -3, 'bc_x%end': -3,
1269+
'bc_y%beg': -3, 'bc_y%end': -3,
1270+
'bc_z%beg': -3, 'bc_z%end': -3,
1271+
}
1272+
for patchID in range(1, 4):
1273+
base_3d[f'patch_icpp({patchID})%geometry'] = 9
1274+
base_3d[f'patch_icpp({patchID})%vel(1)'] = 0.0
1275+
base_3d[f'patch_icpp({patchID})%vel(2)'] = 0.0
1276+
base_3d[f'patch_icpp({patchID})%vel(3)'] = 0.0
1277+
base_3d[f'patch_icpp({patchID})%x_centroid'] = 0.5
1278+
base_3d[f'patch_icpp({patchID})%length_x'] = 1
1279+
base_3d[f'patch_icpp({patchID})%y_centroid'] = 0.5
1280+
base_3d[f'patch_icpp({patchID})%length_y'] = 1
1281+
base_3d.update({
1282+
'patch_icpp(1)%z_centroid': 0.05, 'patch_icpp(1)%length_z': 0.1,
1283+
'patch_icpp(2)%z_centroid': 0.45, 'patch_icpp(2)%length_z': 0.7,
1284+
'patch_icpp(3)%z_centroid': 0.9, 'patch_icpp(3)%length_z': 0.2,
1285+
})
1286+
stack.push('Restart Roundtrip -> 3D', base_3d)
1287+
cases.append(define_case_d(stack, '', {}, restart_check=True))
1288+
stack.pop()
1289+
1290+
restart_roundtrip_tests()
1291+
1292+
def kernel_golden_tests():
1293+
"""Focused golden-value tests for specific physics kernels.
1294+
1295+
Polydisperse bubbles: exercises bubble array indexing with
1296+
multiple bubble sizes and weight distributions.
1297+
"""
1298+
# Polydisperse bubbles (nb=3 with poly_sigma distribution)
1299+
stack.push('Kernel -> 1D -> Polydisperse Bubbles', {
1300+
'm': 299, 'n': 0, 'p': 0,
1301+
'x_domain%beg': 0.E+00, 'x_domain%end': 1.E+00,
1302+
'bc_x%beg': -3, 'bc_x%end': -3,
1303+
'patch_icpp(1)%geometry': 1, 'patch_icpp(2)%geometry': 1,
1304+
'patch_icpp(3)%geometry': 1,
1305+
'patch_icpp(1)%x_centroid': 0.05, 'patch_icpp(1)%length_x': 0.1,
1306+
'patch_icpp(2)%x_centroid': 0.45, 'patch_icpp(2)%length_x': 0.7,
1307+
'patch_icpp(3)%x_centroid': 0.9, 'patch_icpp(3)%length_x': 0.2,
1308+
'patch_icpp(1)%vel(1)': 0.0, 'patch_icpp(2)%vel(1)': 0.0,
1309+
'patch_icpp(3)%vel(1)': 0.0,
1310+
'dt': 1e-07,
1311+
'bubbles_euler': 'T', 'nb': 3, 'polydisperse': 'T', 'poly_sigma': 0.3,
1312+
'polytropic': 'T', 'bubble_model': 2,
1313+
'fluid_pp(1)%gamma': 0.16, 'fluid_pp(1)%pi_inf': 3515.0,
1314+
'bub_pp%R0ref': 1.0, 'bub_pp%p0ref': 1.0,
1315+
'bub_pp%rho0ref': 1.0, 'bub_pp%T0ref': 1.0,
1316+
'bub_pp%ss': 0.07179866765358993, 'bub_pp%pv': 0.02308216136195411,
1317+
'bub_pp%vd': 0.2404125083932959,
1318+
'bub_pp%mu_l': 0.009954269975623244, 'bub_pp%mu_v': 8.758168074360729e-05,
1319+
'bub_pp%mu_g': 0.00017881922111898042, 'bub_pp%gam_v': 1.33, 'bub_pp%gam_g': 1.4,
1320+
'bub_pp%M_v': 18.02, 'bub_pp%M_g': 28.97, 'bub_pp%k_v': 0.5583395141263873,
1321+
'bub_pp%k_g': 0.7346421281308791, 'bub_pp%R_v': 1334.8378710170155,
1322+
'bub_pp%R_g': 830.2995663005393,
1323+
'patch_icpp(1)%alpha_rho(1)': 0.96, 'patch_icpp(1)%alpha(1)': 4e-02,
1324+
'patch_icpp(2)%alpha_rho(1)': 0.96, 'patch_icpp(2)%alpha(1)': 4e-02,
1325+
'patch_icpp(3)%alpha_rho(1)': 0.96, 'patch_icpp(3)%alpha(1)': 4e-02,
1326+
'patch_icpp(1)%pres': 1.0, 'patch_icpp(2)%pres': 1.0, 'patch_icpp(3)%pres': 1.0,
1327+
})
1328+
cases.append(define_case_d(stack, '', {}))
1329+
stack.pop()
1330+
1331+
kernel_golden_tests()
1332+
11121333
# Sanity Check 1
11131334
if stack.size() != 0:
11141335
raise common.MFCException("list_cases: stack isn't fully pop'ed")

toolchain/mfc/test/test.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,34 @@ def _handle_case(case: TestCase, devices: typing.Set[int]):
384384
if msg is not None:
385385
raise MFCException(f"Test {case}: {msg}")
386386

387+
# Restart roundtrip verification: run to midpoint, restart,
388+
# and compare restarted output against the straight run.
389+
if getattr(case, 'restart_check', False) and not ARG("add_new_variables"):
390+
straight_pack = pack
391+
392+
if timeout_flag.is_set():
393+
raise TestTimeoutError("Test case exceeded 1 hour timeout")
394+
395+
restart_result = case.run_restart([PRE_PROCESS, SIMULATION], devices)
396+
out_filepath_restart = os.path.join(case.get_dirpath(), "out_restart.txt")
397+
common.file_write(out_filepath_restart, restart_result.stdout)
398+
399+
if restart_result.returncode != 0:
400+
cons.print(restart_result.stdout)
401+
raise MFCException(f"Test {case}: Restart roundtrip run failed.")
402+
403+
restart_pack, restart_err = packer.pack(case.get_dirpath())
404+
if restart_err is not None:
405+
raise MFCException(f"Test {case}: Restart pack error: {restart_err}")
406+
407+
if restart_pack.has_NaNs():
408+
raise MFCException(f"Test {case}: NaNs detected in restarted output.")
409+
410+
_, restart_msg = packtol.compare(
411+
restart_pack, straight_pack, packtol.Tolerance(tol, tol))
412+
if restart_msg is not None:
413+
raise MFCException(f"Test {case}: Restart roundtrip mismatch: {restart_msg}")
414+
387415
if ARG("test_all"):
388416
case.delete_output()
389417
# Check timeout before launching the (potentially long) post-process run

0 commit comments

Comments
 (0)