Closed-domain thermodynamic-pressure rise (sealed heated box)¶
Results pending GPU execution. This notebook is a scaffold: the analysis is complete but it has not yet been run against simulation output. Run the case with uv run nassu run validation/buoyant_flows/05_closed_domain_pressure/05_closed_domain_pressure.nassu.yaml on a GPU, then execute this notebook.
This case validates the closed-domain low-Mach thermodynamic-pressure closure (models.low_mach.domain_closure: closed, Taha et al. 2024). A sealed, rigid, adiabatic box of ideal gas is heated at a known uniform volumetric rate Q. With no inlet/outlet the total mass is conserved, so the closure evolves the spatially-uniform thermodynamic pressure from total-mass conservation P(t) = M r / I(t), with I(t) = integral (1/T) dV.
The analytic target is the constant-volume first-law result for a sealed rigid adiabatic ideal gas heated at total rate Q_total:
exact for adiabatic walls (the conduction term integrates to the zero wall heat flux). With whole-box uniform heating the volumetric rate Q gives Q_total = Q V, so the target reduces to dP/dt = (gamma - 1) Q. The notebook recovers P(t) node-wise from the EOS, fits the slope, and asserts it matches the target to within 2%.
[ ]:
import os
import pathlib
import glob
import numpy as np
import matplotlib.pyplot as plt
import h5py
from nassu.cfg.model import ConfigScheme
import nassu.viz as common
common.use_style()
[ ]:
def _find_project_root() -> pathlib.Path:
here = pathlib.Path.cwd().resolve()
for cand in [here, *here.parents]:
if (cand / "pyproject.toml").exists() and (cand / "nassu").is_dir():
return cand
raise RuntimeError(f"Could not locate Nassu project root upward from {here}")
# Run from the project root: the simulation `save_path` (and every output path read
# off the parsed config below) is repo-root-relative.
project_root = _find_project_root()
os.chdir(project_root)
case_dir = project_root / "validation/buoyant_flows/05_closed_domain_pressure"
config_path = case_dir / "05_closed_domain_pressure.nassu.yaml"
sim_cfg = ConfigScheme.sim_cfgs_from_file(str(config_path))[0]
n_steps = sim_cfg.n_steps
print(f"loaded {sim_cfg.name}: n_steps={n_steps}, domain={sim_cfg.domain.domain_size}")
[ ]:
# Analytic quantities read straight off the parsed low-Mach config, so the target is
# never hard-coded: gamma = cp / cv with cv = cp - r, Q is the whole-box volumetric
# source rate, and the target slope is (gamma - 1) Q.
lm = sim_cfg.models.low_mach
r_gas = lm.r
cp = lm.cp
cv = cp - r_gas
gamma = cp / cv
P0 = lm.P_thermo
Q = lm.energy.source_region_rate # volumetric heat-release rate (energy/vol/step)
ds = sim_cfg.domain.domain_size
V = ds.x * ds.y * ds.z
Q_total = Q * V # whole-box uniform heating
dPdt_analytic = (gamma - 1.0) * Q_total / V # = (gamma - 1) Q
print(f"r={r_gas}, cp={cp}, cv={cv}, gamma={gamma}")
print(f"V={V}, Q={Q:.3e}, Q_total={Q_total:.3e}")
print(f"analytic dP/dt = (gamma-1) Q_total/V = {dPdt_analytic:.6e} per step")
[ ]:
# Locate the instantaneous volume export off the parsed config and read each snapshot.
# The volume export stores the full domain as a single block per time group:
# `t<step>/block0/<macr>`, each a 3-D array in (z, y, x) layout.
export = sim_cfg.output.instantaneous["default"]
h5_path = sorted(glob.glob(str(export.base_path) + "*.h5"))[0]
print("reading", h5_path)
def read_snapshots(path):
with h5py.File(path, "r") as h:
times = sorted({k.split("/")[0] for k in h.keys()}, key=lambda s: float(s[1:]))
steps = np.array([float(t[1:]) for t in times])
data = {}
for t in times:
g = h[t]["block0"]
data[t] = {m: g[m][:] for m in ("rho", "ux", "uy", "uz", "temp")}
return steps, times, data
steps, times, snaps = read_snapshots(h5_path)
print(f"{len(steps)} snapshots, steps {steps[0]:.0f} .. {steps[-1]:.0f}")
[ ]:
# Recover the spatially-uniform thermodynamic pressure node-wise from the EOS
# rho = P/(r T) => P = r * rho * T, exact since P is uniform. Also collect the peak
# speed (must stay quiescent) and the spatial spread of rho/T (must stay uniform).
cs = 1.0 / np.sqrt(3.0)
P_t, umax, rho_spread, T_spread = [], [], [], []
any_nan = False
for t in times:
s = snaps[t]
rho, T = s["rho"], s["temp"]
P_t.append(float(r_gas * np.nanmean(rho * T)))
speed = np.sqrt(s["ux"] ** 2 + s["uy"] ** 2 + s["uz"] ** 2)
umax.append(float(np.nanmax(speed)))
rho_spread.append(float(np.nanstd(rho) / np.nanmean(rho)))
T_spread.append(float(np.nanstd(T) / np.nanmean(T)))
any_nan = any_nan or bool(np.isnan(rho).any() or np.isnan(T).any())
P_t = np.array(P_t)
umax = np.array(umax)
rho_spread = np.array(rho_spread)
T_spread = np.array(T_spread)
# Least-squares slope of P(t) over the whole run (uniform heating -> linear from step 0).
slope, intercept = np.polyfit(steps, P_t, 1)
rel_err = abs(slope - dPdt_analytic) / abs(dPdt_analytic)
print(f"P(0) recovered = {P_t[0]:.6f} (config P0 = {P0})")
print(f"fitted dP/dt = {slope:.6e} per step")
print(f"analytic dP/dt = {dPdt_analytic:.6e} per step")
print(f"relative error = {rel_err * 100:.3f} %")
print(f"peak |u| over run = {umax.max():.3e} (Ma_max = {umax.max() / cs:.3e})")
Results¶
[ ]:
fig, ax = plt.subplots(1, 3, figsize=(16, 4))
# (1) P(t) recovered vs the analytic linear rise.
P_analytic = P0 + dPdt_analytic * steps
ax[0].plot(steps, P_t, "o", ms=4, label=r"$P(t) = r\,\langle\rho T\rangle$ (recovered)")
ax[0].plot(steps, P_analytic, "--", c="k", lw=1.5, label=r"analytic $P_0 + (\gamma-1)\,Q\,t$")
ax[0].set_xlabel("step")
ax[0].set_ylabel("thermodynamic pressure $P$")
ax[0].set_title(f"Closed-domain pressure rise (err {rel_err * 100:.2f}%)")
ax[0].legend()
ax[0].grid(alpha=0.3)
# (2) Residual of the recovered pressure about the analytic rise, with the +/-2%
# acceptance band scaled to the analytic rise at each step (absolute units).
residual = P_t - P_analytic
tol_band = 0.02 * dPdt_analytic * steps
ax[1].plot(steps, residual, "-o", ms=4, c="C3", label=r"$P(t) - P_\mathrm{analytic}$")
ax[1].fill_between(steps, -tol_band, tol_band, color="grey", alpha=0.2, label="2% band")
ax[1].axhline(0.0, ls="--", c="k", lw=1)
ax[1].set_xlabel("step")
ax[1].set_ylabel(r"$P - P_\mathrm{analytic}$")
ax[1].set_title("Pressure residual vs analytic rise")
ax[1].legend()
ax[1].grid(alpha=0.3)
# (3) Quiescence and spatial-uniformity diagnostics.
ax[2].plot(steps, umax, "-o", ms=3, label=r"$\max|u|$")
ax[2].plot(steps, rho_spread, "-s", ms=3, label=r"$\sigma(\rho)/\langle\rho\rangle$")
ax[2].plot(steps, T_spread, "-^", ms=3, label=r"$\sigma(T)/\langle T\rangle$")
ax[2].axhline(0.1 * cs, ls="--", c="grey", lw=1, label=r"$\mathrm{Ma}=0.1$")
ax[2].set_xlabel("step")
ax[2].set_ylabel("value")
ax[2].set_title("Box stays quiescent and spatially uniform")
ax[2].legend()
ax[2].grid(alpha=0.3)
fig.tight_layout()
Summary¶
Acceptance criteria for the closed-domain pressure closure:
Pressure-rise rate - the fitted
dP/dtmatches the analytic(gamma - 1) Qto within 2%.No divergence - all snapshots are finite (no NaN in
rho/T).Quiescent box -
max|u|stays far belowMa = 0.1(no spurious dilatation flow or reduced-pressure checkerboard mode).Spatial uniformity -
rhoandTstay spatially uniform (small relative spread), confirming the closure keeps the uniformly-heated sealed box homogeneous.
[ ]:
assert not any_nan, "divergence: NaN found in the fields"
assert rel_err < 0.02, f"dP/dt off analytic by {rel_err * 100:.2f}% (> 2% tolerance)"
assert umax.max() < 0.1 * cs, f"velocity not subsonic: max|u|={umax.max():.3e}"
assert rho_spread.max() < 1e-2, f"rho not spatially uniform: max spread {rho_spread.max():.3e}"
assert T_spread.max() < 1e-2, f"T not spatially uniform: max spread {T_spread.max():.3e}"
print("VALIDATION PASSED: closed-domain dP/dt matches (gamma-1) Q within 2%.")
Version¶
[ ]:
sim_info = sim_cfg.output.read_info()
print(sim_info)
Configuration¶
[ ]:
from IPython.display import Code
Code(filename=str(config_path), language="yaml")