CelerisLab/tests/run_sah04_st_matrix.py

660 lines
25 KiB
Python

# CelerisLab/tests/run_sah04_st_matrix.py
"""Sah04 MRT-only Strouhal validation on S1-S4 anchors.
This runner implements the current validation contract in ``tests/Sah04_validation.md``:
- Cases: S1-S4 only (hard periodic anchors).
- Collision: MRT only.
- Inlet: parabolic + channel_stabilized.
- Walls: no-slip channel (from base config + confined geometry).
- Grid policy: S3/S4 can use configurable refined diameter for diagnostics.
Usage::
conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py
conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py --case S3 --smoke
conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py --gate-pct 10 --json-out tests/output/sah04_mrt/summary.json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Sequence, Tuple
import numpy as np
import pycuda.driver as cuda
_PKG_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_DEFAULT_LBM = os.path.join(_PKG_ROOT, "src", "CelerisLab", "configs", "config_lbm.json")
_BASE_D = 30.0
@dataclass(frozen=True)
class Sah04Case:
"""One hard benchmark case from Sah04_validation.md."""
case_id: str
beta_nominal: float
re_nominal: float
target_st: float
h_fluid: int
steps: int
burn: int
@dataclass(frozen=True)
class CaseGeometry:
"""Resolved lattice geometry for one case."""
diameter: float
h_fluid: int
nx: int
ny: int
center_x: float
center_y: float
radius: float
beta_real: float
wall_gap_cells: float
CASES: Tuple[Sah04Case, ...] = (
Sah04Case("S1", 0.3, 100.0, 0.2115, 100, 120_000, 45_000),
Sah04Case("S2", 0.5, 200.0, 0.3513, 60, 120_000, 45_000),
Sah04Case("S3", 0.8, 160.0, 0.5537, 38, 220_000, 99_000),
Sah04Case("S4", 0.9, 200.0, 0.5314, 33, 220_000, 99_000),
)
def _load_json(path: str) -> dict:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _write_json(path: str, payload: dict) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
def rfft_power_spectrum(samples: np.ndarray, *, sample_dt: float) -> Tuple[np.ndarray, np.ndarray]:
"""Mean-subtracted Hanning-windowed signal to rFFT power spectrum."""
x = np.asarray(samples, dtype=np.float64)
x = x - np.mean(x)
n = x.size
if n < 64:
return np.zeros(0, dtype=np.float64), np.zeros(0, dtype=np.float64)
win = np.hanning(n)
spec = np.abs(np.fft.rfft(x * win)) ** 2
freqs = np.fft.rfftfreq(n, d=float(sample_dt))
return freqs.astype(np.float64), spec.astype(np.float64)
def _parabolic_peak_freq(freqs: np.ndarray, spec: np.ndarray, idx: int) -> float:
"""Sub-bin frequency estimate with local log-parabolic interpolation."""
i = int(np.clip(idx, 0, spec.size - 1))
if i <= 0 or i + 1 >= spec.size:
return float(freqs[i])
y0 = np.log(spec[i - 1] + 1e-30)
y1 = np.log(spec[i] + 1e-30)
y2 = np.log(spec[i + 1] + 1e-30)
den = y0 - 2.0 * y1 + y2
if abs(den) < 1e-20:
return float(freqs[i])
delta = float(np.clip(0.5 * (y0 - y2) / den, -1.0, 1.0))
return float(freqs[i]) + delta * float(freqs[i + 1] - freqs[i])
def _strouhal_from_lift(
lift: np.ndarray,
*,
diameter: float,
u_max: float,
sample_dt: float,
f_hz_min: float,
f_hz_max: float,
) -> Tuple[float, float]:
"""Return guided Strouhal and guided dominant frequency."""
freqs, spec = rfft_power_spectrum(lift, sample_dt=sample_dt)
if freqs.size == 0:
return float("nan"), float("nan")
band = (freqs >= float(f_hz_min)) & (freqs <= float(f_hz_max))
if not np.any(band):
return float("nan"), float("nan")
f0 = 0.5 * (float(f_hz_min) + float(f_hz_max))
sigma = max(1e-12, 0.18 * f0)
weight = np.exp(-((freqs - f0) / sigma) ** 2)
idx = int(np.argmax(spec * band.astype(np.float64) * weight))
f_peak = _parabolic_peak_freq(freqs, spec, idx)
return float(f_peak * diameter / u_max), float(f_peak)
def shedding_freq_band_hz(
target_st: float,
u_max: float,
diameter: float,
*,
half_width: float = 0.42,
) -> Tuple[float, float]:
"""Frequency band around target shedding frequency for robust FFT pick."""
f0 = float(target_st) * float(u_max) / float(diameter)
return max(1e-8, f0 * (1.0 - half_width)), f0 * (1.0 + half_width)
def vorticity_z_from_velocity(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
"""Return z-vorticity for 2D velocity fields."""
ux = np.asarray(ux, dtype=np.float64)
uy = np.asarray(uy, dtype=np.float64)
return np.gradient(uy, axis=1) - np.gradient(ux, axis=0)
def save_final_vorticity_png(path: str, ux: np.ndarray, uy: np.ndarray, *, title: str) -> None:
"""Save final-step vorticity image; requires matplotlib."""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError as exc:
raise RuntimeError("save_final_vorticity_png requires matplotlib.") from exc
omega = vorticity_z_from_velocity(ux, uy)
abs_o = np.abs(omega[np.isfinite(omega)])
vmax = float(np.percentile(abs_o, 99.5)) if abs_o.size else 1.0
if vmax <= 0.0:
vmax = 1.0
ny, nx = omega.shape
fig, ax = plt.subplots(figsize=(min(18.0, max(8.0, nx / 100.0)), min(12.0, max(3.0, ny / 40.0))))
im = ax.imshow(
omega,
origin="lower",
aspect="equal",
cmap="RdBu_r",
vmin=-vmax,
vmax=vmax,
extent=(0, nx - 1, 0, ny - 1),
)
ax.set_xlabel("x (lattice)")
ax.set_ylabel("y (lattice)")
ax.set_title(title)
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label="omega_z")
fig.tight_layout()
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
def _build_case_geometry(
case: Sah04Case,
*,
refine_high_beta: bool,
high_beta_diameter: float,
diameter_override: Optional[float],
h_fluid_override: Optional[int],
) -> CaseGeometry:
"""Map case to geometry, with optional high-beta refinement."""
diameter = _BASE_D
h_fluid = int(case.h_fluid)
if diameter_override is not None or h_fluid_override is not None:
if diameter_override is None or h_fluid_override is None:
raise ValueError("Both --diameter-override and --h-fluid-override must be set together.")
diameter = float(diameter_override)
h_fluid = int(h_fluid_override)
elif refine_high_beta and case.case_id in ("S3", "S4"):
# Diagnostic default keeps high-blockage runs around D~80 for faster sweeps.
diameter = float(high_beta_diameter)
h_fluid = int(round(float(diameter) / float(case.beta_nominal)))
nx = int(80.0 * diameter + 2.0)
ny = int(h_fluid + 2)
center_x = 40.0 * diameter + 0.5
center_y = 0.5 * float(h_fluid) + 0.5
radius = 0.5 * diameter
beta_real = float(diameter / float(h_fluid))
wall_gap_cells = 0.5 * float(h_fluid - diameter)
return CaseGeometry(
diameter=diameter,
h_fluid=h_fluid,
nx=nx,
ny=ny,
center_x=center_x,
center_y=center_y,
radius=radius,
beta_real=beta_real,
wall_gap_cells=wall_gap_cells,
)
def _relative_error(measured: float, target: float) -> Optional[float]:
if not np.isfinite(measured) or target <= 0.0:
return None
return abs(float(measured) - float(target)) / float(target)
def _realized_umax(ux: np.ndarray, *, probe_x: int) -> float:
"""Estimate developed centerline maximum from one downstream vertical profile."""
# Skip top/bottom walls and sample at a downstream x station.
prof = np.asarray(ux[1:-1, int(probe_x)], dtype=np.float64)
if prof.size == 0:
return float("nan")
return float(np.max(prof))
def run_one_simulation(
case: Sah04Case,
geometry: CaseGeometry,
*,
collision: str,
outlet_mode: str,
inlet_profile: str,
inlet_scheme: str,
u_max_nominal: float,
steps: int,
burn: int,
record_every: int,
f_hz_min: float,
f_hz_max: float,
dump_npz_path: Optional[str] = None,
final_vorticity_png_path: Optional[str] = None,
crash_dump_dir: Optional[str] = None,
) -> Dict[str, Any]:
"""Build config, run simulation, and return measured metrics."""
nu = float(u_max_nominal * geometry.diameter / case.re_nominal)
u0_mean = float(u_max_nominal / 1.5)
cfg = _load_json(_DEFAULT_LBM)
cfg["grid"]["nx"] = int(geometry.nx)
cfg["grid"]["ny"] = int(geometry.ny)
cfg["grid"]["nz"] = 1
cfg["physics"]["viscosity"] = float(nu)
cfg["physics"]["velocity"] = float(u0_mean)
cfg["physics"]["rho"] = 1.0
cfg["method"]["collision"] = str(collision).upper()
cfg["method"]["streaming"] = "double_buffer"
cfg["method"]["les"]["enabled"] = False
cfg["method"]["inlet"]["profile"] = str(inlet_profile)
cfg["method"]["inlet"]["scheme"] = str(inlet_scheme)
cfg["method"]["outlet"]["mode"] = str(outlet_mode)
body_doc = {
"objects": [
{
"type": "cylinder",
"center": [float(geometry.center_x), float(geometry.center_y)],
"radius": float(geometry.radius),
}
]
}
tmpd = tempfile.mkdtemp(prefix="celeris_sah04_mrt_")
lbm_tmp = os.path.join(tmpd, "config_lbm.json")
body_tmp = os.path.join(tmpd, "config_body.json")
_write_json(lbm_tmp, cfg)
_write_json(body_tmp, body_doc)
from CelerisLab import Simulation # noqa: WPS433
sim = Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp)
sim.initialize()
stream = cuda.Stream()
rec_every = max(1, int(record_every))
lift_hist: List[float] = []
fx_hist: List[float] = []
step_hist: List[int] = []
n_curved = int(sim.field.n_curved)
fallback_links = int(sim.bodies.fallback_link_count())
low_q_links = int(sim.bodies.low_q_link_count())
for step in range(1, int(steps) + 1):
sim.bodies.zero_force_segment_async(stream)
sim.stepper.step(
1,
action_gpu=sim.bodies.action_gpu,
obs_gpu=sim.bodies.obs_gpu,
stream=stream,
)
if step % rec_every == 0 or step == int(steps):
stream.synchronize()
sim.bodies.download_obs_full_async(stream)
stream.synchronize()
fvec = sim.bodies.read_force(0)
lift = float(fvec[1])
drag = float(fvec[0])
if not np.isfinite(lift) or not np.isfinite(drag):
crash_npz_path: Optional[str] = None
crash_png_path: Optional[str] = None
if crash_dump_dir:
os.makedirs(crash_dump_dir, exist_ok=True)
stream.synchronize()
macro_bad = sim.get_macroscopic()
ux_bad = np.asarray(macro_bad["ux"], dtype=np.float64).reshape(geometry.ny, geometry.nx)
uy_bad = np.asarray(macro_bad["uy"], dtype=np.float64).reshape(geometry.ny, geometry.nx)
rho_bad = np.asarray(macro_bad["rho"], dtype=np.float64).reshape(geometry.ny, geometry.nx)
prefix = f"{case.case_id.lower()}_{str(collision).lower()}_crash_step{step}"
crash_npz_path = os.path.join(crash_dump_dir, f"{prefix}.npz")
np.savez_compressed(
crash_npz_path,
rho=rho_bad.astype(np.float32),
ux=ux_bad.astype(np.float32),
uy=uy_bad.astype(np.float32),
step=np.array([int(step)], dtype=np.int64),
case_id=np.array([case.case_id]),
collision=np.array([str(collision)]),
inlet_scheme=np.array([str(inlet_scheme)]),
outlet_mode=np.array([str(outlet_mode)]),
)
crash_png_path = os.path.join(crash_dump_dir, f"{prefix}.png")
try:
save_final_vorticity_png(
crash_png_path,
ux_bad,
uy_bad,
title=(
f"Sah04 {case.case_id} {collision} crash@{step} "
f"D={int(geometry.diameter)} H={geometry.h_fluid}"
),
)
except Exception: # noqa: BLE001
crash_png_path = None
sim.close()
msg = f"NaN/Inf force at step {step}"
if crash_npz_path:
msg += f"; crash_npz={crash_npz_path}"
if crash_png_path:
msg += f"; crash_png={crash_png_path}"
raise RuntimeError(msg)
lift_hist.append(lift)
fx_hist.append(drag)
step_hist.append(step)
stream.synchronize()
macro_last = sim.get_macroscopic()
ux_last = np.asarray(macro_last["ux"], dtype=np.float64).reshape(geometry.ny, geometry.nx)
uy_last = np.asarray(macro_last["uy"], dtype=np.float64).reshape(geometry.ny, geometry.nx)
rho_last = np.asarray(macro_last["rho"], dtype=np.float64).reshape(geometry.ny, geometry.nx)
sim.close()
if final_vorticity_png_path:
out_dir = os.path.dirname(os.path.abspath(final_vorticity_png_path))
if out_dir:
os.makedirs(out_dir, exist_ok=True)
save_final_vorticity_png(
final_vorticity_png_path,
ux_last,
uy_last,
title=f"Sah04 {case.case_id} MRT Re={case.re_nominal:.0f} beta_nom={case.beta_nominal:.1f}",
)
lift_arr = np.asarray(lift_hist, dtype=np.float64)
fx_arr = np.asarray(fx_hist, dtype=np.float64)
step_arr = np.asarray(step_hist, dtype=np.int64)
burn_idx = min(int(burn) // rec_every, max(0, lift_arr.size - 16))
lift_tail = lift_arr[burn_idx:]
st, f_peak = _strouhal_from_lift(
lift_tail,
diameter=float(geometry.diameter),
u_max=float(u_max_nominal),
sample_dt=float(rec_every),
f_hz_min=float(f_hz_min),
f_hz_max=float(f_hz_max),
)
mean_cd = (
float(np.mean(fx_arr[burn_idx:]) * 2.0 / (u_max_nominal**2 * geometry.diameter))
if fx_arr.size
else float("nan")
)
beta_real = float(geometry.beta_real)
probe_x = min(geometry.nx - 2, max(2, geometry.nx - 10))
u_max_real = _realized_umax(ux_last, probe_x=probe_x)
re_real = float(u_max_real * geometry.diameter / nu) if np.isfinite(u_max_real) and nu > 0.0 else float("nan")
if dump_npz_path:
freqs, power = rfft_power_spectrum(lift_tail, sample_dt=float(rec_every))
out_dir = os.path.dirname(os.path.abspath(dump_npz_path))
if out_dir:
os.makedirs(out_dir, exist_ok=True)
np.savez_compressed(
dump_npz_path,
lift_samples=lift_arr,
drag_samples=fx_arr,
sample_lbm_step=step_arr,
burn_index_samples=int(burn_idx),
record_every_lbm_steps=int(rec_every),
freqs_hz_post_burn=freqs,
power_post_burn=power,
rho_final=rho_last.astype(np.float32),
ux_final=ux_last.astype(np.float32),
uy_final=uy_last.astype(np.float32),
st=np.array([st], dtype=np.float64),
f_peak=np.array([f_peak], dtype=np.float64),
re_real=np.array([re_real], dtype=np.float64),
beta_real=np.array([beta_real], dtype=np.float64),
)
return {
"collision": str(collision).upper(),
"inlet_profile": inlet_profile,
"inlet_scheme": inlet_scheme,
"St": float(st),
"f_peak_per_step": float(f_peak),
"mean_Cd": float(mean_cd),
"Re_real": float(re_real),
"U_max_real": float(u_max_real),
"beta_real": float(beta_real),
"n_curved": n_curved,
"fallback_links": fallback_links,
"low_q_links": low_q_links,
"rho_min_final": float(np.min(rho_last)),
"rho_max_final": float(np.max(rho_last)),
"n_lift_samples": int(lift_arr.size),
}
def evaluate_rows(rows: Sequence[Dict[str, Any]], *, gate_pct: float) -> Dict[str, Any]:
"""Aggregate pass/fail summary for S1-S4 hard anchors."""
valid_rows = [r for r in rows if "error" not in r]
st_errs = [r.get("St_error_pct") for r in valid_rows if r.get("St_error_pct") is not None]
pass_count = sum(1 for v in st_errs if float(v) <= float(gate_pct))
return {
"gate_pct": float(gate_pct),
"cases_total": len(rows),
"cases_completed": len(valid_rows),
"cases_failed": sum(1 for r in rows if "error" in r),
"cases_within_gate": int(pass_count),
"pass_gate_all_completed": bool(len(valid_rows) > 0 and pass_count == len(valid_rows)),
}
def main() -> int:
ap = argparse.ArgumentParser(description="Sah04 MRT-only S1-S4 validation runner")
ap.add_argument("--case", default="all", help='S1-S4 or "all"')
ap.add_argument("--collision", default="MRT", choices=("SRT", "TRT", "MRT"))
ap.add_argument("--outlet", default="neq_extrap", choices=("neq_extrap", "zero_gradient", "blended"))
ap.add_argument("--inlet-profile", default="parabolic", choices=("parabolic",))
ap.add_argument(
"--inlet-scheme",
default="channel_stabilized",
choices=("channel_stabilized", "regularized", "zou_he_local", "equilibrium"),
)
ap.add_argument("--record-every", type=int, default=5)
ap.add_argument("--smoke", action="store_true", help="Short run for wiring checks.")
ap.add_argument("--steps", type=int, default=None, help="Override case steps (ignored with --smoke).")
ap.add_argument("--burn", type=int, default=None, help="Override case burn (ignored with --smoke).")
ap.add_argument("--gate-pct", type=float, default=5.0, help="Pass gate for St relative error percent.")
ap.add_argument("--json-out", type=str, default=None, help="Write summary JSON.")
ap.add_argument("--dump-npz-dir", type=str, default=None, help="Optional directory for case NPZ dumps.")
ap.add_argument("--final-vorticity-dir", type=str, default=None, help="Optional directory for final vorticity PNG.")
ap.add_argument(
"--crash-dump-dir",
type=str,
default=None,
help="Optional directory to dump full flowfield NPZ/PNG immediately before crash.",
)
ap.add_argument(
"--no-refine-high-beta",
action="store_true",
help="Disable default refined geometry for S3/S4 (debug only).",
)
ap.add_argument(
"--high-beta-diameter",
type=float,
default=80.0,
help="Refined diameter for S3/S4 when high-beta refinement is enabled.",
)
ap.add_argument("--diameter-override", type=float, default=None, help="Override cylinder diameter for selected case.")
ap.add_argument("--h-fluid-override", type=int, default=None, help="Override fluid height H for selected case.")
args = ap.parse_args()
if not os.path.isfile(_DEFAULT_LBM):
print(f"Missing base config: {_DEFAULT_LBM}", file=sys.stderr)
return 2
selected_case = str(args.case).upper()
if selected_case != "ALL" and selected_case not in {c.case_id for c in CASES}:
print("--case must be one of S1,S2,S3,S4,all", file=sys.stderr)
return 2
cases_to_run = [c for c in CASES if selected_case == "ALL" or c.case_id == selected_case]
if args.dump_npz_dir:
os.makedirs(args.dump_npz_dir, exist_ok=True)
if args.final_vorticity_dir:
os.makedirs(args.final_vorticity_dir, exist_ok=True)
rows: List[Dict[str, Any]] = []
for case in cases_to_run:
geometry = _build_case_geometry(
case,
refine_high_beta=not bool(args.no_refine_high_beta),
high_beta_diameter=float(args.high_beta_diameter),
diameter_override=args.diameter_override,
h_fluid_override=args.h_fluid_override,
)
steps = 5000 if args.smoke else (int(args.steps) if args.steps is not None else case.steps)
burn = 1500 if args.smoke else (int(args.burn) if args.burn is not None else case.burn)
f_lo, f_hi = shedding_freq_band_hz(case.target_st, 0.1, geometry.diameter)
npz_path = os.path.join(args.dump_npz_dir, f"{case.case_id.lower()}_mrt.npz") if args.dump_npz_dir else None
vort_path = (
os.path.join(args.final_vorticity_dir, f"{case.case_id.lower()}_mrt_laststep.png")
if args.final_vorticity_dir
else None
)
print(
f"--- {case.case_id} {args.collision} beta_nom={case.beta_nominal:.1f} Re_nom={case.re_nominal:.0f} "
f"D={int(geometry.diameter)} H={geometry.h_fluid} gap~{geometry.wall_gap_cells:.2f} "
f"steps={steps} burn={burn} inlet={args.inlet_scheme}/{args.inlet_profile} ---",
flush=True,
)
try:
out = run_one_simulation(
case,
geometry,
collision=args.collision,
outlet_mode=args.outlet,
inlet_profile=args.inlet_profile,
inlet_scheme=args.inlet_scheme,
u_max_nominal=0.1,
steps=steps,
burn=burn,
record_every=int(args.record_every),
f_hz_min=f_lo,
f_hz_max=f_hi,
dump_npz_path=npz_path,
final_vorticity_png_path=vort_path,
crash_dump_dir=args.crash_dump_dir,
)
except Exception as exc: # noqa: BLE001
rows.append(
{
"case_id": case.case_id,
"collision": str(args.collision).upper(),
"inlet_scheme": args.inlet_scheme,
"inlet_profile": args.inlet_profile,
"outlet": args.outlet,
"grid": {"nx": geometry.nx, "ny": geometry.ny, "diameter": int(geometry.diameter), "h_fluid": geometry.h_fluid},
"error": str(exc),
}
)
print(f"FAILED: {exc}", flush=True)
continue
rel_err = _relative_error(out["St"], case.target_st)
st_err_pct = (100.0 * rel_err) if rel_err is not None else None
row = {
"case_id": case.case_id,
"collision": out["collision"],
"inlet_scheme": out["inlet_scheme"],
"inlet_profile": out["inlet_profile"],
"grid": {"nx": geometry.nx, "ny": geometry.ny, "diameter": int(geometry.diameter), "h_fluid": geometry.h_fluid},
"steps": int(steps),
"burn_in": int(burn),
"Re_nominal": float(case.re_nominal),
"Re_real": out["Re_real"],
"beta_nominal": float(case.beta_nominal),
"beta_real": out["beta_real"],
"wall_gap_cells": geometry.wall_gap_cells,
"target_St": float(case.target_st),
"St": out["St"],
"St_error_pct": float(st_err_pct) if st_err_pct is not None and np.isfinite(st_err_pct) else None,
"gate_pct": float(args.gate_pct),
"gate_pass": bool(st_err_pct is not None and st_err_pct <= float(args.gate_pct)),
"mean_Cd": out["mean_Cd"],
"U_max_real": out["U_max_real"],
"rho_min_final": out["rho_min_final"],
"rho_max_final": out["rho_max_final"],
"n_curved": out["n_curved"],
"fallback_links": out["fallback_links"],
"low_q_links": out["low_q_links"],
"n_lift_samples": out["n_lift_samples"],
}
rows.append(row)
st_err_txt = f"{row['St_error_pct']:.2f}%" if row["St_error_pct"] is not None else "n/a"
re_real_txt = f"{row['Re_real']:.2f}" if np.isfinite(row["Re_real"]) else "nan"
print(
f" St={row['St']:.5f} target={row['target_St']:.5f} err={st_err_txt} "
f"Re_real={re_real_txt} beta_real={row['beta_real']:.4f} "
f"[{'PASS' if row['gate_pass'] else 'CHECK'}]",
flush=True,
)
evaluation = evaluate_rows(rows, gate_pct=float(args.gate_pct))
print("\n=== Sah04 MRT S1-S4 summary ===", flush=True)
print(json.dumps(evaluation, indent=2), flush=True)
if args.json_out:
json_out_path = os.path.abspath(args.json_out)
json_out_dir = os.path.dirname(json_out_path)
if json_out_dir:
os.makedirs(json_out_dir, exist_ok=True)
_write_json(
json_out_path,
{
"requested": {
"case": args.case,
"outlet": args.outlet,
"inlet_profile": args.inlet_profile,
"inlet_scheme": args.inlet_scheme,
"record_every": int(args.record_every),
"smoke": bool(args.smoke),
"steps_override": args.steps,
"burn_override": args.burn,
"gate_pct": float(args.gate_pct),
},
"rows": rows,
"evaluation": evaluation,
},
)
print(f"Wrote: {json_out_path}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())