From 3651b04a9a6a9583079629efc901b2dc3b7e7ba0 Mon Sep 17 00:00:00 2001 From: Marc Day Date: Thu, 27 Aug 2026 22:12:07 +0200 Subject: [PATCH 1/4] NSCBC 1/7: characteristic ghost-cell boundary conditions, 1-D-normal LODI limit The base characteristic treatment as a ghost-cell fill: one wave decomposition in an outward-normal frame, two boundary types. Outgoing invariants extrapolate from the interior on a minmod-limited slope; the incoming acoustic is modelled as a Poinsot-Lele relaxation written as a gradient increment (a mesh-independent rate, so literature coefficients transfer), sigma at outflows, relax_u/relax_t at inflows, with a stateless feed-forward slot (Target::dudt) for problems that inject signals. Supersonic faces are exact. Flow reversal at an outflow runs the same closure with the material slopes upwinded off across a narrow Mach band -- one code path on both sides of u_out = 0. The fill is a pure function of pre-launch interior data: idempotent, order-independent, bit-reproducible across restarts and decompositions, GPU-resident (POD captures, counted fallbacks instead of device aborts). Every fallback increments a counter and the counters report by default -- a silent fallback is indistinguishable from a healthy boundary. Problems opt in per boundary POINT through a bcnormal_nscbc() hook returning a Target; a face may mix inflow, outflow and wall. EB requires eb_zero_body_state (fatal otherwise, the fill detects covered cells by non-positive density); an AMR fine face touching a characteristic boundary warns. This PR is the 1-D-normal LODI limit on purpose: transverse terms and the reacting-boundary closures arrive as the next two PRs in this stack, each an additive, default-off correction to the modelled incoming wave (some comments here forward-reference them). Verification driver and regression cases follow in the stack; headline number from the case PR: an acoustic pulse leaves through this boundary with 0.8% reflection against 97% for a hard-pressure fill. Also removes the dead nscbc_adv/nscbc_diff keys of the deleted Fortran implementation (aborting, not ignoring, when an inputs file still sets them) and retires the 1 K default for eb_boundary_T in favour of 300 K. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XU8M23nucKsu1do2WxXFeq --- .codespell-ignore-words | 4 + .gitignore | 1 + Exec/RegTests/MMS/mms-4.inp | 9 +- Source/BCfill.cpp | 325 +++++++- Source/Diffusion.cpp | 50 +- Source/Hydro.cpp | 56 +- Source/NSCBC.H | 738 ++++++++++++++++++ Source/Params/_cpp_parameters | 63 +- Source/Params/param_includes/pelec_defaults.H | 10 +- Source/Params/param_includes/pelec_params.H | 8 +- Source/Params/param_includes/pelec_queries.H | 8 +- Source/PeleC.H | 27 + Source/PeleC.cpp | 119 +++ Source/ProblemSpecificFunctions.H | 32 + Source/SumIQ.cpp | 2 + 15 files changed, 1356 insertions(+), 96 deletions(-) create mode 100644 Source/NSCBC.H diff --git a/.codespell-ignore-words b/.codespell-ignore-words index 49d7e99a6..c90a7c1a9 100644 --- a/.codespell-ignore-words +++ b/.codespell-ignore-words @@ -4,3 +4,7 @@ blocs renewl frop fpr +ue +statics +Rin +anly diff --git a/.gitignore b/.gitignore index 9dea5cb1e..fbbf487af 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ Docs/sphinx/doctrees/ Docs/sphinx_doc/ __pycache__ +build*/ diff --git a/Exec/RegTests/MMS/mms-4.inp b/Exec/RegTests/MMS/mms-4.inp index 8982912ae..ec02f873a 100644 --- a/Exec/RegTests/MMS/mms-4.inp +++ b/Exec/RegTests/MMS/mms-4.inp @@ -16,9 +16,12 @@ amr.n_cell = 16 16 16 pelec.lo_bc = "FOExtrap" "NoSlipWall" "Hard" pelec.hi_bc = "Hard" "NoSlipWall" "FOExtrap" -# We should not have NSCBC turned on for this test case -pelec.nscbc_adv = 0 -pelec.nscbc_diff = 0 +# NOTE: this case must keep its "Hard" faces as exact Dirichlet faces -- the +# manufactured solution is imposed there and any characteristic relaxation of +# the boundary state would invalidate the convergence test. It therefore must +# NOT enable the characteristic (NSCBC) boundary treatment. (This used to be +# spelled pelec.nscbc_adv = 0 / pelec.nscbc_diff = 0, which had no effect; both +# keys have been removed.) # WHICH PHYSICS pelec.do_hydro = 1 diff --git a/Source/BCfill.cpp b/Source/BCfill.cpp index 6d08a815c..51b905345 100644 --- a/Source/BCfill.cpp +++ b/Source/BCfill.cpp @@ -1,22 +1,205 @@ +#include + #include #include +#include #include +#include #include "PeleC.H" #include "prob.H" +#include "NSCBC.H" + +namespace { +// Device-resident fallback counters for the characteristic boundary +// treatment. Allocated on first use, reduced and reported by +// PeleC::nscbc_report_diagnostics(). +// +// Held as a heap pointer released through amrex::ExecOnFinalize rather than as +// a function-local static object. A static Gpu::DeviceVector destructs at +// program exit, which is AFTER amrex::Finalize() has torn down the arena it +// allocated from -- harmless on a CPU build and a use-after-free of the device +// allocator on a GPU one. +amrex::Gpu::DeviceVector* nscbc_diag_p = nullptr; + +amrex::Gpu::DeviceVector& +nscbc_diag() +{ + if (nscbc_diag_p == nullptr) { + nscbc_diag_p = + new amrex::Gpu::DeviceVector(pc_nscbc::Diag::count, 0); + amrex::ExecOnFinalize([]() { + delete nscbc_diag_p; + nscbc_diag_p = nullptr; + }); + } + return *nscbc_diag_p; +} + +} // namespace struct PCHypFillExtDir { ProbParmDevice const* lprobparm; bool m_do_turb_inflow{false}; + bool m_nscbc{false}; + amrex::GpuArray m_nscbc_prm; + amrex::Long* m_nscbc_diag{nullptr}; AMREX_GPU_HOST - constexpr explicit PCHypFillExtDir( - const ProbParmDevice* d_prob_parm, const bool do_turb_inflow) - : lprobparm(d_prob_parm), m_do_turb_inflow(do_turb_inflow) + explicit PCHypFillExtDir( + const ProbParmDevice* d_prob_parm, + const bool do_turb_inflow, + const bool nscbc, + const amrex::GpuArray& nscbc_prm, + amrex::Long* nscbc_diag) + : lprobparm(d_prob_parm), + m_do_turb_inflow(do_turb_inflow), + m_nscbc(nscbc), + m_nscbc_prm(nscbc_prm), + m_nscbc_diag(nscbc_diag) { } + // ------------------------------------------------------------------------- + // Characteristic (NSCBC) fill for one ghost cell. + // + // Returns true if this ghost cell was filled here, in which case the + // ordinary bcnormal() path below is skipped for it entirely. + // + // Corner ownership. A ghost cell may lie outside the domain in more than + // one direction. Such a cell is owned by the LOWEST idir in which it is + // outside on an ext_dir face whose problem hook returns a live target; + // the state is written exactly once. Combined with the clamped stencil + // below this makes the fill a pure function of valid interior data, hence + // independent of the order in which ghost cells are visited and identical + // on CPU and GPU. + // + // (Note that the legacy bcnormal() path further down does NOT have this + // property: at a corner it reads dest() at a tangential index that is + // itself a ghost cell, which another thread in the same launch may be + // writing. That is pre-existing behaviour and is left untouched here so + // that no existing result moves.) + // ------------------------------------------------------------------------- + AMREX_GPU_DEVICE + AMREX_FORCE_INLINE + bool nscbc_fill( + const amrex::IntVect& iv, + amrex::Array4 const& dest, + amrex::GeometryData const& geom, + const amrex::Real time, + const int* bc) const + { + const int* domlo = geom.Domain().loVect(); + const int* domhi = geom.Domain().hiVect(); + const amrex::Real* prob_lo = geom.ProbLo(); + const amrex::Real* dx = geom.CellSize(); + + for (int idir = 0; idir < AMREX_SPACEDIM; ++idir) { + int sgn = 0; + if ((bc[idir] == amrex::BCType::ext_dir) && (iv[idir] < domlo[idir])) { + sgn = +1; + } else if ( + (bc[idir + AMREX_SPACEDIM] == amrex::BCType::ext_dir) && + (iv[idir] > domhi[idir])) { + sgn = -1; + } else { + continue; + } + + const int N_pos = (sgn > 0) ? domlo[idir] : domhi[idir]; + const int layer = sgn * (N_pos - iv[idir]); // 1 = nearest the domain + + const amrex::Dim3 lo3 = amrex::lbound(dest); + const amrex::Dim3 hi3 = amrex::ubound(dest); + const int fab_lo[3] = {lo3.x, lo3.y, lo3.z}; + const int fab_hi[3] = {hi3.x, hi3.y, hi3.z}; + + // Tangential indices are clamped into the domain (and the FAB), in + // every tangential direction: only valid interior cells are read, and + // the fill is a pure function of pre-launch data. + // + // In a PERIODIC tangential direction this leaves a small, measured + // seam residual, and that is a deliberate trade. amrex's corner + // protocol (StateDataPhysBCFunct) recomputes corner ghosts on a strip + // FAB holding only their image band, so a seam-adjacent cell is filled + // twice on two different FABs; exact agreement requires restricting + // the tangential stencil to the band the strip can see. Both + // alternatives were built and measured: a wrap through the resident + // images leaves the array aperiodic at the 2e-2 level, and the + // strip-consistent band is bitwise-periodic and measured equally + // stable (NSCBC-FlameOutflow-DRM, beta = 0.5) -- but costs ~100 lines + // of decomposition-sensitive index machinery to remove a residual that + // measures 2e-4 (inert) to 1.6e-3 (flame on the seam corner). The + // clamp was kept for simplicity; nscbc_check_periodic_wrap() reports + // the residual and aborts above 1e-2, which a broken stencil fails. + amrex::IntVect base(AMREX_D_DECL(iv[0], iv[1], iv[2])); + for (int d = 0; d < AMREX_SPACEDIM; ++d) { + if (d != idir) { + base[d] = amrex::min( + amrex::max(iv[d], amrex::max(domlo[d], fab_lo[d])), + amrex::min(domhi[d], fab_hi[d])); + } + } + + // How deep a normal stencil is available, in the domain and in the FAB. + const int depth_domain = domhi[idir] - domlo[idir] + 1; + const int depth_fab = + (sgn > 0) ? (fab_hi[idir] - N_pos + 1) : (N_pos - fab_lo[idir] + 1); + const int n_stencil = + amrex::min(3, amrex::min(depth_domain, depth_fab)); + if (n_stencil < 1) { + continue; + } + + auto stencil_iv = [&](const int step) { + amrex::IntVect r = base; + r[idir] = N_pos + sgn * amrex::min(step, n_stencil - 1); + return r; + }; + amrex::Real s_N[NVAR], s_Nm1[NVAR], s_Nm2[NVAR]; + const amrex::IntVect ivN = stencil_iv(0); + const amrex::IntVect ivNm1 = stencil_iv(1); + const amrex::IntVect ivNm2 = stencil_iv(2); + for (int n = 0; n < NVAR; n++) { + s_N[n] = dest(ivN, n); + s_Nm1[n] = dest(ivNm1, n); + s_Nm2[n] = dest(ivNm2, n); + } + + // Query the problem for this boundary POINT. x is the location on the + // boundary plane, not the ghost cell centre: the target is a property + // of the boundary point and must not vary with the ghost layer, or the + // relaxation would be applied to a different target in each layer. + amrex::Real x[AMREX_SPACEDIM]; + for (int d = 0; d < AMREX_SPACEDIM; ++d) { + x[d] = prob_lo[d] + (static_cast(base[d]) + 0.5) * dx[d]; + } + x[idir] = prob_lo[idir] + static_cast( + (sgn > 0) ? domlo[idir] : (domhi[idir] + 1)) * + dx[idir]; + + pc_nscbc::Target tgt = ProblemSpecificFunctions::bcnormal_nscbc( + x, s_N, idir, sgn, time, geom, *lprobparm); + if (tgt.type == pc_nscbc::Type::off) { + continue; // this face is not characteristic here; try the next + } + + // Boundary-register composition (level 0 only; the registers change + // once per advance and are read frozen here). The kernel below sees + // only the composed Target -- it stays a pure function. + amrex::Real s_ghost[NVAR]; + pc_nscbc::apply( + s_N, s_Nm1, s_Nm2, n_stencil, dx[idir], idir, sgn, layer, tgt, + m_nscbc_prm[idir], s_ghost, m_nscbc_diag); + for (int n = 0; n < NVAR; n++) { + dest(iv, n) = s_ghost[n]; + } + return true; + } + return false; + } + AMREX_GPU_DEVICE void operator()( const amrex::IntVect& iv, @@ -43,6 +226,11 @@ struct PCHypFillExtDir const int* bc = bcr->data(); + // Characteristic boundary treatment, where the problem asks for it. + if (m_nscbc && nscbc_fill(iv, dest, geom, time, bc)) { + return; + } + amrex::Real s_int[NVAR] = {0.0}; amrex::Real s_ext[NVAR] = {0.0}; amrex::GpuArray turb_fluc{0.0}; @@ -196,8 +384,19 @@ pc_bcfill_hyp( } const ProbParmDevice* lprobparm = PeleC::d_prob_parm_device; + + // Capture the NSCBC parameters HOST-side (a device kernel must + // never touch ParmParse or a class static). + const bool nscbc = PeleC::nscbc_active(); + amrex::GpuArray nscbc_prm; + for (int d = 0; d < AMREX_SPACEDIM; ++d) { + nscbc_prm[d] = PeleC::nscbc_params(d); + } + amrex::Long* diag = nscbc ? nscbc_diag().data() : nullptr; + amrex::GpuBndryFuncFab hyp_bndry_func( - PCHypFillExtDir{lprobparm, PeleC::turb_inflow.is_initialized()}); + PCHypFillExtDir{ + lprobparm, PeleC::turb_inflow.is_initialized(), nscbc, nscbc_prm, diag}); hyp_bndry_func(bx, data, dcomp, numcomp, geom, time, bcr, bcomp, scomp); } @@ -231,3 +430,121 @@ pc_nullfill( const int /*scomp*/) { } + +void +PeleC::nscbc_check_fine_faces() const +{ + if (!bc_nscbc || level == 0) { + return; + } + const amrex::Box& dom = geom.Domain(); + for (int dir = 0; dir < AMREX_SPACEDIM; ++dir) { + for (int side = 0; side < 2; ++side) { + const int t = (side == 0) ? phys_bc.lo(dir) : phys_bc.hi(dir); + if ((t != PCPhysBCType::inflow) && (t != PCPhysBCType::user_bc)) { + continue; // not a face the characteristic treatment can reach + } + const int face = (side == 0) ? dom.smallEnd(dir) : dom.bigEnd(dir); + bool touches = false; + for (int i = 0; i < grids.size(); ++i) { + const amrex::Box& b = grids[i]; + if ( + ((side == 0) && (b.smallEnd(dir) == face)) || + ((side == 1) && (b.bigEnd(dir) == face))) { + touches = true; + break; + } + } + // Once per face per run: regridding can recur every few steps, and a + // warning repeated 1600 times is a warning nobody reads. + static bool warned[AMREX_SPACEDIM][2] = {}; + if ( + touches && !warned[dir][side] && + amrex::ParallelDescriptor::IOProcessor()) { + warned[dir][side] = true; + // A warning rather than an abort: the problem hook decides per + // boundary POINT whether a face is characteristic, and the host + // cannot know what it will return. But if any point of this face + // is characteristic, the fill's extrapolation stencil is + // level-local, so the fine patch imposes a DIFFERENT boundary + // condition than the coarse level does on the same face -- a + // level-dependent artefact that refining cannot remove. + amrex::Warning( + "NSCBC: level " + std::to_string(level) + " grids touch the domain " + + (side == 0 ? "lo" : "hi") + " face in direction " + + std::to_string(dir) + + ", which is a Hard/UserBC face with pelec.bc_nscbc = 1. The " + "characteristic fill's stencil is level-local, so a refined patch " + "on a characteristic face makes the boundary condition " + "level-dependent. Keep refinement away from characteristic faces " + "(see the BCs chapter). (This warning is printed once per face.)"); + } + } + } +} + +pc_nscbc::Params +PeleC::nscbc_params(const int idir) +{ + pc_nscbc::Params p; + p.sigma = bc_nscbc_sigma; + p.relax_u = bc_nscbc_relax_u; + p.relax_t = bc_nscbc_relax_t; + p.order = bc_nscbc_order; + p.pin_farfield = bc_nscbc_pin_farfield; + // Only the ratio sigma/L_ref is physical. L_ref is fixed to the domain + // extent along the boundary normal so that sigma keeps the meaning it has + // in the literature, rather than being one of two dials for one degree of + // freedom. Uses probhi - problo, not probhi: the legacy Fortran used + // probhi(idir) and was therefore silently wrong for any domain not + // anchored at the origin. + const auto& geom = amrex::DefaultGeometry(); + p.L_ref = geom.ProbHi(idir) - geom.ProbLo(idir); + return p; +} + +void +PeleC::nscbc_report_diagnostics() +{ + if (!bc_nscbc) { + return; + } + std::vector h(pc_nscbc::Diag::count, 0); + amrex::Gpu::copy( + amrex::Gpu::deviceToHost, nscbc_diag().begin(), nscbc_diag().end(), + h.begin()); + amrex::ParallelDescriptor::ReduceLongSum(h.data(), pc_nscbc::Diag::count); + + // The supersonic path is exact, not a degradation, so it is reported but is + // not a warning. The others mean the boundary is being asked for something + // it cannot cleanly provide. + // transverse_drop and source_drop belong here as much as the rest: a + // beta or beta_s that is silently not being applied looks exactly like a + // beta or beta_s that does nothing, and the only way to tell the two apart + // is to count it. + const amrex::Long total = + h[pc_nscbc::Diag::reversed] + h[pc_nscbc::Diag::body_state] + + h[pc_nscbc::Diag::eos_failure] + h[pc_nscbc::Diag::floored] + + h[pc_nscbc::Diag::transverse_drop] + h[pc_nscbc::Diag::source_drop] + + h[pc_nscbc::Diag::target_incomplete]; + if (amrex::ParallelDescriptor::IOProcessor() && (total > 0 || verbose > 1)) { + amrex::Print() << " NSCBC fallbacks since last report:" << " supersonic " + << h[pc_nscbc::Diag::supersonic] << ", flow reversal " + << h[pc_nscbc::Diag::reversed] << ", EB body state " + << h[pc_nscbc::Diag::body_state] << ", EOS failure " + << h[pc_nscbc::Diag::eos_failure] << ", floored " + << h[pc_nscbc::Diag::floored] << ", transverse dropped " + << h[pc_nscbc::Diag::transverse_drop] << ", source dropped " + << h[pc_nscbc::Diag::source_drop] << ", target incomplete " + << h[pc_nscbc::Diag::target_incomplete] << "\n"; + } + // Settle any counter atomics still in flight on other streams before the + // reset; the blocking Gpu::copy above synchronised only its own stream. + amrex::Gpu::Device::synchronize(); + nscbc_diag().assign(pc_nscbc::Diag::count, 0); + // The reset itself is a device fill on the current stream; the next + // advance's fills bump these counters from MFIter's rotating streams, + // which are not ordered against it. Settle the reset before returning + // so a zero can never land on top of a fresh count. + amrex::Gpu::streamSynchronize(); +} diff --git a/Source/Diffusion.cpp b/Source/Diffusion.cpp index 624042039..aa9665c83 100644 --- a/Source/Diffusion.cpp +++ b/Source/Diffusion.cpp @@ -143,39 +143,23 @@ PeleC::getMOLSrcTerm( } }); } - // TODO deal with NSCBC - /* - for (int dir = 0; dir < AMREX_SPACEDIM ; dir++) { - const amrex::Box& bxtmp = amrex::surroundingNodes(vbox,dir); - amrex::Box TestBox(bxtmp); - for(int d=0; d 0. The +// outward wave speeds are u_out - c (always incoming subsonically), u_out +// (the lambda_0 family: entropy, tangential velocity, species, passives -- +// outgoing at an outflow, incoming at an inflow) and u_out + c (always +// outgoing). Hence a subsonic outflow specifies one quantity (p); a +// subsonic inflow specifies u, T and Y. +// +// We work with linearised invariants, the impedance rho*c FROZEN at the +// boundary cell N (a per-cell impedance would make R differences of two +// different variables): +// +// R_+/- = u_out +/- p/(rho c), S = rho - p/c^2 . +// +// Outgoing quantities extrapolate outward on a minmod-limited slope; the +// incoming one takes the Poinsot-Lele relaxation as a gradient increment, +// +// L_in = K (phi - phi_target), K = coeff (1 - M^2) c / L_ref [1/s] +// R_-,ghost = R_-,N + layer dx L_in / ((c - u_out) rho c) , +// +// so K is a mesh-independent RATE and literature coefficients transfer +// directly. The gradient form gives L_in an additive slot for the +// transverse terms (beta) and the reaction source (beta_s); a hard pin of +// R_- has none. The fill is a pure algebraic function of the current +// interior state: idempotent under repeated FillPatch calls, +// bit-reproducible across restarts and decompositions. +// +// SIGNS. Every user-facing coefficient (sigma, relax_u, relax_t) is +// positive; all sign handling is internal and Params::validate() rejects +// negatives. +// +// GPU. Finiteness guards use amrex::Math::isfinite, never std::isfinite +// (SYCL fast-math makes the latter constant-false, which would silently +// degrade every fill to a zero-gradient copy). No allocation, no virtual +// dispatch, no printf/Abort on the device path; fallbacks are counted via +// atomics (see Diag). Stack footprint is order 10*NVAR doubles per thread +// and will spill past the register budget for large mechanisms; tolerable +// because the launch covers only a thin ghost region. +// +// EOS. Only density-carrying entry points are used (RTY2P, RTY2Cs, RYP2T, +// RTY2E, REY2T, PYT2RE), which keeps the kernel real-gas capable: the +// shortcut forms error out under SRK. +// ============================================================================ + +#include +#include +#include +#include +#include + +#include "IndexDefines.H" +#include "Constants.H" +#include "PelePhysics.H" + +namespace pc_nscbc { + +// --------------------------------------------------------------------------- +// Boundary type, decided per boundary POINT by the problem's bcnormal_nscbc(). +// A single face may carry a mixture (a jet inlet surrounded by outflow). +// --------------------------------------------------------------------------- +enum struct Type : int { off = 0, outflow = 1, inflow = 2 }; + +// --------------------------------------------------------------------------- +// Numerical parameters. POD, captured host-side into the boundary functor; +// never read ParmParse or a class static from inside a device kernel. +// --------------------------------------------------------------------------- +struct Params +{ + // Outflow pressure relaxation, classical Poinsot-Lele sigma: + // K = sigma * (1 - M^2) * c / L_ref [1/s] + // 0 is perfectly non-reflecting and leaves the mean pressure unanchored; + // the literature band is 0.15-0.3 (Rudy & Strikwerda 1980 give ~0.27 as + // optimal for a 1-D pulse). + amrex::Real sigma = 0.25; + + // Inflow normal-velocity relaxation. Larger imposes the target velocity + // more strongly and reflects more; >~10 is a hard Dirichlet in disguise. + amrex::Real relax_u = 2.0; + + // Inflow temperature and tangential-velocity relaxation. + amrex::Real relax_t = 0.2; + + // Boundary-normal reference length, probhi[idir] - problo[idir]. Filled per + // face by the caller. Only the ratio sigma/L_ref is physical, so this is + // not a user knob; it exists so that sigma keeps its literature meaning. + amrex::Real L_ref = 1.0; + + // Order of the outgoing-invariant extrapolation: + // 1 = zeroth-order copy (constant across ghost layers) + // 2 = minmod-limited linear in the ghost layer index (default) + // Exposed so that a bit-comparison against the standalone 1-D driver can be + // done without recompiling. Verification knob, not a physics knob. + int order = 2; + + // Replace the relaxation by a hard pin of R_- to the far-field value + // (u_out = 0, p = p_target): non-reflecting AND anchored, but it anchors to + // p_target + rho c u_out, and as a value constraint its effective rate is + // c/dx -- it does not converge under refinement. For a boundary onto a + // quiescent reservoir; wrong for a through-flow duct. sigma is ignored. + bool pin_farfield = false; + + AMREX_GPU_HOST bool validate(std::string& why) const + { + if (sigma < 0.0) { + why = "bc_nscbc_sigma must be >= 0 (all NSCBC relaxation coefficients " + "are positive in PeleC; internal signs are handled by the kernel)"; + return false; + } + if (relax_u < 0.0) { + why = "bc_nscbc_relax_u must be >= 0"; + return false; + } + if (relax_t < 0.0) { + why = "bc_nscbc_relax_t must be >= 0"; + return false; + } + if ((order != 1) && (order != 2)) { + why = "bc_nscbc_order must be 1 or 2"; + return false; + } + if (L_ref <= 0.0) { + why = "NSCBC reference length must be > 0"; + return false; + } + return true; + } +}; + +// --------------------------------------------------------------------------- +// Target state returned per boundary point by the problem hook. Only the +// entries relevant to `type` are read: outflow -> p; inflow -> u[], T, Y[]. +// Deliberately no relaxation for composition: at an inflow every species +// characteristic is incoming (hard imposition is well posed), at an outflow +// all are outgoing (imposing would over-specify). +// --------------------------------------------------------------------------- +struct Target +{ + Type type = Type::off; + amrex::Real p = 0.0; // outflow: target pressure [dyn/cm^2] + amrex::Real u[3] = {0.0, 0.0, 0.0}; // inflow: target velocity [cm/s] + amrex::Real T = 0.0; // inflow: target temperature [K] + amrex::Real Y[NUM_SPECIES] = {0.0}; // inflow: target mass fractions + + // Inflow acoustic-injection feed-forward: the time derivative of the + // NORMAL velocity target, supplied analytically by the problem hook when + // it is forcing the inlet. The value-relaxation alone has no amplitude + // slot (design doc I.3e) -- an injected signal rides the same relaxation + // that holds the mean, with the measured deterioration of driver t3 -- + // and a register-based NRI transplant does not create one (measured: + // subtracting the outgoing wave from the deviation only exposes the bare + // injection bandwidth limit). This term is that slot, stateless: the + // incoming amplitude gains 2 rho c du^t/dt, the GC transplant of the + // flux form's acoustic forcing, and the relaxation keeps its one job of + // holding the mean. Zero (default) recovers the classical inlet + // identically. + amrex::Real dudt = 0.0; // d(u_normal target)/dt, LAB frame [cm/s^2] +}; + +// --------------------------------------------------------------------------- +// Diagnostic counters. Every fallback is counted; a silent fallback is a +// bug that will not be found. Storage is amrex::Long, not int: with the +// default sum_interval = -1 the counters are never reset, and the supersonic +// counter on a large 3-D run walks past 2^31 in ~1e4 steps. +// --------------------------------------------------------------------------- +namespace Diag { +enum : int { + supersonic = 0, // |M| >= 1: zero-gradient copy is exact, not a failure + reversed, // flow ran the wrong way through the face + body_state, // an EB-covered cell was found in the stencil + eos_failure, // an EOS call returned a non-finite or non-positive value + floored, // a positivity floor was hit + transverse_drop, // a transverse derivative was unavailable or unusable + source_drop, // a modelled source term was unavailable or unusable + target_incomplete, // a supersonic inflow whose Target carries no pressure: + // every characteristic is incoming, so the FULL state is + // required; the interior pressure was substituted, which + // makes the ghost depend on a domain it should be + // causally upstream of. Set Target.p in bcnormal_nscbc. + structure, // ADVISORY, not a fallback: material structure sat in an + // outflow boundary cell (|dS| > 5% of rho). A front at + // the boundary wants the flame closures -- + // extrap_temperature and beta_s = 0; see + // NSCBC-FlameOutflow/README.md. + count +}; +} + +// =========================================================================== +// Small helpers +// =========================================================================== + +AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE amrex::Real +finite_or(const amrex::Real x, const amrex::Real fallback) noexcept +{ + return amrex::Math::isfinite(x) ? x : fallback; +} + +AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE amrex::Real +minmod(const amrex::Real a, const amrex::Real b) noexcept +{ + if (a * b <= 0.0) { + return 0.0; + } + return (std::abs(a) < std::abs(b)) ? a : b; +} + +// True if a state looks like EB body state rather than fluid, detected by +// non-positive density. This is why read_params makes eb_zero_body_state = 1 +// mandatory under EB: PeleC's default body state is a sampled fluid state the +// stencil cannot distinguish from interior data. +AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE bool +is_body_state(const amrex::Real s[NVAR]) noexcept +{ + return !(s[URHO] > constants::very_small_num()) || + !amrex::Math::isfinite(s[URHO]) || !amrex::Math::isfinite(s[UEDEN]); +} + +AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE void +copy_state(const amrex::Real src[NVAR], amrex::Real dst[NVAR]) noexcept +{ + for (int n = 0; n < NVAR; n++) { + dst[n] = src[n]; + } +} + +// --------------------------------------------------------------------------- +// Primitive decomposition of one stencil cell. T is taken from the state +// (a maintained component; computeTemp runs after every update), keeping +// every EOS call on this path non-iterative -- which matters under SRK -- +// with the iterative recovery as fallback. +// --------------------------------------------------------------------------- +struct CellPrim +{ + amrex::Real rho, u[3], T, p, c, e; + amrex::Real Y[NUM_SPECIES]; + bool ok; +}; + +AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE CellPrim +cell_primitives(const amrex::Real s[NVAR]) noexcept +{ + auto eos = pele::physics::PhysicsType::eos(); + CellPrim q; + q.ok = true; + + q.rho = s[URHO]; + const amrex::Real rhoinv = 1.0 / q.rho; + q.u[0] = s[UMX] * rhoinv; + q.u[1] = s[UMY] * rhoinv; + q.u[2] = s[UMZ] * rhoinv; + + amrex::Real Ysum = 0.0; + for (int n = 0; n < NUM_SPECIES; n++) { + q.Y[n] = amrex::max(s[UFS + n] * rhoinv, 0.0); + Ysum += q.Y[n]; + } + if (Ysum > constants::very_small_num()) { + const amrex::Real inv = 1.0 / Ysum; + for (amrex::Real& yv : q.Y) { + yv *= inv; + } + } else { + q.ok = false; + } + + const amrex::Real ke = + 0.5 * (q.u[0] * q.u[0] + q.u[1] * q.u[1] + q.u[2] * q.u[2]); + q.e = s[UEDEN] * rhoinv - ke; + + q.T = s[UTEMP]; + if (!(q.T > 0.0) || !amrex::Math::isfinite(q.T)) { + q.T = 300.0; // initial guess for the iterative fallback + eos.REY2T(q.rho, q.e, q.Y, q.T); + } + + eos.RTY2P(q.rho, q.T, q.Y, q.p); + eos.RTY2Cs(q.rho, q.T, q.Y, q.c); + + if ( + !amrex::Math::isfinite(q.p) || !(q.p > 0.0) || + !amrex::Math::isfinite(q.c) || !(q.c > 0.0) || + !amrex::Math::isfinite(q.T) || !(q.T > 0.0)) { + q.ok = false; + } + return q; +} + +// --------------------------------------------------------------------------- +// Pack a set of ghost primitives into a conserved state. +// +// Every NVAR slot is written, and the algebraic identities that the rest of +// the code assumes are enforced exactly rather than approximately: +// sum_k (rho Y)_k == rho (species renormalised before packing) +// UEDEN == UEINT + 0.5 rho |u|^2 +// The passive families (advected, auxiliary, linear/soot) ride the lambda_0 +// characteristic and are handled by the caller through `pass`. +// --------------------------------------------------------------------------- +AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE void +pack_ghost( + const amrex::Real rho, + const amrex::Real u[3], + const amrex::Real e, + const amrex::Real T, + const amrex::Real Y[NUM_SPECIES], + const amrex::Real s_N[NVAR], + amrex::Real s_ghost[NVAR]) noexcept +{ + // s_N feeds only the passive families below; in a build with no passive + // fields every #if block is empty and the parameter would warn as unused. + amrex::ignore_unused(s_N); + s_ghost[URHO] = rho; + s_ghost[UMX] = rho * u[0]; + s_ghost[UMY] = rho * u[1]; + s_ghost[UMZ] = rho * u[2]; + s_ghost[UEINT] = rho * e; + s_ghost[UEDEN] = + rho * e + 0.5 * rho * (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]); + s_ghost[UTEMP] = T; + for (int n = 0; n < NUM_SPECIES; n++) { + s_ghost[UFS + n] = rho * Y[n]; + } + // Passive families default to a mass-scaled zero-gradient; the caller + // overwrites them where a target applies. +#if NUM_ADV > 0 + const amrex::Real ratio = + (s_N[URHO] > constants::very_small_num()) ? rho / s_N[URHO] : 1.0; + for (int n = 0; n < NUM_ADV; n++) { + s_ghost[UFA + n] = s_N[UFA + n] * ratio; // per unit mass in ctoprim + } +#endif +#if NUM_AUX > 0 + for (int n = 0; n < NUM_AUX; n++) { + s_ghost[UFX + n] = s_N[UFX + n]; // NOT per unit mass in ctoprim + } +#endif +#if NUM_LIN > 0 + for (int n = 0; n < NUM_LIN; n++) { + s_ghost[ULIN + n] = s_N[ULIN + n]; // NOT per unit mass in ctoprim + } +#endif +} + +// =========================================================================== +// apply() -- fill one ghost cell. +// s_N, s_Nm1, s_Nm2: interior stencil walking inward; tangential indexing +// is the caller's (BCfill.cpp), keeping the fill a deterministic pure +// function of data written before the launch. +// n_stencil: usable cells (3/2/1), reduced further on EB body state. +// idir, sgn: boundary normal and side (+1 lo, -1 hi, as bcnormal). +// layer: 1 = nearest the domain. diag: optional Long[Diag::count]. +// =========================================================================== +AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE void +apply( + const amrex::Real s_N[NVAR], + const amrex::Real s_Nm1[NVAR], + const amrex::Real s_Nm2[NVAR], + int n_stencil, + const amrex::Real dx_normal, + const int idir, + const int sgn, + const int layer, + Target const& tgt, + Params const& prm, + amrex::Real s_ghost[NVAR], + amrex::Long* diag = nullptr) noexcept +{ + auto eos = pele::physics::PhysicsType::eos(); + + auto bump = [=](const int which) { + if (diag != nullptr) { + amrex::HostDevice::Atomic::Add(&diag[which], amrex::Long(1)); + } + }; + + // ---- 0. Stencil hygiene ------------------------------------------------ + if (is_body_state(s_N)) { + // Nothing usable to build a characteristic state from. Leave whatever the + // caller pre-populated (the reflected NoSlipWall state) in place. + bump(Diag::body_state); + copy_state(s_N, s_ghost); + return; + } + if ((n_stencil > 2) && is_body_state(s_Nm2)) { + n_stencil = 2; + bump(Diag::body_state); + } + if ((n_stencil > 1) && is_body_state(s_Nm1)) { + n_stencil = 1; + bump(Diag::body_state); + } + // Extrapolation order can never exceed what the stencil supports. A + // minmod-limited slope needs three cells; with two it would be unlimited. + const int order = + amrex::min(prm.order, amrex::max(n_stencil - 1, 1)); + const bool linear = (order >= 2) && (n_stencil >= 3); + + const CellPrim qN = cell_primitives(s_N); + if (!qN.ok) { + bump(Diag::eos_failure); + copy_state(s_N, s_ghost); + return; + } + + // ---- 1. Outward-normal frame ------------------------------------------ + const auto n_sgn = static_cast(-sgn); + const amrex::Real u_out_N = n_sgn * qN.u[idir]; + const amrex::Real c_N = qN.c; + const amrex::Real mach = u_out_N / c_N; + + // Frozen acoustic impedance (see the header note: this must be a single + // value shared by every stencil cell). + const amrex::Real rho_c = qN.rho * c_N; + + // ---- 2. Regimes with an exact answer ---------------------------------- + if (std::abs(mach) >= 1.0) { + if (mach >= 1.0) { + // Supersonic outflow: zero-gradient is exact, not an approximation. + bump(Diag::supersonic); + copy_state(s_N, s_ghost); + return; + } + // Supersonic inflow: every characteristic is incoming, so the full state + // is specified. Only meaningful with an inflow target. A Target + // without a pressure under-specifies it: the interior pressure is + // substituted and COUNTED (target_incomplete), because the ghost then + // depends on a domain it should be causally upstream of. + if (tgt.type == Type::inflow) { + if (!(tgt.p > 0.0)) { + bump(Diag::target_incomplete); + } + amrex::Real rho_g = 0.0, e_g = 0.0; + eos.PYT2RE(tgt.p > 0.0 ? tgt.p : qN.p, tgt.Y, tgt.T, rho_g, e_g); + if ( + amrex::Math::isfinite(rho_g) && (rho_g > 0.0) && + amrex::Math::isfinite(e_g)) { + amrex::Real u_g[3] = {tgt.u[0], tgt.u[1], tgt.u[2]}; + pack_ghost(rho_g, u_g, e_g, tgt.T, tgt.Y, s_N, s_ghost); + bump(Diag::supersonic); + return; + } + bump(Diag::eos_failure); + } + bump(Diag::supersonic); + copy_state(s_N, s_ghost); + return; + } + + // ---- 3. Direction sanity ---------------------------------------------- + const bool outflow_face = (tgt.type == Type::outflow); + if (outflow_face && (u_out_N < 0.0)) { + // Transient reversal at an outflow: COUNTED, but no longer a separate + // closure. The acoustic side of the standard outflow path is well + // defined and restoring for u_out < 0 (lambda_in = c - u_out grows, + // 1 - M^2 is even in M, K (p - p_tgt) pulls the ghost pressure toward + // the target AND pushes the ghost normal velocity outward), so one + // closure runs on both sides of u_out = 0; only the lambda_0 material + // SLOPES are upwinded off during reversal (see w_mat below), leaving the + // ghost the interior's material VALUES. Both dedicated branches tried + // here were defects the chamber found: a hard pin of tgt.p is a step + // discontinuity that NaN'd under vent breathing (backflow -144 -> -2628 + // cm/s in 45 steps, a 138 K cell), and the soft pressure-only relaxation + // that replaced it dropped dR+, S_p and T_in relative to the forward + // branch and froze the ghost velocity -- an O(1) discontinuity at + // u_out = 0 that a flame finishing its transit turned into a growing + // dither and a spurious 0.3 atm chamber spike + // (Docs/NSCBC-reversal-branch-defect.md; gates: driver C13 a/b/c). + // Sustained recirculation as a local inflow is Params::backflow_material. + // + // The COUNTER carries a roundoff deadband (the closure itself has no + // branch left to care): a face holding a quiescent charge sits at + // exactly u_out = 0 and the solver's noise dithers the sign, which read + // as 314k "reversals" in the first 100 steps of a chamber whose ignition + // wave was still a centimetre from the boundary. Counting only + // |u_out| > 1e-9 c keeps "reversal count zero" meaning NO PHYSICAL + // BACKFLOW rather than "no roundoff", which is the semantics the + // README's advice ("sustained nonzero means a misplaced outflow") + // depends on. + if (u_out_N < -1.0e-9 * c_N) { + bump(Diag::reversed); + } + } + if (!outflow_face && (u_out_N > 0.0)) { + // Outflow through a configured inflow would impose D+NUM_SPECIES+1 + // conditions where one is admitted; zero-gradient and count it. + bump(Diag::reversed); + copy_state(s_N, s_ghost); + return; + } + + // ---- 4. Outgoing acoustic invariant, R_+ ------------------------------ + // R_+ leaves the domain at both subsonic inflow and subsonic outflow + // (lambda_+ = u_out + c > 0 in both cases), so it is always extrapolated. + const amrex::Real Rp_N = u_out_N + qN.p / rho_c; + amrex::Real dRp = 0.0; + CellPrim qNm1, qNm2; + if (linear) { + qNm1 = cell_primitives(s_Nm1); + qNm2 = cell_primitives(s_Nm2); + if (qNm1.ok && qNm2.ok) { + const amrex::Real Rp_Nm1 = n_sgn * qNm1.u[idir] + qNm1.p / rho_c; + const amrex::Real Rp_Nm2 = n_sgn * qNm2.u[idir] + qNm2.p / rho_c; + dRp = minmod(Rp_N - Rp_Nm1, Rp_Nm1 - Rp_Nm2); + } else { + bump(Diag::eos_failure); + } + } + const amrex::Real Rp_g = Rp_N + static_cast(layer) * dRp; + + // ---- 5. Incoming acoustic invariant, R_- ------------------------------ + // lambda_- < 0 always (subsonic), so R_- is always modelled. Sign checks: + // outflow, p_N > p_target => dR_-/dn > 0 and (dp/dR_- = -rho c/2) the + // ghost pressure falls toward target; inflow, u above target => smaller + // R_-,ghost and the ghost normal velocity falls toward target. + const amrex::Real Rm_N = u_out_N - qN.p / rho_c; + const amrex::Real lambda_in = c_N - u_out_N; // > 0, magnitude of the speed + const amrex::Real one_m_M2 = + amrex::max(1.0 - mach * mach, constants::very_small_num()); + + // Upwinding of the lambda_0 (material) family. Its characteristics leave + // the domain only for u_out > 0; during a reversal an EXTRAPOLATED + // material ghost is advected back in and feeds on itself. That loop is + // the chamber's cold runaway: the outward-cooling T ramp refrigerates the + // boundary cell, the steepened ramp extrapolates colder still, and + // 1/(rho c) amplifies the relaxation as T falls -- 385 -> 241 -> 89 K in + // 42 us, then NaN (Docs/NSCBC-reversal-branch-defect.md). So the material + // SLOPES are used only where their upwind justification holds: full for + // u_out >= 0 (every forward-flow result is untouched to the bit), ramped + // off across a narrow band below zero (1e-3 c -- a continuity + // regularisation of the fill, not a physics knob; C13 gates it), and zero + // under firm reversal, where the ghost keeps the interior VALUES -- which + // is what the surviving closure always did. + const amrex::Real w_mat = + (u_out_N >= 0.0) + ? 1.0 + : amrex::max(0.0, 1.0 + u_out_N / (1.0e-3 * c_N)); + + const amrex::Real dRm = 0.0; + + amrex::Real Rm_g; + if (outflow_face && prm.pin_farfield) { + // Hard far-field pin: R_- takes its far-field value (u_out = 0, + // p = p_target). Value constraint, not a rate, and there is nowhere in it + // to put a transverse term -- so pin_farfield ignores beta. + Rm_g = -tgt.p / rho_c; + } else { + amrex::Real L_in; + if (outflow_face) { + const amrex::Real K = prm.sigma * one_m_M2 * c_N / prm.L_ref; + L_in = K * (qN.p - tgt.p); + } else { + const amrex::Real u_out_tgt = n_sgn * tgt.u[idir]; + const amrex::Real K = prm.relax_u * one_m_M2 * c_N / prm.L_ref; + L_in = -K * qN.rho * c_N * (u_out_N - u_out_tgt); + // Acoustic-injection feed-forward (Target::dudt): the incoming wave + // carries the target's rate directly, so a forced signal does not + // have to fight its way through the relaxation. n_sgn converts the + // lab-frame rate to the outward frame; the sign follows the + // relaxation term's convention (positive L_in raises u_out through + // the Rm inversion), and the factor 2 is u = (R+ + R-)/2: imposing a + // velocity rate through the incoming invariant alone needs twice it. + L_in += 2.0 * rho_c * (n_sgn * tgt.dudt); + } + // There is deliberately NO diffusive source term on L_in. The + // ghost-cell form does not have the flux-form's viscous-condition gap: + // the diffusion operator reads these ghost cells, so a correct ghost + // closure (extrap_temperature, gated by C8 and C12) already carries the + // diffusive physics, and an amplitude-side term counts it twice + // (measured: it moves the C12 driver error 104 -> -911). Re-verify + // against C12 before revisiting. + Rm_g = + Rm_N + static_cast(layer) * dRm + + static_cast(layer) * dx_normal * L_in / (lambda_in * rho_c); + } + + // ---- 6. Invert the acoustic pair -------------------------------------- + amrex::Real u_out_g = 0.5 * (Rp_g + Rm_g); + amrex::Real p_g = 0.5 * rho_c * (Rp_g - Rm_g); + + const amrex::Real p_floor = amrex::max( + 1.0e-3 * (tgt.p > 0.0 ? tgt.p : qN.p), constants::very_small_num()); + if (!amrex::Math::isfinite(p_g) || (p_g < p_floor)) { + p_g = p_floor; + bump(Diag::floored); + } + if (!amrex::Math::isfinite(u_out_g)) { + u_out_g = u_out_N; + bump(Diag::floored); + } + + amrex::Real u_g[3] = {qN.u[0], qN.u[1], qN.u[2]}; + u_g[idir] = n_sgn * u_out_g; + + // ---- 7. The lambda_0 family ------------------------------------------- + // Entropy, tangential velocities, species and passive scalars all convect at + // u_out. At an OUTFLOW they leave the domain and are extrapolated; at an + // INFLOW they enter and are set from the target -- composition hard (all + // NUM_SPECIES characteristics are incoming, so it is well posed), and + // temperature and tangential velocity through a relaxation. + amrex::Real rho_g = 0.0, T_g = 0.0, e_g = 0.0; + amrex::Real Y_g[NUM_SPECIES]; + + if (outflow_face) { + // Entropy invariant extrapolated like R_+; zero slope reduces to the + // linearised isentrope rho_g = rho_N + (p_g - p_N)/c^2. + const amrex::Real inv_c2 = 1.0 / (c_N * c_N); + const amrex::Real S_N = qN.rho - qN.p * inv_c2; + amrex::Real dS = 0.0; + amrex::Real dY[NUM_SPECIES] = {0.0}; + amrex::Real dut[3] = {0.0, 0.0, 0.0}; + if (linear && qNm1.ok && qNm2.ok) { + // All lambda_0 slopes carry the material upwinding factor: extrapolated + // material content is only valid where it advects OUT (see w_mat). + const amrex::Real S_Nm1 = qNm1.rho - qNm1.p * inv_c2; + const amrex::Real S_Nm2 = qNm2.rho - qNm2.p * inv_c2; + dS = w_mat * minmod(S_N - S_Nm1, S_Nm1 - S_Nm2); + for (int n = 0; n < NUM_SPECIES; n++) { + dY[n] = w_mat * minmod(qN.Y[n] - qNm1.Y[n], qNm1.Y[n] - qNm2.Y[n]); + } + for (int d = 0; d < 3; d++) { + if (d != idir) { + dut[d] = w_mat * minmod(qN.u[d] - qNm1.u[d], qNm1.u[d] - qNm2.u[d]); + } + } + } + const auto fl = static_cast(layer); + const amrex::Real S_g = S_N + fl * dS; + rho_g = S_g + p_g * inv_c2; + + amrex::Real Ysum = 0.0; + for (int n = 0; n < NUM_SPECIES; n++) { + Y_g[n] = amrex::max(qN.Y[n] + fl * dY[n], 0.0); + Ysum += Y_g[n]; + } + if (Ysum > constants::very_small_num()) { + const amrex::Real inv = 1.0 / Ysum; + for (amrex::Real& yg : Y_g) { + yg *= inv; + } + } else { + for (int n = 0; n < NUM_SPECIES; n++) { + Y_g[n] = qN.Y[n]; + } + bump(Diag::floored); + } + + for (int d = 0; d < 3; d++) { + if (d != idir) { + u_g[d] = qN.u[d] + fl * dut[d]; + } + } + + const amrex::Real rho_floor = + amrex::max(1.0e-6 * qN.rho, constants::very_small_num()); + if (!amrex::Math::isfinite(rho_g) || (rho_g < rho_floor)) { + rho_g = rho_floor; + bump(Diag::floored); + } + eos.RYP2T(rho_g, Y_g, p_g, T_g); + eos.RTY2E(rho_g, T_g, Y_g, e_g); + + } else { + // Inflow. One dimensionless nudge factor shared by temperature and the + // tangential velocities -- both ride lambda_0, magnitude |u_out|: + // nu = layer dx relax_t c / (L_ref |u_out|), clamped to [0, 1], + // applied as an explicit move toward the target so no sign convention + // can go wrong. + const amrex::Real u_mag = + amrex::max(std::abs(u_out_N), constants::smallu()); + const amrex::Real nu = amrex::min( + static_cast(layer) * dx_normal * prm.relax_t * c_N / + (prm.L_ref * u_mag), + 1.0); + + T_g = qN.T - nu * (qN.T - tgt.T); + for (int d = 0; d < 3; d++) { + if (d != idir) { + u_g[d] = qN.u[d] - nu * (qN.u[d] - tgt.u[d]); + } + } + // Composition: hard. Every species characteristic is incoming. + amrex::Real Ysum = 0.0; + for (int n = 0; n < NUM_SPECIES; n++) { + Y_g[n] = amrex::max(tgt.Y[n], 0.0); + Ysum += Y_g[n]; + } + if (Ysum > constants::very_small_num()) { + const amrex::Real inv = 1.0 / Ysum; + for (amrex::Real& yg : Y_g) { + yg *= inv; + } + } else { + for (int n = 0; n < NUM_SPECIES; n++) { + Y_g[n] = qN.Y[n]; + } + bump(Diag::floored); + } + if (!amrex::Math::isfinite(T_g) || !(T_g > 0.0)) { + T_g = qN.T; + bump(Diag::floored); + } + // Close on (p, T, Y): p from the acoustic pair, T and Y from the target. + eos.PYT2RE(p_g, Y_g, T_g, rho_g, e_g); + } + + if ( + !amrex::Math::isfinite(rho_g) || !(rho_g > 0.0) || + !amrex::Math::isfinite(e_g) || !amrex::Math::isfinite(T_g) || + !(T_g > 0.0)) { + bump(Diag::eos_failure); + copy_state(s_N, s_ghost); + return; + } + + // ---- 8. Pack and sanitise --------------------------------------------- + pack_ghost(rho_g, u_g, e_g, T_g, Y_g, s_N, s_ghost); + + for (int n = 0; n < NVAR; n++) { + if (!amrex::Math::isfinite(s_ghost[n])) { + s_ghost[n] = s_N[n]; + bump(Diag::floored); + } + } +} + +} // namespace pc_nscbc + +#endif diff --git a/Source/Params/_cpp_parameters b/Source/Params/_cpp_parameters index 732bf0b3e..78558dfba 100644 --- a/Source/Params/_cpp_parameters +++ b/Source/Params/_cpp_parameters @@ -67,10 +67,55 @@ use_hybrid_weno bool false # WENO scheme type in PPM method weno_scheme int 1 -# permits Ghost-Cells Navier-Stokes Boundary Conditions to be turned on and off -# for advective terms (adv) and for diffusion terms (diff) -nscbc_adv bool true -nscbc_diff bool false +# --------------------------------------------------------------------------- +# Characteristic (NSCBC) boundary treatment. See Source/NSCBC.H for the +# formulation and Docs/sphinx/BoundaryConditions.rst for guidance. +# +# Master switch. Even when true, the treatment is applied only at boundary +# POINTS for which the problem's ProblemSpecificFunctions::bcnormal_nscbc() +# returns a type other than `off`, so a problem that does not provide that +# hook is unaffected. +bc_nscbc bool false + +# Outflow pressure relaxation, the classical Poinsot-Lele sigma. The +# relaxation rate is K = sigma * (1 - M^2) * c / L, with L the domain extent +# along the boundary normal, so the boundary pressure relaxes toward the +# target over tau = 1/K, which is 1/(sigma (1 - M^2)) acoustic transit times. +# 0 is perfectly non-reflecting and leaves the mean pressure unanchored. +# Literature band is 0.15-0.3. Must be >= 0. +bc_nscbc_sigma Real 0.25 + +# Inflow normal-velocity relaxation coefficient. Larger imposes the target +# velocity more strongly and reflects more. Must be >= 0. +bc_nscbc_relax_u Real 2.0 + +# Inflow temperature and tangential-velocity relaxation coefficient. Must be +# >= 0: unlike the legacy Fortran, every user-facing NSCBC coefficient in +# PeleC is positive and the internal signs are handled by the kernel. +bc_nscbc_relax_t Real 0.2 + +# Order of the outgoing-invariant extrapolation, 1 or 2. Verification and +# debugging knob, not a physics knob; leave at 2. +bc_nscbc_order int 2 + +# Pin the incoming acoustic invariant to its far-field value instead of +# relaxing it toward the target at rate K. Simultaneously non-reflecting and +# anchored, but it anchors to p_target + rho c u rather than to p_target and +# its effective relaxation rate is c/dx, so it does not converge under mesh +# refinement. Appropriate for an open boundary onto a large quiescent +# reservoir; inappropriate for a duct exhausting into a plenum whose true mean +# pressure is not the target. bc_nscbc_sigma is ignored when this is set. +bc_nscbc_pin_farfield bool false + + +# NOTE: pelec.nscbc_adv and pelec.nscbc_diff were removed here. They were +# declared and queried but never read by any live code path -- the +# Ghost-Cell NSCBC implementation they controlled was deleted with the rest +# of the Fortran in 2d3a6f6. nscbc_adv defaulted to true, so leaving them in +# place meant that reconnecting a live NSCBC would silently change the +# behaviour of every Hard/UserBC case that did not explicitly opt out. +# PeleC::read_params aborts with a migration message if either key is present +# in an inputs file. See Docs/sphinx/BoundaryConditions.rst. # if true, define an external source term add_ext_src bool false @@ -219,8 +264,14 @@ PrT Real 1.0 # category: EB #----------------------------------------------------------------------------- -# set the EB boundary temperature for isothermal walls -eb_boundary_T Real 1.0 +# set the EB boundary temperature for isothermal walls. The default is +# ambient-like on purpose: this used to default to 1.0 K, and because +# eb_isothermal also defaults true, any diffusive EB case that did not set +# both parameters was silently conducting heat into one-kelvin walls -- the +# NSCBC-Chamber box variants refrigerated their cut cells to 77 K and NaN'd +# at the corners before this was found (see that case's README). A wall +# temperature is physics; set it deliberately. +eb_boundary_T Real 300.0 # flag for isothermal EB boundary eb_isothermal bool true diff --git a/Source/Params/param_includes/pelec_defaults.H b/Source/Params/param_includes/pelec_defaults.H index d24b70f75..2aeaffe88 100644 --- a/Source/Params/param_includes/pelec_defaults.H +++ b/Source/Params/param_includes/pelec_defaults.H @@ -18,8 +18,12 @@ bool PeleC::do_hydro = true; bool PeleC::do_mol = false; bool PeleC::use_hybrid_weno = false; int PeleC::weno_scheme = 1; -bool PeleC::nscbc_adv = true; -bool PeleC::nscbc_diff = false; +bool PeleC::bc_nscbc = false; +amrex::Real PeleC::bc_nscbc_sigma = 0.25; +amrex::Real PeleC::bc_nscbc_relax_u = 2.0; +amrex::Real PeleC::bc_nscbc_relax_t = 0.2; +int PeleC::bc_nscbc_order = 2; +bool PeleC::bc_nscbc_pin_farfield = false; bool PeleC::add_ext_src = false; amrex::GpuArray PeleC::external_forcing = {0.0}; bool PeleC::add_forcing_src = false; @@ -66,7 +70,7 @@ amrex::Real PeleC::Cs = 0.0; amrex::Real PeleC::Cw = 0.0; amrex::Real PeleC::CI = 0.0; amrex::Real PeleC::PrT = 1.0; -amrex::Real PeleC::eb_boundary_T = 1.0; +amrex::Real PeleC::eb_boundary_T = 300.0; bool PeleC::eb_isothermal = true; bool PeleC::eb_noslip = true; std::string PeleC::redistribution_type = "StateRedist"; diff --git a/Source/Params/param_includes/pelec_params.H b/Source/Params/param_includes/pelec_params.H index 1b45f8318..970779cd0 100644 --- a/Source/Params/param_includes/pelec_params.H +++ b/Source/Params/param_includes/pelec_params.H @@ -18,8 +18,12 @@ static bool do_hydro; static bool do_mol; static bool use_hybrid_weno; static int weno_scheme; -static bool nscbc_adv; -static bool nscbc_diff; +static bool bc_nscbc; +static amrex::Real bc_nscbc_sigma; +static amrex::Real bc_nscbc_relax_u; +static amrex::Real bc_nscbc_relax_t; +static int bc_nscbc_order; +static bool bc_nscbc_pin_farfield; static bool add_ext_src; static amrex::GpuArray external_forcing; static bool add_forcing_src; diff --git a/Source/Params/param_includes/pelec_queries.H b/Source/Params/param_includes/pelec_queries.H index 8aa47a63b..9f850362c 100644 --- a/Source/Params/param_includes/pelec_queries.H +++ b/Source/Params/param_includes/pelec_queries.H @@ -18,8 +18,12 @@ pp.query("do_hydro", do_hydro); pp.query("do_mol", do_mol); pp.query("use_hybrid_weno", use_hybrid_weno); pp.query("weno_scheme", weno_scheme); -pp.query("nscbc_adv", nscbc_adv); -pp.query("nscbc_diff", nscbc_diff); +pp.query("bc_nscbc", bc_nscbc); +pp.query("bc_nscbc_sigma", bc_nscbc_sigma); +pp.query("bc_nscbc_relax_u", bc_nscbc_relax_u); +pp.query("bc_nscbc_relax_t", bc_nscbc_relax_t); +pp.query("bc_nscbc_order", bc_nscbc_order); +pp.query("bc_nscbc_pin_farfield", bc_nscbc_pin_farfield); pp.query("add_ext_src", add_ext_src); { amrex::Vector tmp(AMREX_SPACEDIM, 0.0); diff --git a/Source/PeleC.H b/Source/PeleC.H index 11f36ddd3..df7da62b5 100644 --- a/Source/PeleC.H +++ b/Source/PeleC.H @@ -18,6 +18,7 @@ #include "PeleCUtilities.H" #include "Tagging.H" #include "IndexDefines.H" +#include "NSCBC.H" #include "prob_parm.H" #include "PelePhysics.H" #include "ReactorBase.H" @@ -93,6 +94,32 @@ public: ~PeleC() override; + // NSCBC accessors. pc_bcfill_hyp is a free function and the runtime + // parameters are protected statics, so these exist to let the boundary + // filler capture its parameters HOST-side into a POD functor member. A + // device kernel must never read a class static or ParmParse directly. + static bool nscbc_active() { return bc_nscbc; } + static pc_nscbc::Params nscbc_params(int idir); + // Report and reset the NSCBC fallback counters. A silent fallback is a bug + // that will not be found; this is how it surfaces. + static void nscbc_report_diagnostics(); + + // Boundary registers (Docs/NSCBC-boundary-registers-design.md): the + // once-per-advance update, and checkpoint/restart of the register array. + + // Warn when THIS refined level's grids touch a domain face that the + // characteristic treatment can reach (Hard/UserBC): the extrapolation + // stencil is level-local, so a fine patch on such a face makes the + // boundary condition level-dependent. Called from post_regrid and + // post_init; no-op on level 0 or when bc_nscbc is off. + void nscbc_check_fine_faces() const; + + // Gate the characteristic fill's periodicity: a ghost cell and its image + // one period away along a periodic tangential direction must agree to the + // last bit, since they are built from the same data by the same arithmetic. + // Runs once, from post_init, and only when a characteristic face has a + // periodic tangential direction. + // Restart from a checkpoint file. void restart( amrex::Amr& papa, std::istream& is, bool bReadSpecial = false) override; diff --git a/Source/PeleC.cpp b/Source/PeleC.cpp index 11f7951ea..fbba65586 100644 --- a/Source/PeleC.cpp +++ b/Source/PeleC.cpp @@ -194,6 +194,21 @@ PeleC::read_params() pp.query("v", verbose); + // Removed parameters. nscbc_adv/nscbc_diff controlled the Fortran + // Ghost-Cell NSCBC deleted in 2d3a6f6; they have had no effect since. + // Abort rather than ignore, so that an inputs file which believes it is + // configuring a characteristic boundary treatment is not silently wrong. + for (const char* removed : {"nscbc_adv", "nscbc_diff"}) { + if (pp.contains(removed)) { + amrex::Abort( + "pelec." + std::string(removed) + + " has been removed: it has had no effect since the Fortran GC-NSCBC " + "was deleted. Remove it from your inputs file. See " + "Docs/sphinx/BoundaryConditions.rst for the currently recommended " + "subsonic inflow/outflow treatment."); + } + } + // Get boundary conditions amrex::Vector lo_bc_char(AMREX_SPACEDIM); amrex::Vector hi_bc_char(AMREX_SPACEDIM); @@ -324,6 +339,88 @@ PeleC::read_params() } } + if (bc_nscbc) { + pc_nscbc::Params probe = nscbc_params(0); + probe.sigma = bc_nscbc_sigma; + std::string why; + if (!probe.validate(why)) { + amrex::Abort("pelec.bc_nscbc_*: " + why); + } + // The treatment only ever acts on ext_dir faces, i.e. Hard/UserBC. + bool any_ext_dir = false; + for (int dir = 0; dir < AMREX_SPACEDIM; ++dir) { + for (const auto& b : {lo_bc[dir], hi_bc[dir]}) { + any_ext_dir = any_ext_dir || (b == PCPhysBCType::user_bc) || + (b == PCPhysBCType::inflow); + } + } + if (!any_ext_dir) { + amrex::Abort( + "pelec.bc_nscbc = 1 but no boundary is Hard or UserBC. The " + "characteristic treatment only acts on those faces."); + } + // Chemistry integrated in ghost cells would burn the state the boundary + // condition just wrote there -- a hot inflow target composition would + // ignite in the ghost region. + if (state_nghost > 0) { + amrex::Abort( + "pelec.bc_nscbc = 1 with pelec.state_nghost > 0: reactions are " + "integrated over grown tileboxes, which would advance chemistry in " + "the ghost cells written by the boundary condition."); + } + // The characteristic fill has no EBCellFlag: it detects covered stencil + // cells by their non-positive density. The DEFAULT body state is a + // sampled fluid state (define_body_state) -- positive density, + // undetectable -- so without eb_zero_body_state the fill would silently + // read body values as if they were interior data. Fatal rather than + // silent: that is the standing rule for this boundary condition. + if (eb_in_domain && !eb_zero_body_state) { + amrex::Abort( + "pelec.bc_nscbc = 1 with EB geometry requires " + "pelec.eb_zero_body_state = 1: the characteristic fill detects " + "covered stencil cells by non-positive density, and the default " + "body state is a sampled fluid state it cannot distinguish from " + "interior data."); + } + if (amrex::ParallelDescriptor::IOProcessor()) { + amrex::Print() + << "\n NSCBC: characteristic boundary treatment ENABLED (advective " + "ghost fill).\n" + << " This is the 1-D-normal LODI limit: no transverse terms and no\n" + << " reaction-source correction. Place outflows away from flames,\n" + << " shear layers and composition fronts. See Source/NSCBC.H.\n"; + if (bc_nscbc_pin_farfield) { + amrex::Print() + << " incoming acoustic PINNED to the far field; bc_nscbc_sigma " + "ignored.\n" + << " Anchors to p_target + rho*c*u and does not converge under " + "mesh refinement.\n"; + } else { + amrex::Print() << " sigma = " << bc_nscbc_sigma; + if (bc_nscbc_sigma > 0.0) { + amrex::Print() + << " -> outflow pressure relaxes over " << 1.0 / bc_nscbc_sigma + << " acoustic transit times at low Mach\n" + << " (tau_relax / tau_acoustic = 1 / (sigma (1 - " + "M^2)));\n" + << " it must be much larger than 1 and much smaller " + "than the run time.\n"; + } else { + amrex::Print() << " -> perfectly non-reflecting; the mean pressure " + "is NOT anchored and will drift.\n"; + } + } + amrex::Print() << " relax_u = " << bc_nscbc_relax_u + << ", relax_t = " << bc_nscbc_relax_t + << ", order = " << bc_nscbc_order << "\n"; + amrex::Print() << " L_ref = ("; + for (int dir = 0; dir < AMREX_SPACEDIM; ++dir) { + amrex::Print() << (dir > 0 ? ", " : "") << nscbc_params(dir).L_ref; + } + amrex::Print() << ")\n\n"; + } + } + if (amrex::DefaultGeometry().IsRZ() && (lo_bc[0] != PCPhysBCType::symmetry)) { amrex::Error( "PeleC::read_params: must set r=0 boundary condition to " @@ -1157,6 +1254,19 @@ PeleC::postCoarseTimeStep(amrex::Real cumtime) { BL_PROFILE("PeleC::postCoarseTimeStep()"); AmrLevel::postCoarseTimeStep(cumtime); + + // NSCBC counters must never be silent: without this, a run with the + // default sum_interval = -1 never calls nscbc_report_diagnostics() and a + // boundary counting millions of reversal fills looks identical to one + // counting none (that misread happened; see + // Docs/NSCBC-reversal-branch-defect.md). When the user has not opted + // into periodic reporting, report on a fixed cadence -- the report only + // prints when something actually counted, so healthy runs stay quiet. + if (bc_nscbc && verbose > 0 && sum_interval <= 0 && sum_per <= 0.0) { + if (parent->levelSteps(0) % 100 == 0) { + nscbc_report_diagnostics(); + } + } } void @@ -1176,6 +1286,8 @@ PeleC::post_regrid(int lbase, int /*new_finest*/) if ((do_react) && (use_typical_vals_chem)) { set_typical_values_chem(); } + + nscbc_check_fine_faces(); } void @@ -1183,6 +1295,13 @@ PeleC::post_init(amrex::Real /*stop_time*/) { BL_PROFILE("PeleC::post_init()"); + if (level == 0) { + const int finest = parent->finestLevel(); + for (int lev = 1; lev <= finest; ++lev) { + getLevel(lev).nscbc_check_fine_faces(); + } + } + amrex::Real dtlev = parent->dtLevel(level); amrex::Real cumtime = parent->cumTime(); diff --git a/Source/ProblemSpecificFunctions.H b/Source/ProblemSpecificFunctions.H index ef8262ae3..d6b98a474 100644 --- a/Source/ProblemSpecificFunctions.H +++ b/Source/ProblemSpecificFunctions.H @@ -7,6 +7,7 @@ #include "PeleC.H" #include "IndexDefines.H" +#include "NSCBC.H" struct DefaultProblemSpecificFunctions { @@ -160,6 +161,37 @@ struct DefaultProblemSpecificFunctions { } + // Characteristic (NSCBC) boundary treatment. + // + // Return the target state for one boundary POINT. The default returns + // Type::off, which leaves that point to the ordinary bcnormal() path, so + // pelec.bc_nscbc = 1 is a no-op for a problem that does not override this. + // Because the decision is per point, a single face may mix an inflow, an + // outflow and a wall. + // + // Only the fields relevant to the returned type are read: + // Type::outflow -> p + // Type::inflow -> u[], T, Y[] + // There is deliberately no relaxation coefficient for composition; see + // Source/NSCBC.H. + // + // s_int is the state in the first interior cell along the boundary normal, + // for problems whose target depends on the interior state (a back-pressure + // that tracks a measured plenum, say). Most problems will not need it. + AMREX_GPU_DEVICE + AMREX_FORCE_INLINE + static pc_nscbc::Target bcnormal_nscbc( + const amrex::Real* /*x[AMREX_SPACEDIM]*/, + const amrex::Real* /*s_int[NVAR]*/, + const int /*idir*/, + const int /*sgn*/, + const amrex::Real /*time*/, + amrex::GeometryData const& /*geomdata*/, + ProbParmDevice const& /*prob_parm*/) + { + return pc_nscbc::Target{}; + } + /* TODO static void something_with_ADV_AUX_transport() { diff --git a/Source/SumIQ.cpp b/Source/SumIQ.cpp index 9b3daf376..5c6bfd84d 100644 --- a/Source/SumIQ.cpp +++ b/Source/SumIQ.cpp @@ -11,6 +11,8 @@ PeleC::sum_integrated_quantities() return; } + nscbc_report_diagnostics(); + bool local_flag = true; int finest_level = parent->finestLevel(); From ece2d39d8df235325625dde3986ea335fa38cb40 Mon Sep 17 00:00:00 2001 From: Marc Day Date: Thu, 27 Aug 2026 22:13:57 +0200 Subject: [PATCH 2/4] NSCBC 2/7: transverse terms in the modelled incoming wave The multi-dimensional correction: -(1 - beta) T_in on the incoming amplitude, where T_in carries the tangential convective and dilatational terms in the outward frame (the ghost-cell transplantation of Motheau's T1/T4 -- one expression for lo and hi faces). Tangential derivatives come from the boundary cell's clamped neighbours; at a corner the stencil collapses one-sided or to nothing, which is the measured replacement for the AIAA corner-coupling machinery. The beta blend is exact for a plane wave at incidence theta: beta_opt = 1 - cos(theta)/(1 + cos(theta)) -- 0.5 head-on, toward 1 at grazing -- and both measured optima in the COVO/pulse suite land on it. The response is asymmetric (a wrong beta is worse than an absent one), so the default stays 1: this PR is bit-inert until a problem opts in, and every result of the previous PR is unchanged. The clamp leaves a small measured seam residual where a characteristic face meets a periodic direction, and that trade is guarded rather than assumed: nscbc_check_periodic_wrap() measures the ghost-image mismatch at startup, reports it beside the boundary-row spread (so a vacuous pass is visible), and aborts above 1e-2 -- an order above the worst known-good measurement and an order below what a broken stencil produces. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XU8M23nucKsu1do2WxXFeq --- Source/BCfill.cpp | 217 +++++++++++++++++- Source/NSCBC.H | 88 ++++++- Source/Params/_cpp_parameters | 22 ++ Source/Params/param_includes/pelec_defaults.H | 1 + Source/Params/param_includes/pelec_params.H | 1 + Source/Params/param_includes/pelec_queries.H | 1 + Source/PeleC.H | 1 + Source/PeleC.cpp | 1 + 8 files changed, 330 insertions(+), 2 deletions(-) diff --git a/Source/BCfill.cpp b/Source/BCfill.cpp index 51b905345..6089534e0 100644 --- a/Source/BCfill.cpp +++ b/Source/BCfill.cpp @@ -188,10 +188,42 @@ struct PCHypFillExtDir // Boundary-register composition (level 0 only; the registers change // once per advance and are read frozen here). The kernel below sees // only the composed Target -- it stays a pure function. + // Tangential neighbours of the boundary cell, for the transverse terms, + // clamped into the domain: at a corner the clamp collapses the stencil + // and inv_dt falls to a one-sided spacing, or to zero if both + // neighbours land on the same cell. + pc_nscbc::Transverse tr; + amrex::Real s_tm[AMREX_SPACEDIM][NVAR], s_tp[AMREX_SPACEDIM][NVAR]; + if (m_nscbc_prm[idir].beta < 1.0) { + for (int d = 0; d < AMREX_SPACEDIM; ++d) { + if (d == idir) { + continue; + } + const int dlo = amrex::max(domlo[d], fab_lo[d]); + const int dhi = amrex::min(domhi[d], fab_hi[d]); + const int jm = amrex::max(ivN[d] - 1, dlo); + const int jp = amrex::min(ivN[d] + 1, dhi); + if (jp == jm) { + continue; + } + amrex::IntVect ivm(ivN), ivp(ivN); + ivm[d] = jm; + ivp[d] = jp; + for (int n = 0; n < NVAR; n++) { + s_tm[d][n] = dest(ivm, n); + s_tp[d][n] = dest(ivp, n); + } + tr.sm[d] = s_tm[d]; + tr.sp[d] = s_tp[d]; + tr.inv_dt[d] = 1.0 / (static_cast(jp - jm) * dx[d]); + tr.valid = true; + } + } + amrex::Real s_ghost[NVAR]; pc_nscbc::apply( s_N, s_Nm1, s_Nm2, n_stencil, dx[idir], idir, sgn, layer, tgt, - m_nscbc_prm[idir], s_ghost, m_nscbc_diag); + m_nscbc_prm[idir], s_ghost, m_nscbc_diag, &tr); for (int n = 0; n < NVAR; n++) { dest(iv, n) = s_ghost[n]; } @@ -483,6 +515,188 @@ PeleC::nscbc_check_fine_faces() const } } +// --------------------------------------------------------------------------- +// Periodic-seam gate. Measures how far the characteristic fill is from +// periodic where the domain is: the worst relative mismatch between ghost +// cells and their images one period away. The clamped tangential stencil +// (see nscbc_fill) leaves a deliberate residual at the seam under amrex's +// corner-strip protocol -- measured 2e-4 on the inert vortex and 1.6e-3 +// with a flame front sitting on the seam corner -- so the gate REPORTS the +// measured value and aborts only above 1e-2, an order of margin above the +// worst known-good state and an order below a broken stencil (the naive +// wrap measured 2.2e-2 here). The report guards its own blind spots: the +// tangential spread of the boundary row is printed beside the mismatch (a pass +// on a boundary-uniform row gates nothing), and a decomposition with no image +// pair in one FAB says NOT CHECKED instead of passing. Exercised by +// NSCBC-COVO/nscbc-wrapgate.inp. +// --------------------------------------------------------------------------- +void +PeleC::nscbc_check_periodic_wrap() +{ + if (!bc_nscbc || (level != 0)) { + return; + } + static bool done = false; + if (done) { + return; + } + + const amrex::Box& dom = geom.Domain(); + auto characteristic = [&](const int dir, const int side) { + const int t = (side == 0) ? phys_bc.lo(dir) : phys_bc.hi(dir); + return (t == PCPhysBCType::inflow) || (t == PCPhysBCType::user_bc); + }; + + bool relevant = false; + for (int idir = 0; idir < AMREX_SPACEDIM; ++idir) { + for (int d = 0; d < AMREX_SPACEDIM; ++d) { + relevant = + relevant || ((characteristic(idir, 0) || characteristic(idir, 1)) && + (d != idir) && geom.isPeriodic(d)); + } + } + if (!relevant) { + return; + } + done = true; + + const int ng = numGrow(); + amrex::MultiFab S(grids, dmap, NVAR, ng, amrex::MFInfo(), Factory()); + FillPatch(*this, S, ng, state[State_Type].curTime(), State_Type, 0, NVAR); + + const amrex::Real big = std::numeric_limits::max(); + + for (int idir = 0; idir < AMREX_SPACEDIM; ++idir) { + for (int side = 0; side < 2; ++side) { + if (!characteristic(idir, side)) { + continue; + } + const int N_pos = (side == 0) ? dom.smallEnd(idir) : dom.bigEnd(idir); + + for (int d = 0; d < AMREX_SPACEDIM; ++d) { + if ((d == idir) || (!geom.isPeriodic(d))) { + continue; + } + const int n_d = dom.length(d); + if (n_d < ng) { + continue; // the image slab would fold over itself + } + + // The ghost cells of this face that sit above the domain in d. The + // image of each is n_d cells below, and is a ghost of this face too. + amrex::Box reg = amrex::grow(dom, ng); + if (side == 0) { + reg.setBig(idir, dom.smallEnd(idir) - 1); + } else { + reg.setSmall(idir, dom.bigEnd(idir) + 1); + } + reg.setSmall(d, dom.bigEnd(d) + 1); + reg.setBig(d, dom.bigEnd(d) + ng); + + const amrex::IntVect img = -n_d * amrex::IntVect::TheDimensionVector(d); + + amrex::ReduceOps< + amrex::ReduceOpMax, amrex::ReduceOpSum, amrex::ReduceOpMax, + amrex::ReduceOpMin> + op; + amrex::ReduceData + rd(op); + using RT = typename decltype(rd)::Type; + + for (amrex::MFIter mfi(S); mfi.isValid(); ++mfi) { + auto const& a = S.const_array(mfi); + const amrex::Box& fbx = mfi.fabbox(); + + // The image pairs this FAB holds both halves of. + amrex::Box sh(fbx); + sh.shift(-img); + const amrex::Box pbx = fbx & reg & sh; + if (!pbx.isEmpty()) { + op.eval(pbx, rd, [=] AMREX_GPU_DEVICE(int i, int j, int k) -> RT { + amrex::ignore_unused(k); // 2-D: AMREX_D_DECL drops it + const amrex::IntVect iv(AMREX_D_DECL(i, j, k)); + const amrex::IntVect iw = iv + img; + amrex::Real e = 0.0; + for (int n = 0; n < NVAR; n++) { + const amrex::Real u = a(iv, n); + const amrex::Real v = a(iw, n); + const amrex::Real s = amrex::max( + amrex::Math::abs(u), + amrex::max( + amrex::Math::abs(v), + std::numeric_limits::min())); + e = amrex::max(e, amrex::Math::abs(u - v) / s); + } + return {e, amrex::Long(1), -big, big}; + }); + } + + // The tangential structure of the boundary row itself: valid data, + // so this measures whether the check above could have failed. + amrex::Box row = dom; + row.setSmall(idir, N_pos); + row.setBig(idir, N_pos); + row &= mfi.validbox(); + if (!row.isEmpty()) { + op.eval(row, rd, [=] AMREX_GPU_DEVICE(int i, int j, int k) -> RT { + const amrex::Real r = a(i, j, k, URHO); + return {0.0, amrex::Long(0), r, r}; + }); + } + } + + auto hv = rd.value(op); + amrex::Real worst = amrex::get<0>(hv); + amrex::Long npairs = amrex::get<1>(hv); + amrex::Real rmax = amrex::get<2>(hv); + amrex::Real rmin = amrex::get<3>(hv); + amrex::ParallelDescriptor::ReduceRealMax(worst); + amrex::ParallelDescriptor::ReduceLongSum(npairs); + amrex::ParallelDescriptor::ReduceRealMax(rmax); + amrex::ParallelDescriptor::ReduceRealMin(rmin); + const amrex::Real spread = + (rmax > -big) + ? (rmax - rmin) / amrex::max( + amrex::Math::abs(rmax), + std::numeric_limits::min()) + : 0.0; + + constexpr amrex::Real tol = 1.0e-2; + if (worst > tol) { + amrex::Abort( + "NSCBC periodic-seam check FAILED on direction " + + std::to_string(idir) + " " + (side == 0 ? "lo" : "hi") + + " with periodic tangential direction " + std::to_string(d) + + ": worst relative mismatch " + std::to_string(worst) + " over " + + std::to_string(npairs) + + " image pairs. The characteristic fill is not periodic where the " + "domain is."); + } + if (amrex::ParallelDescriptor::IOProcessor() && (verbose > 0)) { + amrex::Print() << " NSCBC periodic-seam check: dir " << idir + << (side == 0 ? " lo" : " hi") + << ", periodic tangential dir " << d << " -- "; + if (npairs == 0) { + amrex::Print() + << "NOT CHECKED: no box holds a ghost cell and its image " + "together. Raise amr.max_grid_size in direction " + << d << " to span the domain if you want this gated.\n"; + } else { + amrex::Print() << npairs << " image pairs agree to " << worst + << " (boundary-row density spread " << spread + << ")\n"; + if (spread == 0.0) { + amrex::Print() + << " (that row is uniform along the boundary, so this pass " + "is vacuous -- a clamped stencil would pass it too.)\n"; + } + } + } + } + } + } +} + pc_nscbc::Params PeleC::nscbc_params(const int idir) { @@ -491,6 +705,7 @@ PeleC::nscbc_params(const int idir) p.relax_u = bc_nscbc_relax_u; p.relax_t = bc_nscbc_relax_t; p.order = bc_nscbc_order; + p.beta = bc_nscbc_beta; p.pin_farfield = bc_nscbc_pin_farfield; // Only the ratio sigma/L_ref is physical. L_ref is fixed to the domain // extent along the boundary normal so that sigma keeps the meaning it has diff --git a/Source/NSCBC.H b/Source/NSCBC.H index 57a1bd088..4f187cad0 100644 --- a/Source/NSCBC.H +++ b/Source/NSCBC.H @@ -91,6 +91,20 @@ struct Params // Inflow temperature and tangential-velocity relaxation. amrex::Real relax_t = 0.2; + // Weight on the transverse terms in the modelled incoming acoustic: + // L_in = K (phi - phi_target) - (1 - beta) * T_transverse + // 1 discards them (the 1-D-normal LODI limit, and the default: enabling + // them is an opt-in change), 0 includes them fully, < 0 selects the + // pointwise local Mach number (the legacy Fortran convention). + // + // Exact blend for a plane wave at angle theta: + // beta_opt = 1 - cos(theta)/(1+cos(theta)) -- 0.5 head-on, toward 1 at + // grazing; both measured optima land on it (NSCBC-COVO/README.md). The + // response is asymmetric: beta = 0 is near-unstable and pointwise + // local-Mach is worse than no correction. A wrong beta is more dangerous + // than an absent one: start at 0.5, raise toward 1 for grazing incidence. + amrex::Real beta = 1.0; + // Boundary-normal reference length, probhi[idir] - problo[idir]. Filled per // face by the caller. Only the ratio sigma/L_ref is physical, so this is // not a user knob; it exists so that sigma keeps its literature meaning. @@ -125,6 +139,11 @@ struct Params why = "bc_nscbc_relax_t must be >= 0"; return false; } + if (beta > 1.0) { + why = "bc_nscbc_beta must be <= 1 (1 discards the transverse terms, 0 " + "includes them fully, negative selects the local Mach number)"; + return false; + } if ((order != 1) && (order != 2)) { why = "bc_nscbc_order must be 1 or 2"; return false; @@ -167,6 +186,22 @@ struct Target amrex::Real dudt = 0.0; // d(u_normal target)/dt, LAB frame [cm/s^2] }; +// --------------------------------------------------------------------------- +// Tangential neighbours of the BOUNDARY CELL, for the transverse terms +// T_in = sum_t [ u_t (dp/dt - rho c du_out/dt) ] + gamma p sum_t du_t/dt +// (derivatives at the boundary cell; the outward frame absorbs the lo/hi +// sign flip). Neighbour indexing is the caller's -- see tang_range() in +// BCfill.cpp -- and the spacing arrives as an explicit reciprocal, zero when +// a collapsed corner stencil leaves nothing to difference. +// --------------------------------------------------------------------------- +struct Transverse +{ + const amrex::Real* sm[AMREX_SPACEDIM] = {}; + const amrex::Real* sp[AMREX_SPACEDIM] = {}; + amrex::Real inv_dt[AMREX_SPACEDIM] = {}; + bool valid = false; +}; + // --------------------------------------------------------------------------- // Diagnostic counters. Every fallback is counted; a silent fallback is a // bug that will not be found. Storage is amrex::Long, not int: with the @@ -373,7 +408,8 @@ apply( Target const& tgt, Params const& prm, amrex::Real s_ghost[NVAR], - amrex::Long* diag = nullptr) noexcept + amrex::Long* diag = nullptr, + Transverse const* tr = nullptr) noexcept { auto eos = pele::physics::PhysicsType::eos(); @@ -545,6 +581,55 @@ apply( ? 1.0 : amrex::max(0.0, 1.0 + u_out_N / (1.0e-3 * c_N)); + // Transverse contribution to the modelled incoming wave. Additive on the + // amplitude, which is exactly why the relaxation is written as a gradient + // increment: there is a well-defined slot for it here, and none at all in a + // hard far-field pin of R_-. + amrex::Real beta = prm.beta; + if (beta < 0.0) { + beta = amrex::min(std::abs(mach), 1.0); + } + amrex::Real T_in = 0.0; + if ((tr != nullptr) && tr->valid && (beta < 1.0)) { + amrex::Real gam = 0.0; + eos.RTY2G(qN.rho, qN.T, qN.Y, gam); + amrex::Real div_ut = 0.0; + bool any = false; + for (int d = 0; d < AMREX_SPACEDIM; ++d) { + if ( + (d == idir) || (tr->sm[d] == nullptr) || (tr->sp[d] == nullptr) || + (tr->inv_dt[d] == 0.0)) { + continue; + } + const CellPrim qm = cell_primitives(tr->sm[d]); + const CellPrim qp = cell_primitives(tr->sp[d]); + if (!qm.ok || !qp.ok) { + bump(Diag::transverse_drop); + continue; + } + const amrex::Real dpdt = (qp.p - qm.p) * tr->inv_dt[d]; + // du_out/dt: the normal velocity in the OUTWARD frame, differentiated + // along the tangential direction. The n_sgn here is what turns the + // legacy hi-face T1 and lo-face T4 into one expression. + const amrex::Real duodt = + n_sgn * (qp.u[idir] - qm.u[idir]) * tr->inv_dt[d]; + const amrex::Real dutdt = (qp.u[d] - qm.u[d]) * tr->inv_dt[d]; + T_in += qN.u[d] * (dpdt - rho_c * duodt); + div_ut += dutdt; + any = true; + } + if (any && amrex::Math::isfinite(gam) && (gam > 0.0)) { + T_in += gam * qN.p * div_ut; + } else { + T_in = 0.0; + bump(Diag::transverse_drop); + } + if (!amrex::Math::isfinite(T_in)) { + T_in = 0.0; + bump(Diag::transverse_drop); + } + } + const amrex::Real dRm = 0.0; amrex::Real Rm_g; @@ -571,6 +656,7 @@ apply( // velocity rate through the incoming invariant alone needs twice it. L_in += 2.0 * rho_c * (n_sgn * tgt.dudt); } + L_in -= (1.0 - beta) * T_in; // There is deliberately NO diffusive source term on L_in. The // ghost-cell form does not have the flux-form's viscous-condition gap: // the diffusion operator reads these ghost cells, so a correct ghost diff --git a/Source/Params/_cpp_parameters b/Source/Params/_cpp_parameters index 78558dfba..3c1397804 100644 --- a/Source/Params/_cpp_parameters +++ b/Source/Params/_cpp_parameters @@ -94,6 +94,28 @@ bc_nscbc_relax_u Real 2.0 # PeleC is positive and the internal signs are handled by the kernel. bc_nscbc_relax_t Real 0.2 +# Weight on the transverse terms in the modelled incoming acoustic wave: +# 1 discard them -- the 1-D-normal LODI limit, and the DEFAULT +# 0 include them fully +# <0 use the local Mach number (the legacy Fortran convention; not +# recommended, see below) +# +# The transverse terms are what a boundary needs when a wave arrives obliquely, +# or when a vortex rather than a sound wave crosses it. Measured in +# Exec/RegTests/NSCBC-COVO, against a periodic no-boundary reference: +# +# convected vortex, rms residual: hard 14.0x floor, beta=1 10.0x, +# beta=0.5 3.8x, beta=0.2 16.1x, beta=0 132x +# circular pulse, front amplitude spread: hard 21.4%, beta=1 0.90%, +# beta=0.8 0.066%, beta=0 2.9% +# +# The optimum follows beta = 1 - cos(theta)/(1 + cos(theta)) for a wave meeting +# the boundary at angle theta: 0.5 at normal incidence, rising toward 1 at +# grazing incidence. Too little correction costs a factor of a few; too much +# is catastrophic. RECOMMENDED STARTING VALUE 0.5. The default is 1 only +# because a wrong beta is far more dangerous than an absent one. Must be <= 1. +bc_nscbc_beta Real 1.0 + # Order of the outgoing-invariant extrapolation, 1 or 2. Verification and # debugging knob, not a physics knob; leave at 2. bc_nscbc_order int 2 diff --git a/Source/Params/param_includes/pelec_defaults.H b/Source/Params/param_includes/pelec_defaults.H index 2aeaffe88..efa1a41fc 100644 --- a/Source/Params/param_includes/pelec_defaults.H +++ b/Source/Params/param_includes/pelec_defaults.H @@ -22,6 +22,7 @@ bool PeleC::bc_nscbc = false; amrex::Real PeleC::bc_nscbc_sigma = 0.25; amrex::Real PeleC::bc_nscbc_relax_u = 2.0; amrex::Real PeleC::bc_nscbc_relax_t = 0.2; +amrex::Real PeleC::bc_nscbc_beta = 1.0; int PeleC::bc_nscbc_order = 2; bool PeleC::bc_nscbc_pin_farfield = false; bool PeleC::add_ext_src = false; diff --git a/Source/Params/param_includes/pelec_params.H b/Source/Params/param_includes/pelec_params.H index 970779cd0..c5c6f80e7 100644 --- a/Source/Params/param_includes/pelec_params.H +++ b/Source/Params/param_includes/pelec_params.H @@ -22,6 +22,7 @@ static bool bc_nscbc; static amrex::Real bc_nscbc_sigma; static amrex::Real bc_nscbc_relax_u; static amrex::Real bc_nscbc_relax_t; +static amrex::Real bc_nscbc_beta; static int bc_nscbc_order; static bool bc_nscbc_pin_farfield; static bool add_ext_src; diff --git a/Source/Params/param_includes/pelec_queries.H b/Source/Params/param_includes/pelec_queries.H index 9f850362c..b381f61d0 100644 --- a/Source/Params/param_includes/pelec_queries.H +++ b/Source/Params/param_includes/pelec_queries.H @@ -22,6 +22,7 @@ pp.query("bc_nscbc", bc_nscbc); pp.query("bc_nscbc_sigma", bc_nscbc_sigma); pp.query("bc_nscbc_relax_u", bc_nscbc_relax_u); pp.query("bc_nscbc_relax_t", bc_nscbc_relax_t); +pp.query("bc_nscbc_beta", bc_nscbc_beta); pp.query("bc_nscbc_order", bc_nscbc_order); pp.query("bc_nscbc_pin_farfield", bc_nscbc_pin_farfield); pp.query("add_ext_src", add_ext_src); diff --git a/Source/PeleC.H b/Source/PeleC.H index df7da62b5..ce9c82344 100644 --- a/Source/PeleC.H +++ b/Source/PeleC.H @@ -119,6 +119,7 @@ public: // last bit, since they are built from the same data by the same arithmetic. // Runs once, from post_init, and only when a characteristic face has a // periodic tangential direction. + void nscbc_check_periodic_wrap(); // Restart from a checkpoint file. void restart( diff --git a/Source/PeleC.cpp b/Source/PeleC.cpp index fbba65586..38e99f89e 100644 --- a/Source/PeleC.cpp +++ b/Source/PeleC.cpp @@ -1300,6 +1300,7 @@ PeleC::post_init(amrex::Real /*stop_time*/) for (int lev = 1; lev <= finest; ++lev) { getLevel(lev).nscbc_check_fine_faces(); } + nscbc_check_periodic_wrap(); } amrex::Real dtlev = parent->dtLevel(level); From ecb42d7bd2063fe94839da7e1612afa3c7269780 Mon Sep 17 00:00:00 2001 From: Marc Day Date: Thu, 27 Aug 2026 22:14:51 +0200 Subject: [PATCH 3/4] NSCBC 3/7: reacting boundaries and fronts What a flame does to a characteristic boundary, and the closures that survive it -- all default-off, so the previous two PRs' results are unchanged to the bit until a problem opts in. - The reaction source enters the modelled incoming wave with +(1 - beta_s) S_p (Sutherland-Kennedy): a flame in the boundary cells raises pressure at a rate the relaxation has no model for, and without the term the mean sits off target by S_p/K. The sign is PLUS -- the exact steady flame has dR_-/dn = +S_p/(rho c^2), and the driver PR carries a convention-free check of exactly that. S_p is the compositional dp/dt at fixed (rho, e): closed form for ideal gas, a directional finite difference along the reaction path for SRK. - extrap_temperature closes the lambda_0 family on temperature instead of the entropy invariant. The diffusion operator forms boundary-face fluxes from these ghosts, so the ghost dT/dn IS the heat flux leaving; the entropy closure overstates a flame-like face gradient by 40%. This is the flag that lets a flame survive the outflow. - extrap_material continues the material part of R_- into the ghost, bounded through the acoustically-blind entropy family (extrapolating R_-'s own gradient is positive feedback, measured 16x). For fronts that SIT on the boundary; a transit over-vents with it on. - backflow_material treats FIRM reversal as a local inflow of the Target's reservoir state, ramped over an outward-Mach band continuous with the reversal upwinding underneath -- without it a face in sustained recirculation feeds the domain its own exhaust. Also the structure advisory: material structure sitting in outflow boundary cells is counted, and the counter report prints the measured flame-on-boundary recipe when it fires. Measured guidance and the sitting/transit tables live with the regression cases later in this stack (NSCBC-FlameOutflow, README). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XU8M23nucKsu1do2WxXFeq --- Source/BCfill.cpp | 22 +- Source/NSCBC.H | 260 +++++++++++++++++- Source/Params/_cpp_parameters | 49 ++++ Source/Params/param_includes/pelec_defaults.H | 4 + Source/Params/param_includes/pelec_params.H | 4 + Source/Params/param_includes/pelec_queries.H | 4 + 6 files changed, 334 insertions(+), 9 deletions(-) diff --git a/Source/BCfill.cpp b/Source/BCfill.cpp index 6089534e0..5efe03a1b 100644 --- a/Source/BCfill.cpp +++ b/Source/BCfill.cpp @@ -706,6 +706,7 @@ PeleC::nscbc_params(const int idir) p.relax_t = bc_nscbc_relax_t; p.order = bc_nscbc_order; p.beta = bc_nscbc_beta; + p.beta_s = bc_nscbc_beta_s; p.pin_farfield = bc_nscbc_pin_farfield; // Only the ratio sigma/L_ref is physical. L_ref is fixed to the domain // extent along the boundary normal so that sigma keeps the meaning it has @@ -714,6 +715,9 @@ PeleC::nscbc_params(const int idir) // probhi(idir) and was therefore silently wrong for any domain not // anchored at the origin. const auto& geom = amrex::DefaultGeometry(); + p.extrap_temperature = bc_nscbc_extrap_temperature; + p.extrap_material = bc_nscbc_extrap_material; + p.backflow_material = bc_nscbc_backflow_material; p.L_ref = geom.ProbHi(idir) - geom.ProbLo(idir); return p; } @@ -742,7 +746,10 @@ PeleC::nscbc_report_diagnostics() h[pc_nscbc::Diag::eos_failure] + h[pc_nscbc::Diag::floored] + h[pc_nscbc::Diag::transverse_drop] + h[pc_nscbc::Diag::source_drop] + h[pc_nscbc::Diag::target_incomplete]; - if (amrex::ParallelDescriptor::IOProcessor() && (total > 0 || verbose > 1)) { + const amrex::Long structure = h[pc_nscbc::Diag::structure]; + if ( + amrex::ParallelDescriptor::IOProcessor() && + (total > 0 || structure > 0 || verbose > 1)) { amrex::Print() << " NSCBC fallbacks since last report:" << " supersonic " << h[pc_nscbc::Diag::supersonic] << ", flow reversal " << h[pc_nscbc::Diag::reversed] << ", EB body state " @@ -752,6 +759,19 @@ PeleC::nscbc_report_diagnostics() << h[pc_nscbc::Diag::transverse_drop] << ", source dropped " << h[pc_nscbc::Diag::source_drop] << ", target incomplete " << h[pc_nscbc::Diag::target_incomplete] << "\n"; + if (structure > 0) { + // Advisory, not a fallback: a front is in the outflow boundary cells, + // which is the configuration the flame closures exist for. + amrex::Print() + << " NSCBC: material structure (|dS| > 5% of rho per cell) sat in " + << structure << " outflow boundary-cell fills since the last report.\n" + << " A flame or front is on this outflow: set " + "bc_nscbc_extrap_temperature = 1 and bc_nscbc_beta_s = 0 (any " + "sigma then\n" + << " survives a crossing), and keep bc_nscbc_extrap_material " + "off during a transit. See the BCs chapter and " + "NSCBC-FlameOutflow/README.md.\n"; + } } // Settle any counter atomics still in flight on other streams before the // reset; the blocking Gpu::copy above synchronised only its own stream. diff --git a/Source/NSCBC.H b/Source/NSCBC.H index 4f187cad0..3f3f194c6 100644 --- a/Source/NSCBC.H +++ b/Source/NSCBC.H @@ -105,6 +105,66 @@ struct Params // than an absent one: start at 0.5, raise toward 1 for grazing incidence. amrex::Real beta = 1.0; + // Weight on the REACTION source term in the modelled incoming acoustic, + // with the same convention as beta: + // L_in = K (phi - phi_target) - (1 - beta) T + (1 - beta_s) S_reaction + // 1 discards it (the default; correct when no heat release reaches the + // boundary cells), 0 includes it fully. + // + // A flame in the boundary cells raises p at S_p = dp/dt|_react, which the + // relaxation has no model for; without this term the mean sits off target + // by S_p/K. Including it is the Sutherland-Kennedy (JCP 2003) + // cancellation; the sign is PLUS because the exact steady flame has + // dR_-/dn = +S_p/(rho c^2) (convention-free check: + // Verification/NSCBC1D/source_sign_check.py). Guidance, measured in + // NSCBC-FlameOutflow/README.md: sitting flame -- beta_s = 0 halves the + // sigma = 1 error and with extrap_temperature holds hard-outflow level at + // every sigma (at sigma = 16 it overshoots; do not stack both). Crossing + // flame -- with extrap_temperature it cuts the transit disturbance 7-10x + // and lets sigma < 16 survive. Not obtainable from srcq(QPRES); see + // reaction_dpdt(). + amrex::Real beta_s = 1.0; + + // Close the lambda_0 family on TEMPERATURE instead of the entropy + // invariant. The diffusion operator forms boundary-face fluxes from these + // ghost cells, so the ghost dT/dn IS the heat flux leaving; the entropy + // closure sets T as a by-product of the acoustic algebra and overstates a + // flame-like face gradient by 40% (check C8). With this set, T rides the + // same minmod slope as everything else and rho follows from the EOS -- a + // deliberate Neumann condition on T instead of an accidental Dirichlet one. + // Default false (changes every outflow result). Turn it on whenever + // thermal or compositional structure nears the boundary: it is the flag + // that lets a flame survive the outflow (NSCBC-FlameOutflow/README.md). + bool extrap_temperature = false; + + // Continue the MATERIAL part of R_- into the ghost at an outflow, so a + // dilatational gradient crossing the face is not converted into ghost + // pressure (the C9(a) bias, 1/2 rho c l du per layer). The slope cannot be + // read from R_- itself (its gradient contains the boundary's own incoming + // waves; extrapolating them back is positive feedback, 16x on the rate, + // C5), so it is bounded through the acoustically-blind entropy family: + // du_mat = -u_out dS / (rho (1 - M^2)), + // applied slope = minmod(measured dR_-, du_mat (1 + M)). + // For pure acoustics the bound vanishes and the continuation shuts off. + // Quasi-steady bound: conservative for a moving front; keep sigma > 0. + // Default false. For structure that SITS near the outflow; leave it off + // when a front crosses -- a transit over-vents (NSCBC-FlameOutflow/ + // README.md). pin_farfield ignores it. + bool extrap_material = false; + + // Treat FIRM reversal at an outflow as a local inflow of reservoir gas: + // under sustained backflow the lambda_0 ghost content ramps from the + // frozen interior values (the transient-breathing closure) to the ambient + // state the face's Target carries (tgt.T, tgt.Y, tangential tgt.u), over + // an outward-Mach band [1e-3, 1e-2] -- continuous with w_mat's band below + // it, fully reservoir by Mach 0.01. Without this, a face in sustained + // recirculation feeds the domain its own exhaust: a hot domain drawing + // from a cold reservoir never cools (driver C14, the flush test). Only + // active when the Target supplies a physical state (tgt.T > 0); breathing + // reversals (chamber ring-down, radial rarefaction: |M| << 1e-3 at the + // face) never leave the frozen closure. Default false. + bool backflow_material = false; + // Boundary-normal reference length, probhi[idir] - problo[idir]. Filled per // face by the caller. Only the ratio sigma/L_ref is physical, so this is // not a user knob; it exists so that sigma keeps its literature meaning. @@ -139,6 +199,10 @@ struct Params why = "bc_nscbc_relax_t must be >= 0"; return false; } + if (beta_s > 1.0) { + why = "bc_nscbc_beta_s must be <= 1"; + return false; + } if (beta > 1.0) { why = "bc_nscbc_beta must be <= 1 (1 discards the transverse terms, 0 " "includes them fully, negative selects the local Mach number)"; @@ -331,6 +395,84 @@ cell_primitives(const amrex::Real s[NVAR]) noexcept return q; } +// --------------------------------------------------------------------------- +// reaction_dpdt -- dp/dt from chemistry alone at fixed (rho, e). Chemistry +// conserves both, so the effect is purely compositional: +// dp/dt = sum_k (dp/dY_k)|_{rho,e} wdot_k / rho +// = Ru T sum_k wdot_k/W_k - p/(rho T c_v) sum_k e_k wdot_k +// (ideal-gas closed form, gated by C7). SRK/manifold lack the composition +// derivative, so there a single directional finite difference along the +// reaction path is used (sum wdot = 0 keeps sum Y = 1 along it). +// --------------------------------------------------------------------------- +AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE amrex::Real +reaction_dpdt(CellPrim const& q, bool& ok) noexcept +{ + auto eos = pele::physics::PhysicsType::eos(); + ok = true; + + amrex::Real wdot[NUM_SPECIES] = {0.0}; // GCC -Wmaybe-uninitialized at -O: + // it cannot see RTY2WDOT fill it + eos.RTY2WDOT(q.rho, q.T, q.Y, wdot); + amrex::Real wmax = 0.0; + for (const amrex::Real wn : wdot) { + if (!amrex::Math::isfinite(wn)) { + ok = false; + return 0.0; + } + wmax = amrex::max(wmax, std::abs(wn)); + } + if (wmax <= 0.0) { + return 0.0; // frozen chemistry here; nothing to correct + } + +#if defined(USE_SRK_EOS) || defined(USE_MANIFOLD_EOS) + // Directional finite difference along the reaction path. + const amrex::Real dY_scale = wmax / q.rho; // [1/s] + const amrex::Real tau = 1.0e-6 / dY_scale; // gives max |dY| ~ 1e-6 + amrex::Real Yp[NUM_SPECIES], sum = 0.0; + for (int n = 0; n < NUM_SPECIES; n++) { + Yp[n] = amrex::max(q.Y[n] + tau * wdot[n] / q.rho, 0.0); + sum += Yp[n]; + } + if (!(sum > 0.0)) { + ok = false; + return 0.0; + } + for (amrex::Real& yp : Yp) { + yp /= sum; + } + amrex::Real Tp = q.T, pp = 0.0; + eos.REY2T(q.rho, q.e, Yp, Tp); + eos.RTY2P(q.rho, Tp, Yp, pp); + if (!amrex::Math::isfinite(pp) || !amrex::Math::isfinite(Tp) || !(Tp > 0.0)) { + ok = false; + return 0.0; + } + return (pp - q.p) / tau; +#else + amrex::Real imw[NUM_SPECIES], ei[NUM_SPECIES], cv = 0.0; + eos.inv_molecular_weight(imw); + eos.RTY2Ei(q.rho, q.T, q.Y, ei); + eos.RTY2Cv(q.rho, q.T, q.Y, cv); + if (!amrex::Math::isfinite(cv) || !(cv > 0.0)) { + ok = false; + return 0.0; + } + amrex::Real mole_term = 0.0, heat_term = 0.0; + for (int n = 0; n < NUM_SPECIES; n++) { + mole_term += wdot[n] * imw[n]; + heat_term += ei[n] * wdot[n]; + } + const amrex::Real dpdt = pele::physics::Constants::RU * q.T * mole_term - + q.p * heat_term / (q.rho * q.T * cv); + if (!amrex::Math::isfinite(dpdt)) { + ok = false; + return 0.0; + } + return dpdt; +#endif +} + // --------------------------------------------------------------------------- // Pack a set of ghost primitives into a conserved state. // @@ -630,7 +772,43 @@ apply( } } - const amrex::Real dRm = 0.0; + // Entropy-family slope at the face: measured for two purposes below. dS + // carries no acoustic content and is an OUTGOING family at an outflow, so + // nothing the incoming model consumes here can feed on the waves it + // launches. Measured on the same stencil, in the same frozen-impedance + // variables, and with the same limiter as R_+. + amrex::Real dRm = 0.0; + if (outflow_face && linear && qNm1.ok && qNm2.ok) { + const amrex::Real inv_c2 = 1.0 / (c_N * c_N); + const amrex::Real S_N = qN.rho - qN.p * inv_c2; + const amrex::Real S_Nm1 = qNm1.rho - qNm1.p * inv_c2; + const amrex::Real S_Nm2 = qNm2.rho - qNm2.p * inv_c2; + const amrex::Real dS = minmod(S_N - S_Nm1, S_Nm1 - S_Nm2); + + // The transit guard: count material structure in the outflow boundary + // cell so nscbc_report_diagnostics() can advise the flame closures + // (extrap_temperature, beta_s). 5% of rho per cell is well above + // acoustic or roundoff content and well below any front. + if ((layer == 1) && (std::abs(dS) > 0.05 * qN.rho)) { + bump(Diag::structure); + } + + // Material-slope continuation of R_- (see Params::extrap_material): the + // measured slope of R_- is used only within the bound the entropy family + // supplies through steady continuity and the momentum-flux pressure + // gradient. + if (prm.extrap_material) { + const amrex::Real Rm_Nm1 = n_sgn * qNm1.u[idir] - qNm1.p / rho_c; + const amrex::Real Rm_Nm2 = n_sgn * qNm2.u[idir] - qNm2.p / rho_c; + const amrex::Real dRm_meas = minmod(Rm_N - Rm_Nm1, Rm_Nm1 - Rm_Nm2); + const amrex::Real du_mat = -u_out_N * dS / (qN.rho * one_m_M2); + dRm = w_mat * minmod(dRm_meas, du_mat * (1.0 + mach)); + if (!amrex::Math::isfinite(dRm)) { + dRm = 0.0; + bump(Diag::floored); + } + } + } amrex::Real Rm_g; if (outflow_face && prm.pin_farfield) { @@ -657,6 +835,15 @@ apply( L_in += 2.0 * rho_c * (n_sgn * tgt.dudt); } L_in -= (1.0 - beta) * T_in; + if (prm.beta_s < 1.0) { + bool src_ok = true; + const amrex::Real S_p = reaction_dpdt(qN, src_ok); + if (src_ok) { + L_in += (1.0 - prm.beta_s) * S_p; + } else { + bump(Diag::source_drop); + } + } // There is deliberately NO diffusive source term on L_in. The // ghost-cell form does not have the flux-form's viscous-condition gap: // the diffusion operator reads these ghost cells, so a correct ghost @@ -723,6 +910,12 @@ apply( const amrex::Real S_g = S_N + fl * dS; rho_g = S_g + p_g * inv_c2; + // Temperature closure: T on the same limited slope; rho from the EOS. + amrex::Real dT = 0.0; + if (prm.extrap_temperature && linear && qNm1.ok && qNm2.ok) { + dT = w_mat * minmod(qN.T - qNm1.T, qNm1.T - qNm2.T); + } + amrex::Real Ysum = 0.0; for (int n = 0; n < NUM_SPECIES; n++) { Y_g[n] = amrex::max(qN.Y[n] + fl * dY[n], 0.0); @@ -746,15 +939,66 @@ apply( } } - const amrex::Real rho_floor = - amrex::max(1.0e-6 * qN.rho, constants::very_small_num()); - if (!amrex::Math::isfinite(rho_g) || (rho_g < rho_floor)) { - rho_g = rho_floor; - bump(Diag::floored); + if (prm.extrap_temperature) { + T_g = qN.T + fl * dT; + // A temperature floor rather than a density one: rho is the derived + // quantity in this closure. + const amrex::Real T_floor = + amrex::max(1.0e-3 * qN.T, constants::very_small_num()); + if (!amrex::Math::isfinite(T_g) || (T_g < T_floor)) { + T_g = T_floor; + bump(Diag::floored); + } + eos.PYT2RE(p_g, Y_g, T_g, rho_g, e_g); + } else { + const amrex::Real rho_floor = + amrex::max(1.0e-6 * qN.rho, constants::very_small_num()); + if (!amrex::Math::isfinite(rho_g) || (rho_g < rho_floor)) { + rho_g = rho_floor; + bump(Diag::floored); + } + eos.RYP2T(rho_g, Y_g, p_g, T_g); + eos.RTY2E(rho_g, T_g, Y_g, e_g); } - eos.RYP2T(rho_g, Y_g, p_g, T_g); - eos.RTY2E(rho_g, T_g, Y_g, e_g); + // Sustained-backflow material (see Params::backflow_material): under + // firm reversal the lambda_0 content ramps from the frozen interior + // values just computed to the reservoir state the Target carries. The + // ramp starts where w_mat's band ends (outward Mach -1e-3) and is + // complete by Mach -1e-2, so breathing reversals never feel it, and the + // acoustic side is untouched -- p_g stays the relaxation's. + if (prm.backflow_material && (u_out_N < 0.0) && (tgt.T > 0.0)) { + const amrex::Real m_lo = 1.0e-3, m_hi = 1.0e-2; + const amrex::Real w_rev = amrex::min( + amrex::max((-mach - m_lo) / (m_hi - m_lo), 0.0), 1.0); + if (w_rev > 0.0) { + amrex::Real Ysum_rev = 0.0; + for (int n = 0; n < NUM_SPECIES; n++) { + Y_g[n] = (1.0 - w_rev) * Y_g[n] + + w_rev * amrex::max(tgt.Y[n], 0.0); + Ysum_rev += Y_g[n]; + } + if (Ysum_rev > constants::very_small_num()) { + const amrex::Real inv = 1.0 / Ysum_rev; + for (amrex::Real& yg : Y_g) { + yg *= inv; + } + } + T_g = (1.0 - w_rev) * T_g + w_rev * tgt.T; + for (int d = 0; d < 3; d++) { + if (d != idir) { + u_g[d] = (1.0 - w_rev) * u_g[d] + w_rev * tgt.u[d]; + } + } + const amrex::Real T_floor = + amrex::max(1.0e-3 * qN.T, constants::very_small_num()); + if (!amrex::Math::isfinite(T_g) || (T_g < T_floor)) { + T_g = T_floor; + bump(Diag::floored); + } + eos.PYT2RE(p_g, Y_g, T_g, rho_g, e_g); + } + } } else { // Inflow. One dimensionless nudge factor shared by temperature and the // tangential velocities -- both ride lambda_0, magnitude |u_out|: diff --git a/Source/Params/_cpp_parameters b/Source/Params/_cpp_parameters index 3c1397804..482dfd5dc 100644 --- a/Source/Params/_cpp_parameters +++ b/Source/Params/_cpp_parameters @@ -116,6 +116,22 @@ bc_nscbc_relax_t Real 0.2 # because a wrong beta is far more dangerous than an absent one. Must be <= 1. bc_nscbc_beta Real 1.0 +# Weight on the REACTION source term in the modelled incoming acoustic, same +# convention as bc_nscbc_beta: 1 discards it (default), 0 includes it fully. +# +# A flame near an outflow raises the pressure in the boundary cell at a rate +# the 1-D relaxation has no model for, so sigma has to absorb it and the mean +# pressure sits off target. This supplies the missing dp/dt. Only worth +# enabling when the reaction zone is close enough to the boundary to matter -- +# the correct first answer to that situation is to move the boundary. +# +# Chemistry conserves mass, and PeleC's constant-volume reactor conserves total +# internal energy because the formation enthalpies live inside e, so the entire +# effect is compositional: dp/dY|_{rho,e}. Exact in closed form for ideal-gas +# EOS; a directional finite difference along the reaction path is used for SRK. +# Must be <= 1. +bc_nscbc_beta_s Real 1.0 + # Order of the outgoing-invariant extrapolation, 1 or 2. Verification and # debugging knob, not a physics knob; leave at 2. bc_nscbc_order int 2 @@ -129,6 +145,39 @@ bc_nscbc_order int 2 # pressure is not the target. bc_nscbc_sigma is ignored when this is set. bc_nscbc_pin_farfield bool false +# Close the lambda_0 family at an outflow on TEMPERATURE rather than on the +# linearised entropy invariant. Matters because PeleC's diffusion operator +# forms the boundary-face conductive and species fluxes from the NSCBC ghost +# cells, and with the entropy closure the ghost temperature is a by-product of +# the acoustic algebra: check C8 in Verification/NSCBC1D measures a 40% +# overstatement of the face temperature gradient on a flame-like ramp. Turn it +# on when a thermal or compositional structure is near the boundary. Default +# false because it changes every outflow result. +bc_nscbc_extrap_temperature bool false + +# Continue the material (non-acoustic) part of the incoming invariant R_- into +# the ghost at an outflow, in addition to the relaxation increment. The +# material slope is bounded through the entropy family (outgoing, carries no +# acoustics), which cancels the ghost-pressure bias of check C9(a) in +# Verification/NSCBC1D without touching reflection, anchoring or the +# relaxation rate. For a front SITTING on the outflow it is worth 5% alone +# and 42% together with bc_nscbc_extrap_temperature; for a front PASSING OUT +# through the face it is a liability -- at small sigma the quasi-steady model +# turns the crossing into a runaway (see NSCBC-FlameOutflow/README.md). Keep +# sigma > 0 with this on. Default false. Ignored by pin_farfield. +bc_nscbc_extrap_material bool false + +# Treat FIRM reversal at an outflow as a local inflow of reservoir gas: under +# sustained backflow the lambda_0 ghost content ramps from the frozen interior +# values to the ambient state the face's bcnormal_nscbc Target carries (T, Y, +# tangential u), over an outward-Mach band [1e-3, 1e-2]. Without it, a face +# held in recirculation feeds the domain its own exhaust (driver check C14, +# the flush test); with it, the entering gas carries the reservoir state. +# Breathing reversals (|M| << 1e-3 at the face) never leave the frozen +# closure either way. Requires the problem's outflow Target to supply T > 0 +# (and Y); inert otherwise. Default false. +bc_nscbc_backflow_material bool false + # NOTE: pelec.nscbc_adv and pelec.nscbc_diff were removed here. They were # declared and queried but never read by any live code path -- the diff --git a/Source/Params/param_includes/pelec_defaults.H b/Source/Params/param_includes/pelec_defaults.H index efa1a41fc..b1b8e7331 100644 --- a/Source/Params/param_includes/pelec_defaults.H +++ b/Source/Params/param_includes/pelec_defaults.H @@ -23,8 +23,12 @@ amrex::Real PeleC::bc_nscbc_sigma = 0.25; amrex::Real PeleC::bc_nscbc_relax_u = 2.0; amrex::Real PeleC::bc_nscbc_relax_t = 0.2; amrex::Real PeleC::bc_nscbc_beta = 1.0; +amrex::Real PeleC::bc_nscbc_beta_s = 1.0; int PeleC::bc_nscbc_order = 2; bool PeleC::bc_nscbc_pin_farfield = false; +bool PeleC::bc_nscbc_extrap_temperature = false; +bool PeleC::bc_nscbc_extrap_material = false; +bool PeleC::bc_nscbc_backflow_material = false; bool PeleC::add_ext_src = false; amrex::GpuArray PeleC::external_forcing = {0.0}; bool PeleC::add_forcing_src = false; diff --git a/Source/Params/param_includes/pelec_params.H b/Source/Params/param_includes/pelec_params.H index c5c6f80e7..338fe2a2d 100644 --- a/Source/Params/param_includes/pelec_params.H +++ b/Source/Params/param_includes/pelec_params.H @@ -23,8 +23,12 @@ static amrex::Real bc_nscbc_sigma; static amrex::Real bc_nscbc_relax_u; static amrex::Real bc_nscbc_relax_t; static amrex::Real bc_nscbc_beta; +static amrex::Real bc_nscbc_beta_s; static int bc_nscbc_order; static bool bc_nscbc_pin_farfield; +static bool bc_nscbc_extrap_temperature; +static bool bc_nscbc_extrap_material; +static bool bc_nscbc_backflow_material; static bool add_ext_src; static amrex::GpuArray external_forcing; static bool add_forcing_src; diff --git a/Source/Params/param_includes/pelec_queries.H b/Source/Params/param_includes/pelec_queries.H index b381f61d0..8884f62a0 100644 --- a/Source/Params/param_includes/pelec_queries.H +++ b/Source/Params/param_includes/pelec_queries.H @@ -23,8 +23,12 @@ pp.query("bc_nscbc_sigma", bc_nscbc_sigma); pp.query("bc_nscbc_relax_u", bc_nscbc_relax_u); pp.query("bc_nscbc_relax_t", bc_nscbc_relax_t); pp.query("bc_nscbc_beta", bc_nscbc_beta); +pp.query("bc_nscbc_beta_s", bc_nscbc_beta_s); pp.query("bc_nscbc_order", bc_nscbc_order); pp.query("bc_nscbc_pin_farfield", bc_nscbc_pin_farfield); +pp.query("bc_nscbc_extrap_temperature", bc_nscbc_extrap_temperature); +pp.query("bc_nscbc_extrap_material", bc_nscbc_extrap_material); +pp.query("bc_nscbc_backflow_material", bc_nscbc_backflow_material); pp.query("add_ext_src", add_ext_src); { amrex::Vector tmp(AMREX_SPACEDIM, 0.0); From 4ef6f9647000d84fab32b22809f2c4dc767a90b1 Mon Sep 17 00:00:00 2001 From: Marc Day Date: Thu, 27 Aug 2026 22:17:02 +0200 Subject: [PATCH 4/4] NSCBC 4/7: the 1-D verification driver and field-measurement tools Verification/NSCBC1D is a standalone 1-D compressible solver (needs only a 1-D AMReX) that compiles Source/NSCBC.H unmodified and runs it against analytic and constructed references: checks C1-C14, self-gated, covering uniform-state transparency, pulse reflection vs sigma, inflow injection, the flame-on-boundary closures (sitting and in transit), the reaction-source sign (a convention-free check), extrapolation-bias bounds, profile-fit closures, the unified reversal closure, and sustained-backflow flushing. Four builds gate different physics: air, LiDryer (reacting), two passive scalars, and SRK real gas. Beyond the checks: a sigma-reflection sweep, forced-duct modes measuring what a value-relaxation inlet does to injected signals, and the register/ feed-forward gates for the stateful inlet/outlet repairs arriving later in this stack (the driver holds its own registers, so those checks are self-contained here). Verification/NSCBCFields adds fielddump (flatten one plotfile variable at full precision) and metrics.py (residual, circularity, sphericity) for measuring PeleC fields against the driver's predictions. CI gains an NSCBC-Driver job: builds a 1-D AMReX and runs all four driver configurations green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XU8M23nucKsu1do2WxXFeq --- .github/workflows/ci.yml | 45 + Verification/NSCBC1D/.gitignore | 3 + Verification/NSCBC1D/CMakeLists.txt | 56 + Verification/NSCBC1D/GNUmakefile | 84 + Verification/NSCBC1D/README.md | 342 ++ Verification/NSCBC1D/nscbc1d.cpp | 3689 +++++++++++++++++++++ Verification/NSCBC1D/source_sign_check.py | 72 + Verification/NSCBCFields/.gitignore | 4 + Verification/NSCBCFields/CMakeLists.txt | 26 + Verification/NSCBCFields/GNUmakefile | 42 + Verification/NSCBCFields/fielddump.cpp | 130 + Verification/NSCBCFields/metrics.py | 266 ++ 12 files changed, 4759 insertions(+) create mode 100644 Verification/NSCBC1D/.gitignore create mode 100644 Verification/NSCBC1D/CMakeLists.txt create mode 100644 Verification/NSCBC1D/GNUmakefile create mode 100644 Verification/NSCBC1D/README.md create mode 100644 Verification/NSCBC1D/nscbc1d.cpp create mode 100644 Verification/NSCBC1D/source_sign_check.py create mode 100644 Verification/NSCBCFields/.gitignore create mode 100644 Verification/NSCBCFields/CMakeLists.txt create mode 100644 Verification/NSCBCFields/GNUmakefile create mode 100644 Verification/NSCBCFields/fielddump.cpp create mode 100644 Verification/NSCBCFields/metrics.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1646294e1..6ac3dff07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -268,6 +268,51 @@ jobs: cat warnings.txt export return=$(tail -n 1 warnings.txt | awk '{print $2}') exit ${return} + NSCBC-Driver: + needs: Formatting + runs-on: ubuntu-24.04 + steps: + - name: Clone + uses: actions/checkout@v6 + with: + submodules: 'recursive' + - name: Build AMReX 1D + run: | + cmake -S Submodules/PelePhysics/Submodules/amrex -B ${{runner.temp}}/amrex1d-build \ + -DCMAKE_INSTALL_PREFIX=${{runner.temp}}/amrex1d \ + -DAMReX_SPACEDIM=1 -DAMReX_MPI=OFF -DAMReX_OMP=OFF -DAMReX_FORTRAN=OFF \ + -DAMReX_PARTICLES=OFF -DAMReX_EB=OFF -DAMReX_PLOTFILE_TOOLS=OFF \ + -DCMAKE_BUILD_TYPE=Release + cmake --build ${{runner.temp}}/amrex1d-build -j $(nproc) --target install + - name: Driver, air + working-directory: ./Verification/NSCBC1D + run: | + cmake -S . -B build_air -DAMReX_DIR=${{runner.temp}}/amrex1d/lib/cmake/AMReX \ + -DCMAKE_BUILD_TYPE=Release + cmake --build build_air -j $(nproc) + ./build_air/nscbc1d + - name: Driver, LiDryer (reacting) + working-directory: ./Verification/NSCBC1D + run: | + cmake -S . -B build_lidryer -DAMReX_DIR=${{runner.temp}}/amrex1d/lib/cmake/AMReX \ + -DPELE_MECHANISM=LiDryer -DCMAKE_BUILD_TYPE=Release + cmake --build build_lidryer -j $(nproc) + ./build_lidryer/nscbc1d + - name: Driver, passive scalars + working-directory: ./Verification/NSCBC1D + run: | + cmake -S . -B build_adv -DAMReX_DIR=${{runner.temp}}/amrex1d/lib/cmake/AMReX \ + -DPELE_NUM_ADV=2 -DCMAKE_BUILD_TYPE=Release + cmake --build build_adv -j $(nproc) + ./build_adv/nscbc1d + - name: Driver, SRK real gas (static checks) + working-directory: ./Verification/NSCBC1D + run: | + cmake -S . -B build_srk -DAMReX_DIR=${{runner.temp}}/amrex1d/lib/cmake/AMReX \ + -DPELE_MECHANISM=LiDryer -DPELE_EOS=SRK -DCMAKE_BUILD_TYPE=Release + cmake --build build_srk -j $(nproc) + ./build_srk/nscbc1d + CPU-CMake: needs: Formatting runs-on: ${{matrix.os}} diff --git a/Verification/NSCBC1D/.gitignore b/Verification/NSCBC1D/.gitignore new file mode 100644 index 000000000..4ee117153 --- /dev/null +++ b/Verification/NSCBC1D/.gitignore @@ -0,0 +1,3 @@ +*.ex +tmp_build_dir/ +build*/ diff --git a/Verification/NSCBC1D/CMakeLists.txt b/Verification/NSCBC1D/CMakeLists.txt new file mode 100644 index 000000000..3c4622bd0 --- /dev/null +++ b/Verification/NSCBC1D/CMakeLists.txt @@ -0,0 +1,56 @@ +# Standalone 1-D verification driver for Source/NSCBC.H. +# +# Deliberately independent of the PeleC application build: it compiles the +# production boundary-condition header against a minimal 1-D solver so that a +# failure can be attributed to the boundary condition rather than to the AMReX +# plumbing around it. See the header comment in nscbc1d.cpp. +# +# cmake -S . -B build -DAMReX_DIR=/lib/cmake/AMReX \ +# -DPELEC_DIR=../.. -DPELE_MECHANISM=air +# cmake --build build && ./build/nscbc1d sweep +cmake_minimum_required(VERSION 3.20) +project(nscbc1d CXX) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(PELEC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "PeleC root") +set(PELE_MECHANISM "air" CACHE STRING "PelePhysics mechanism") +# Fuego (ideal gas, the default) or SRK (real gas): the kernel restricts +# itself to density-carrying EOS entry points precisely so that it compiles +# and runs under SRK, and this switch is how that claim is checked. +set(PELE_EOS "Fuego" CACHE STRING "EOS model: Fuego or SRK") +# Passive advected scalars ride the lambda_0 family through pack_ghost; +# a build with PELE_NUM_ADV > 0 is how that path is compiled and checked. +set(PELE_NUM_ADV 0 CACHE STRING "Number of passive advected scalars") +find_package(AMReX REQUIRED) + +set(PP "${PELEC_DIR}/Submodules/PelePhysics") +add_executable(nscbc1d + nscbc1d.cpp + ${PP}/Mechanisms/${PELE_MECHANISM}/mechanism.cpp) +target_include_directories(nscbc1d PRIVATE + ${PELEC_DIR}/Source + ${PP}/Source + ${PP}/Source/Eos + ${PP}/Source/Transport + ${PP}/Source/Utility + ${PP}/Source/Utility/Utilities + ${PP}/Source/Utility/Filter + ${PP}/Source/Utility/BlackBoxFunction + ${PP}/Source/Utility/PMF + ${PP}/Source/Utility/TurbInflow + ${PP}/Source/Utility/Diagnostics + ${PP}/Mechanisms/${PELE_MECHANISM}) +if (PELE_EOS STREQUAL "SRK") + # SRK forbids constant transport (PelePhysicsConstraints.H); pair it with + # Simple. Nothing in the driver reads transport coefficients -- the C12 + # conduction lives in the mini solver -- so the choice only has to compile. + set(PELE_EOS_DEF USE_SRK_EOS) + set(PELE_TRANS_DEF USE_SIMPLE_TRANSPORT) +else () + set(PELE_EOS_DEF USE_FUEGO_EOS) + set(PELE_TRANS_DEF USE_CONSTANT_TRANSPORT) +endif () +target_compile_definitions(nscbc1d PRIVATE + ${PELE_EOS_DEF} ${PELE_TRANS_DEF} NUM_ADV=${PELE_NUM_ADV} NUM_AUX=0) +target_link_libraries(nscbc1d PRIVATE AMReX::amrex_1d) diff --git a/Verification/NSCBC1D/GNUmakefile b/Verification/NSCBC1D/GNUmakefile new file mode 100644 index 000000000..7f53398c4 --- /dev/null +++ b/Verification/NSCBC1D/GNUmakefile @@ -0,0 +1,84 @@ +# GNUmake build for the standalone 1-D NSCBC driver, for those not using CMake. +# +# The CMakeLists.txt alongside this file needs an INSTALLED AMReX +# (-DAMReX_DIR=/lib/cmake/AMReX). If what you have is a source tree -- +# a PelePhysics submodule checkout, say -- use this instead: +# +# make -j # air, 2 species: 22 checks +# make Chemistry_Model=LiDryer -j # a reacting mechanism: 26 checks, +# # C7 (the reaction source) included +# +# AMREX_HOME may come from the environment, which is the usual thing when the +# amrex you build against lives under another Pele repository: +# +# export AMREX_HOME=$HOME/src/PeleLMeX/Submodules/PelePhysics/Submodules/amrex +# make -j && ./nscbc1d.gnu.ex +# +# `./nscbc1d.gnu.ex sweep` also dumps the sigma table. +# +# Like the CMake build this compiles exactly two translation units: the driver +# and the mechanism. PelePhysics' EOS is header-only at this level, so none of +# its .cpp files are needed and none of its heavier dependencies are pulled in. +# +# This compiles Source/NSCBC.H UNMODIFIED -- that is the whole point of the +# driver, so do not be tempted to keep a local copy of the kernel here. + +PELE_HOME ?= ../.. +PELE_PHYSICS_HOME ?= $(PELE_HOME)/Submodules/PelePhysics +AMREX_HOME ?= $(PELE_PHYSICS_HOME)/Submodules/amrex + +DEBUG = FALSE +DIM ?= 1 +COMP ?= gnu +PRECISION = DOUBLE +USE_MPI = FALSE +USE_OMP = FALSE +USE_CUDA = FALSE + +BL_NO_FORT = TRUE + +Chemistry_Model ?= air +PELE_EOS ?= Fuego + +programs += nscbc1d + +include $(AMREX_HOME)/Tools/GNUMake/Make.defs + +multiple_executables = $(addsuffix .$(machineSuffix).ex, $(programs)) +default: $(multiple_executables) + +include $(AMREX_HOME)/Src/Base/Make.package + +CEXE_sources += mechanism.cpp + +PP := $(PELE_PHYSICS_HOME) +PP_DIRS := $(PP)/Source \ + $(PP)/Source/Eos \ + $(PP)/Source/Transport \ + $(PP)/Source/Utility \ + $(PP)/Source/Utility/Utilities \ + $(PP)/Source/Utility/Filter \ + $(PP)/Source/Utility/BlackBoxFunction \ + $(PP)/Source/Utility/PMF \ + $(PP)/Source/Utility/TurbInflow \ + $(PP)/Source/Utility/Diagnostics \ + $(PP)/Mechanisms/$(Chemistry_Model) + +# PeleC's Source/ carries NSCBC.H, IndexDefines.H and Constants.H. +VPATH_LOCATIONS += . $(PELE_HOME)/Source $(PP_DIRS) +INCLUDE_LOCATIONS += . $(PELE_HOME)/Source $(PP_DIRS) + +# The same set the CMake build defines: no advected or auxiliary variables. +# SRK forbids constant transport (PelePhysicsConstraints.H); pair it with +# simple transport, as the CMake build does. +ifeq ($(PELE_EOS),SRK) +DEFINES += -DUSE_SRK_EOS -DUSE_SIMPLE_TRANSPORT +else +DEFINES += -DUSE_FUEGO_EOS -DUSE_CONSTANT_TRANSPORT +endif +DEFINES += -DNUM_ADV=0 -DNUM_AUX=0 + +include $(AMREX_HOME)/Tools/GNUMake/Make.rules + +clean:: + $(SILENT) $(RM) $(multiple_executables) diff --git a/Verification/NSCBC1D/README.md b/Verification/NSCBC1D/README.md new file mode 100644 index 000000000..04229cd86 --- /dev/null +++ b/Verification/NSCBC1D/README.md @@ -0,0 +1,342 @@ +# nscbc1d — standalone verification of `Source/NSCBC.H` + +This driver compiles the production NSCBC kernel **unmodified** against a +minimal 1-D finite-volume Euler solver. Its purpose is to separate two failure +modes that are otherwise indistinguishable in a full PeleC run: + +> *"the boundary condition is wrong"* vs *"the AMReX plumbing around it is wrong"* + +It is cheap enough to sweep parameters, so it also produces the reference curves +that the AMReX-side regression tests are checked against. + +## Build and run + +```sh +cmake -S . -B build -DAMReX_DIR=/lib/cmake/AMReX +cmake --build build +./build/nscbc1d # run all checks +./build/nscbc1d sweep # also dump the sigma sweep +``` + +Any AMReX build with `AMReX_SPACEDIM=1` works; MPI, OpenMP, EB and particles are +all unnecessary. The default mechanism is `air` (2 species, Fuego); override +with `-DPELE_MECHANISM=`. Build it a second time against a reacting mechanism +to exercise check C7, which is skipped otherwise: + +```sh +cmake -S . -B build_lidryer -DAMReX_DIR=... -DPELE_MECHANISM=LiDryer +cmake --build build_lidryer && ./build_lidryer/nscbc1d # 65/65 (air: 61/61) +``` + +Two further build axes, both run in CI (the `NSCBC-Driver` job): + +```sh +cmake -S . -B build_adv -DAMReX_DIR=... -DPELE_NUM_ADV=2 # 61/61: pack_ghost's +cmake --build build_adv && ./build_adv/nscbc1d # passive-scalar path +cmake -S . -B build_srk -DAMReX_DIR=... -DPELE_MECHANISM=LiDryer -DPELE_EOS=SRK +cmake --build build_srk && ./build_srk/nscbc1d # 45/45 static checks +``` + +The SRK build is the EOS-portability claim made checkable: the kernel keeps +to density-carrying EOS entry points precisely so it runs under a real gas. +Under SRK the dynamic checks (C4/C5/C9b/C10/C11/C12) are skipped — they +integrate the mini solver for thousands of steps, every one paying several +Newton solves per cell, to re-verify algebra that is EOS-independent and +already gated under Fuego — and two gates change meaning: C1's tolerance sits +at the Newton round-trip floor (~1e-11) rather than machine epsilon, and C7 +gates FD-vs-FD agreement instead of FD-vs-closed-form convergence, because +under SRK the kernel path *is* the finite difference. + +Nothing in the driver may assume a particular mechanism. The first version of +`air_Y()` returned "0.233 for O2, else 0.767", which is right for the +two-species `air` mechanism and gives a composition summing to 6.6 for +LiDryer's nine — every check downstream then failed for reasons having nothing +to do with the boundary condition. + +## Units + +PeleC and PelePhysics work in **CGS**: cm, g, s, K, dyn/cm², erg/g. +`Constants::PATM = 1.01325e6`. The first version of this driver was written in +SI and every sound speed was 100× too large, which silently turned the +supersonic-outflow check into a subsonic one — that is exactly the class of +error this driver exists to catch, and it is why the checks assert on physical +relationships rather than on hard-coded numbers. + +## What is checked + +| | Check | What a failure means | +|---|---|---| +| **C1** | A uniform state is reproduced exactly in every ghost layer, at every σ, for both inflow and outflow | The kernel is manufacturing a gradient out of nothing; everything downstream is meaningless | +| **C2** | Every relaxation moves the boundary *toward* its target, on both the lo and hi face | A sign error. This is the assertion the legacy Fortran lacked, and its absence is why users had to be told "`relax_T` must be negative" | +| **C3** | Outflow *extrapolates* composition rather than imposing it; inflow imposes it exactly; `Σ Y = 1` and `UEDEN = UEINT + KE` hold to round-off | Species over-specification at outflow (the legacy defect), or a broken state identity | +| **C4** | Acoustic reflection of a pressure pulse: below 1% at σ=0.25, essentially zero at σ=0, and 2nd-order extrapolation no worse than 1st | The extrapolation or the invariant algebra is wrong | +| **C5** | The relaxation is a **rate**: grid-independent, and equal to `K = σ(1−M²)c/L` | The parameterisation has drifted to a value-blend, whose effective rate scales as `c/Δx` and therefore doubles when the mesh does | +| **C6** | Supersonic, reversed-flow and EB-body-state fallbacks each return a finite physical state and increment their counter; a supersonic INFLOW without `Target.p` is a counted substitution (`target_incomplete`) and with it an exact full-state imposition | A silent fallback — i.e. a bug that will not be found in production; or a supersonic inflow quietly borrowing pressure from a domain it should be causally upstream of | +| **C7** | The closed-form reaction source `dp/dt|_react` matches a directional finite difference along the reaction path, converging as τ→0; chemistry conserves mass; a cold state gives zero | The thermodynamics of `reaction_dpdt()` is wrong. Skipped automatically when the mechanism has no reactions | +| **C8** | On a flame-like temperature ramp the entropy closure's ghost overstates the face temperature gradient by 40%; `extrap_temperature` reproduces the ramp exactly and still returns a uniform state to round-off | The diffusion operator reads these ghosts, so this is the conductive heat flux leaving the domain being wrong | +| **C9** | (a) Extrapolating `R₊` across a normal velocity gradient manufactures ghost pressure `½ρc·ℓ·δu`, exactly, and order 1 gives exactly zero. With `extrap_material`, on the mass-conserving form of the same ramp, the bias vanishes while the ghost keeps the full `du/dn`. (b) A heat band at the boundary produces a σ-suppressed offset that the order control shows is *not* the extrapolation — reported, not gated | The ghost-pressure bias mechanism, isolated. (b) failing to isolate it dynamically is why C10 and C11 exist | +| **C10** | The source-free ramp: mass/momentum-consistent, no sustainer. Its own negative result — a source-free expansion cannot persist in a duct — plus a reported row showing `extrap_material` holds a *decaying* ramp alive at the face, which is its known cost | Nothing; the gated content moved to C11 | +| **C11** | The sustained ramp: C10's structure plus the manufactured energy source `S_E = ṁ dH/dx` that makes it an exact steady solution straddling the outflow — a flame's mechanical structure minus the chemistry. An *oracle* ghost fill (exact continuation) holds it, so the architecture is sound; the entropy closure drifts 15707 dyn/cm² in 0.7 relaxation times and distorts the face `du/dn` to 175% of exact; `extrap_material` holds those to 3175 and 80%, and cuts the static face-flux error 3.3× | The material-slope continuation is broken, or the late-time columns are being read without their caveat: the frozen source cannot follow a structure the boundary lets slip, so late-time drift is the MMS's artefact, not the boundary's | +| **C12** | With real conduction in the mini solver and a hot flank in the outflow cells, against a shielded reference: the entropy closure leaks 887 dyn/cm² of boundary error, `extrap_temperature` holds it to 104 | The diffusive boundary physics lives in the ghost **T closure**, not in the wave model — an amplitude-side diffusion source term was built, verified exact on quadratic profiles, measured to double-count (104 → −911 here; +1200 → +1771 in PeleC), and removed | +| **C13** | (a) The outflow closure is a *continuous* function of the interior normal velocity through `u_out = 0` — a swept flame-like stencil shows no outlier jump in ghost p, u or T at the crossing; (b) an over-pressured transient reversal still relaxes: ghost p toward target AND ghost `u_out` pushed outward; (c) a firm reversal does NOT extrapolate material content: with T falling toward the face, the ghost keeps the interior's own T | The reversal handling has drifted from either of its two proven properties. The 2026-08 NSCBC-Chamber production A/B (`Docs/NSCBC-reversal-branch-defect.md`) caught both failure modes in sequence: a dedicated reversal branch that dropped `dR₊`, `S_p` and `T_in` and froze the ghost velocity put a 4.2e3 dyn/cm² step at `u_out = 0` (vs 1.2e-2 sweep variation, fails a/b) and fed a growing dither and a spurious 0.3 atm spike; unifying the closure *without* upwinding the material slopes (fails c, ghost T 341.5 vs 400 K) instead fed the cold runaway — extrapolated outward-cooling ghosts advected back in, 385 → 89 K in 42 µs, NaN. The shipped closure passes all three: unified acoustics, `w_mat`-upwinded material slopes | +| **C14** | A duct held in steady INFLOW through an outflow face (lo face relaxes to a low-pressure sink, hi face is an outflow whose Target also carries the 300 K reservoir state; interior starts at 600 K): the frozen-material closure keeps the hi half at 597 K — the domain feeds on its own exhaust — while `backflow_material` flushes it to 299.5 K on the advective clock; a static probe confirms the Mach-[10⁻³,10⁻²] ramp is bit-inert at breathing amplitudes | Sustained recirculation is being fed recycled interior gas (the flag off is that, by design — but the FLUSH failing with the flag on means the reservoir ramp is broken), or the ramp has crept down into breathing amplitudes where the chamber's transient physics must stay frozen | + +C4 also measures the **inflow** reflection curve: R = 2.3% / 4.8% / 19% / 57% at `relax_u` = 0.5 / 2 / 10 / 50 — soft inlets swallow acoustics, stiff ones are walls; the default reflects under 5%. And the kernel now carries a **transit guard**: an advisory counter (`material structure`) that fires when |dS| > 5% of ρ per cell sits in an outflow boundary cell — the configuration whose crossing the σ = 0.25 default does not survive. + +The C11 oracle row is the load-bearing negative control: it separates "the ghost-cell *form* cannot do this" (false — the oracle holds the front indefinitely) from "this particular *closure* cannot" (true for the entropy closure, mostly fixed by `extrap_material`). + +## C11x — the profile-fit experiment (reported, not gated) + +C11 also carries an experiment on the question "if you own a 1-D profile of +the front, can the boundary use it?" The closure is given the profile *family* +(tanh between end states — the analogue of owning an unstretched flamelet) but +not its position or thickness; both are fitted per fill, statelessly, by +inverting T at the last two interior cells through the family (a closed-form +value-and-slope match). Ladder rows at σ = 1, ⟨p⟩ error at 0.7 relaxation +times / at t_end: + +| ghost closure | 0.7 τ | t_end | what it supplies | +|---|---|---|---| +| entropy | 25007 | 143274 | nothing beyond the algebra | +| `extrap_temperature` | 26090 | 144414 | linear T | +| **fit** (profile T only, ρ from EOS at kernel p) | 25268 | 116569 | fitted-profile T | +| `extrap_material` | 3175 | 89132 | linear T *and* u | +| **fitU** (profile T and u; p stays relaxed) | **66.8** | **79.8** | fitted-profile structure | +| fitUX (same, family end-state 15% wrong) | 73.8 | 86.9 | robustness probe | +| oracle | −38.1 | −54.7 | the exact answer, placed exactly | + +(The drifting rows' numbers grew when the outflow-reversal fallback became a +soft, σ-rated relaxation instead of a hard ambient pin — the old pin was +silently braking the frozen-source walk-off through the transient reversals +these badly-anchored runs experience. The equilibration-window values of +every closure that actually holds the front, and every gate, are unchanged +to the digit; the late-time columns sit in the artifact region either way.) + +Three findings. **Material-only profile information buys nothing here** — fit += `extrap_temperature` = entropy to 0.3%, because this MMS is inviscid and its +boundary error was never in the material content (C12 is where T-content +pays). **The fitted profile supplying T and u recovers 97% of the +`extrap_material` → oracle gap** — and, unlike `extrap_material`, it does not +walk off with the frozen source at late time (79.8 vs 27210 at t_end): the +per-fill re-fit re-locks the structure to the family and cuts the mismatch +feedback loop. **The fit is insensitive to a wrong family**: a 15% error in +the assumed end state costs 9%, because the value-and-slope match absorbs the +leading-order deformation — the stretch/curvature argument in miniature. The +pressure never comes from the profile in any row; it stays the relaxation's. + +The release side is measured on C10's decaying ramp (the structure a correct +boundary must let die; `extrap_material`'s known failure). At t_end, ⟨p⟩ +error: plain kernel **+1477**, `extrap_material` **−20406** (holds the ramp +alive), unbounded profile-fitU **−3499**. The stateless re-fit releases only +*partially* — the two-parameter family can shift and widen but cannot +represent a shrinking amplitude, so during the decay it keeps imposing +full-amplitude structure through weakened data. + +The repair is the **source-consistency bound** (`fitB`): a steady front obeys +du/dn = (dp/dt)|src / ρc² (the Sutherland–Kennedy relation behind β_s), so +the continuation's amplitude is blended toward the plain kernel by +w = min(1, du/dn_sustainable / du/dn_measured), with the sustainable +dilatation computed from the measured local source. Measured, both horns: + +| | sustained front (C11), 0.7 τ / t_end | decaying ramp (C10), t_end | +|---|---|---| +| plain kernel | 15707 / 27129 | **+1477** | +| `extrap_material` | 3175 / 27210 | −20406 | +| fitU, unbounded | **66.8 / 79.8** | −3499 | +| **fitU + source bound** | **66.8 / 79.8** | **+1477** | +| oracle | −38.1 / −54.7 | — | + +On the sustained front the bound is inert to the printed digit — the +manufactured source sustains du/dn = 448 against the actual 449, so w ≈ +0.998 — and on the source-free ramp it refuses the continuation outright and +reproduces the plain kernel exactly. One stateless closure now passes both +qualifications. (Here the closure is handed the manufactured source exactly; +a PeleC version would assemble dp/dt|src from `reaction_dpdt` plus the +diffusive term, and inherits their coverage and their gaps.) + +The shape axis is also measured: a second sustained-front block replaces the +truth with a Richards curve (k = 3 — asymmetric, outside any tanh; the +manufactured source and the oracle follow it automatically) while the fit +still assumes tanh. At 0.7 τ / t_end: entropy 14632 / 86673, **fitU 54.9 / +71.7**, oracle −15.0 / −21.7 — the tanh fit through a non-tanh truth retains +**100%** of the recovery, and the source bound stays inert. The reason is +geometric: the ghosts extend 4 cells past the boundary while the front is ~20 +cells wide, so any smooth monotone saturating family matched locally in value +and slope agrees with the truth to second order over the overhang. The +library's global shape barely matters; what carries the closure is (i) +monotone saturating structure with bounded end states, (ii) the local +value-and-slope match, refreshed statelessly, and (iii) the source gate. +That is the design statement for a 2-D/3-D version: it does not need the PMF +profile per se — it needs a one-parameter monotone family with measurable end +states. + +Still untested: multi-species fitting on a progress variable, and the +interaction with β_s = 0, which feeds the same S_p into the incoming wave. + +## Reference results + +Measured with `air`, `n = 400`, `L = 10 cm`, `order = 2`, a 0.1% Gaussian +pressure pulse, integrated for 1.6 acoustic transit times. `R` is the peak +residual wave amplitude in the upstream half after the pulse has left, measured +against the *instantaneous domain mean* so that the σ-driven anchoring +adjustment is not miscounted as a reflected wave. + +| σ | R [%] | mean p drift [dyn/cm²] | τ_relax [s] | +|---|---|---|---| +| 0.00 | 0.00078 | −0.0076 | ∞ | +| 0.05 | 0.160 | −1.68 | 5.75e−3 | +| 0.10 | 0.315 | −3.30 | 2.87e−3 | +| 0.15 | 0.466 | −4.88 | 1.92e−3 | +| 0.20 | 0.614 | −6.44 | 1.44e−3 | +| **0.25** | **0.758** | **−7.96** | **1.15e−3** | +| 0.30 | 0.899 | −9.44 | 9.58e−4 | +| 0.50 | 1.43 | −15.0 | 5.75e−4 | +| 1.00 | 2.56 | −26.8 | 2.87e−4 | +| 2.00 | 4.18 | −43.5 | 1.44e−4 | +| 4.00 | 7.20 | −60.3 | 7.19e−5 | +| 8.00 | 14.97 | −69.3 | 3.59e−5 | +| 16.00 | 28.14 | −70.8 | 1.80e−5 | +| — (`pin_farfield`) | 0.015 | +0.010 | n/a (value pin) | + +The sweep runs past σ = 2 because `Exec/RegTests/NSCBC-FlameOutflow` needs +σ ≈ 10 to anchor an outflow with a flame crossing it, and the price of that has +to be quotable: 20-30% reflection, and a mean drift that has saturated — beyond +σ ≈ 8 the anchoring stops improving while the reflection keeps growing, so +σ > 16 buys nothing at all. + +Three things to read out of that table. + +**The reflection/anchoring trade-off is real and monotone.** `R` grows very +nearly linearly in σ while the pressure anchoring strengthens in step. σ = 0 is +perfectly non-reflecting and completely unanchored. This is the curve that makes +"σ = 0.25 is a good default" a measured statement rather than a received one. + +**The relaxation is a genuine rate.** Doubling and quadrupling the resolution +changes the measured `K` by 3% (1925 → 1861 s⁻¹ from n=200 to n=800), and the +measured value sits within 7% of `σc/L`. A boundary condition parameterised as a +*value blend* instead has an effective rate of +`c/Δx`, which doubles when the mesh does, so its σ is not transferable and its +behaviour is not grid-converged. Keeping this check green is what keeps +literature σ values meaningful in PeleC. + +**`pin_farfield` is not simply "σ = ∞".** The hard far-field pin is *both* +nearly non-reflecting (R = 0.015%) *and* anchored (drift +0.01 vs −7.96 at +σ=0.25) — because it constrains the incoming invariant's *value* rather than its +gradient, and a value constraint on the incoming characteristic alone reflects +nothing. Its cost is that it anchors to `p_target + ρc·u_out` rather than to +`p_target`, and that it does not converge under refinement. It is the right +choice for an open boundary onto a large quiescent reservoir and the wrong one +for a duct exhausting into a plenum whose true mean pressure is not `p_target`. + +## Duct modes — the injection-fidelity bed (`t1` / `t3` / `t5`) + +```sh +./nscbc1d t1 # step-change convergence vs relax_u (Dupuy) +./nscbc1d t3 # forced-inlet deterioration index (Daviller) +./nscbc1d t5 [relax_u=2] # standing-wave P_RMS(x) profiles (Daviller) +``` + +One duct, run only when asked: NSCBC value-relaxation inflow at the lo face, +hard pressure ghost (FOExtrap, p pinned — fully reflecting, R ≈ −1) at the +hi face, mean inflow 2×10³ cm/s, forcing amplitude 10⁻³ c. These are Part +III of the design doc made runnable — the first *dynamic* measurements of +what the value-relaxation inlet does to injected signals, against the +Dupuy/Daviller phenomenology. Each mode carries its own relationship gates +and exits nonzero on failure; none is part of the default suite. + +**t1 — step response (Dupuy Figs 5a/8a).** Inlet target stepped at t = 0 +against the reflecting far end; t_conv = last time the domain-mean velocity +sits outside 0.1% of the step, in units of t_a = 2L/c, 40 t_a window: + +| relax_u | 0.1 | 0.2 | **0.3** | 0.5 | 1 | 2 | 5 | 10–100 | +|---|---|---|---|---|---|---|---|---| +| t_conv/t_a | 31.3 | 13.5 | **6.06** | 6.84 | 11.2 | 20.3 | 39.9 | >40 (never, in-window) | + +The CLR curve exactly as their DDE analysis predicts: an interior optimum +at 0.3, drift-limited convergence below it, reflection-delayed convergence +above, unusable by 5. Gated: interior minimum, argmin ∈ [0.1, 1]. + +**t3 — forced-inlet deterioration (Daviller §4–5).** Harmonic target +u^t = u₀ + A sin(2πft) at 0.8/1.0/1.2 × the duct's quarter-wave f₀ +(Doppler-corrected, 866.7 Hz here). I_u = achieved u′ amplitude at the +boundary cell over A (1 = faithful injection); I_in = incoming-invariant +amplitude over the ideal injector's; the stiff (velocity-Dirichlet) duct +gain 1/|1+e^{iθ}| is the reference: + +| relax_u | I_u at 0.8 f₀ | I_u at f₀ | I_u at 1.2 f₀ | +|---|---|---|---| +| 0 | 0.011 | 0.013 | 0.016 | +| 0.5 | 0.137 | 0.013 | 0.073 | +| 2 | 0.949 | 0.010 | 0.244 | +| 5 | 2.709 | 0.146 | 0.455 | + +Three findings, all design-doc I.3e made quantitative. **relax_u = 0 +injects nothing** (gated): the GC inflow has no amplitude slot — a +time-varying target enters only through the relaxation, so the injection +vanishes with the stiffness. This is the exact opposite of NRI's I ≡ 1 at +any K. **Off-resonance injection is monotone in relax_u but not faithful +anywhere** (gated on the monotonicity): 14% of the target at relax_u = 0.5, +95% at 2 — and 271% at 5, overshooting, because the duct's return wave and +the relaxation's response interfere and the closed loop has no notion of +the difference between "target not met" and "reflection arriving". +**On resonance the injection collapses instead of over-driving** (I_u ≈ +0.01 at every stiffness while I_in stays order-1): the quarter-wave mode +has a velocity node at the driven end, and a velocity relaxation cannot +impose a signal at a velocity node — the incoming wave it launches returns +inverted and cancels it. An inlet that must both hold a mean and inject a +signal through one relaxation coefficient does neither faithfully near a +resonance; that is the measured cost of statelessness at the inlet, and the +NRI/register discussion (design doc II.2, queue item 4) is the literature's +answer to exactly this table. + +**t3 also carries the queue-item-4 prototypes** (register + feed-forward +GATED at I_in ∈ [0.85, 1.15] across the matrix): the same matrix under +three inlet models, with the register held by the *driver loop* — updated +once per step outside the stages, read frozen — which is the +boundary-registers architecture with the kernel untouched. I_in +(Daviller's index, incoming amplitude over the ideal injector's): + +| inlet model | I_in over the 9-point matrix | +|---|---| +| classical | 0.07 – 4.7 | +| NRI register alone | 0.22 – 0.95 | +| `Target::dudt` feed-forward alone | 0.85 – 5.24 | +| **register + feed-forward** | **0.89 – 1.08, incl. on resonance** | + +The register-only NRI transplant fails structurally (the value form has no +amplitude slot to protect — I.3e measured); `dudt` is that slot, stateless +and now in the kernel; the register's real job is the *learned reference +mean* that lets the relaxation ignore the returning wave. Together they +put I_in within ~10% of unity at every stiffness and frequency, and the +residual velocity pattern is the duct's own `|1+e^{iθ}|/2` to three +digits. Full design: `Docs/NSCBC-boundary-registers-design.md`. + +Two register footnotes. `./nscbc1d ndnr` is the outlet-side twin (gated): +relaxing toward the register's EMA mean instead of the fixed far-field +collapses the σ = 16 planar-pulse reflection 28.14% → 3.56% while keeping +the anchor. And the register invariant is referenced to the *frozen +ambient* ρc on purpose: `NSCBC1D_LOCAL_RC=1` re-runs t3 with the +instantaneous local impedance instead, degrading register+FF to +I_in = 1.61 at relax_u = 2 — the mean p/(ρc) times the coherent impedance +oscillation aliases into R₋ at signal amplitude. Kept as a runnable +forensic; PeleC carries the same reference as a slow-EMA'd `ema_rhoc` +(`Exec/RegTests/NSCBC-Acoustic/README.md` for its half of both tables). + +**t5 — standing-wave pattern (Daviller §7).** Same runs; P_RMS(x) at 17 +stations against the analytic envelope |sin(k_eff(L−x))|. Shape correlation +is 1.000 at all three frequencies (gated off-resonance at > 0.95): nodes +and antinodes sit exactly where the duct puts them, at every stiffness — +the *geometry* is never the problem. The amplitudes carry t3's story: +antinode P_RMS = 2819 / 1260 / 812 dyn/cm² at 0.8/1.0/1.2 f₀ (relax_u = 2), +i.e. the strongest response is NEAR resonance and the weakest ON it, +because the inlet cannot couple into the mode it is nominally driving +hardest. (Ideal injected amplitude for scale: ρcA ≈ 1.4×10³, doubled at a +standing-wave antinode.) + +## Adding a check + +Checks are plain functions that call `check(bool, name, detail)`. Prefer +assertions on *physical relationships* (monotonicity, grid-independence, +conservation identities, direction of relaxation) over assertions on numbers: +the numbers move when the mechanism, the mesh or the interior scheme changes, +and the relationships do not. diff --git a/Verification/NSCBC1D/nscbc1d.cpp b/Verification/NSCBC1D/nscbc1d.cpp new file mode 100644 index 000000000..cf931d65e --- /dev/null +++ b/Verification/NSCBC1D/nscbc1d.cpp @@ -0,0 +1,3689 @@ +// ============================================================================ +// nscbc1d -- standalone 1-D verification driver for Source/NSCBC.H +// +// This driver compiles the production NSCBC kernel unmodified against a +// minimal 1-D finite-volume Euler solver. Its purpose is to separate two +// failure modes that are otherwise indistinguishable in a full PeleC run: +// +// "the boundary condition is wrong" vs "the AMReX plumbing is wrong" +// +// It is cheap enough to sweep parameters, so it also produces the reference +// curves (reflection coefficient vs sigma, relaxation rate vs resolution) +// that the AMReX-side regression tests are checked against. +// +// Build: see CMakeLists.txt in this directory. +// Run: ./nscbc1d (runs every check, prints a summary) +// ./nscbc1d sweep (also dumps the sigma sweep as CSV) +// +// Solver: MUSCL-Hancock, minmod-limited primitive reconstruction, HLLC flux, +// SSP-RK2. Deliberately simple -- it is the boundary condition under test, +// not the interior scheme. +// ============================================================================ + +#include +#include + +#include +#include +#include +#include +#include + +#include "NSCBC.H" + +using amrex::Real; + +namespace { + +constexpr int NG = 4; // ghost layers, matching PeleC::numGrow() without EB + +// --------------------------------------------------------------------------- +// A 1-D state vector, laid out exactly like PeleC's conserved state so that +// the kernel sees the indices it expects. +// --------------------------------------------------------------------------- +struct Field +{ + int n; + std::vector s; // (n + 2*NG) * NVAR + + explicit Field(int n_) : n(n_), s(static_cast(n + 2 * NG) * NVAR, 0.0) + { + } + Real* at(int i) { return &s[static_cast(i + NG) * NVAR]; } + const Real* at(int i) const { return &s[static_cast(i + NG) * NVAR]; } +}; + +struct Prim +{ + Real rho, u, p, T; + Real Y[NUM_SPECIES]; +}; + +void +set_state(Real* s, Real rho, Real u, Real T, const Real Y[NUM_SPECIES]) +{ + auto eos = pele::physics::PhysicsType::eos(); + Real e = 0.0; + eos.RTY2E(rho, T, Y, e); + s[URHO] = rho; + s[UMX] = rho * u; + s[UMY] = 0.0; + s[UMZ] = 0.0; + s[UEINT] = rho * e; + s[UEDEN] = rho * (e + 0.5 * u * u); + s[UTEMP] = T; + for (int k = 0; k < NUM_SPECIES; k++) { + s[UFS + k] = rho * Y[k]; + } +#if NUM_ADV > 0 + // Nonzero, distinct, per-unit-mass values, so that a build with passive + // scalars actually exercises pack_ghost's UFA handling: C1's uniform-state + // loop runs over ALL NVAR components and catches any slot the fill drops + // or mis-scales. + for (int k = 0; k < NUM_ADV; k++) { + s[UFA + k] = rho * 0.1 * static_cast(k + 1); + } +#endif +} + +Prim +get_prim(const Real* s) +{ + auto eos = pele::physics::PhysicsType::eos(); + Prim q{}; + q.rho = s[URHO]; + q.u = s[UMX] / q.rho; + q.T = s[UTEMP]; + Real ysum = 0.0; + for (int k = 0; k < NUM_SPECIES; k++) { + q.Y[k] = s[UFS + k] / q.rho; + ysum += q.Y[k]; + } + for (int k = 0; k < NUM_SPECIES; k++) { + q.Y[k] /= ysum; + } + eos.RTY2P(q.rho, q.T, q.Y, q.p); + return q; +} + +Real +sound_speed(const Prim& q) +{ + auto eos = pele::physics::PhysicsType::eos(); + Real c = 0.0; + eos.RTY2Cs(q.rho, q.T, q.Y, c); + return c; +} + +// --------------------------------------------------------------------------- +// Boundary fill: exactly the dispatch the AMReX-side BCfill.cpp will do. +// layer runs 1..NG outward; the stencil walks inward from the boundary cell. +// --------------------------------------------------------------------------- +void +fill_bcs( + Field& f, + const pc_nscbc::Target& lo_tgt, + const pc_nscbc::Target& hi_tgt, + const pc_nscbc::Params& prm, + Real dx, + amrex::Long* diag = nullptr) +{ + const int n = f.n; + for (int layer = 1; layer <= NG; layer++) { + if (lo_tgt.type != pc_nscbc::Type::off) { + pc_nscbc::apply( + f.at(0), f.at(1), f.at(2), 3, dx, /*idir=*/0, /*sgn=*/+1, layer, lo_tgt, + prm, f.at(-layer), diag); + } else { + for (int v = 0; v < NVAR; v++) { + f.at(-layer)[v] = f.at(0)[v]; + } + } + if (hi_tgt.type != pc_nscbc::Type::off) { + pc_nscbc::apply( + f.at(n - 1), f.at(n - 2), f.at(n - 3), 3, dx, /*idir=*/0, /*sgn=*/-1, + layer, hi_tgt, prm, f.at(n - 1 + layer), diag); + } else { + for (int v = 0; v < NVAR; v++) { + f.at(n - 1 + layer)[v] = f.at(n - 1)[v]; + } + } + } +} + +// --------------------------------------------------------------------------- +// HLLC flux for the multi-species Euler equations. +// --------------------------------------------------------------------------- +void +hllc(const Prim& L, const Prim& R, Real* flx) +{ + auto eos = pele::physics::PhysicsType::eos(); + const Real cL = sound_speed(L), cR = sound_speed(R); + const Real sL = std::min(L.u - cL, R.u - cR); + const Real sR = std::max(L.u + cL, R.u + cR); + const Real sM = + (R.p - L.p + L.rho * L.u * (sL - L.u) - R.rho * R.u * (sR - R.u)) / + (L.rho * (sL - L.u) - R.rho * (sR - R.u)); + + auto cons_flux = [&](const Prim& q, Real* F) { + Real e = 0.0; + eos.RTY2E(q.rho, q.T, q.Y, e); + const Real E = q.rho * (e + 0.5 * q.u * q.u); + F[URHO] = q.rho * q.u; + F[UMX] = q.rho * q.u * q.u + q.p; + F[UMY] = 0.0; + F[UMZ] = 0.0; + F[UEDEN] = (E + q.p) * q.u; + F[UEINT] = q.rho * e * q.u; + F[UTEMP] = 0.0; + for (int k = 0; k < NUM_SPECIES; k++) { + F[UFS + k] = q.rho * q.Y[k] * q.u; + } + }; + auto star_state = [&](const Prim& q, Real s, Real* U) { + Real e = 0.0; + eos.RTY2E(q.rho, q.T, q.Y, e); + const Real E = q.rho * (e + 0.5 * q.u * q.u); + const Real fac = (s - q.u) / (s - sM); + const Real rs = q.rho * fac; + U[URHO] = rs; + U[UMX] = rs * sM; + U[UMY] = 0.0; + U[UMZ] = 0.0; + U[UEDEN] = rs * (E / q.rho + (sM - q.u) * (sM + q.p / (q.rho * (s - q.u)))); + U[UEINT] = rs * e; + U[UTEMP] = 0.0; + for (int k = 0; k < NUM_SPECIES; k++) { + U[UFS + k] = rs * q.Y[k]; + } + }; + + Real FL[NVAR], FR[NVAR], UL[NVAR], UR[NVAR], US[NVAR]; + cons_flux(L, FL); + cons_flux(R, FR); + auto cons_state = [&](const Prim& q, Real* U) { + Real e = 0.0; + eos.RTY2E(q.rho, q.T, q.Y, e); + U[URHO] = q.rho; + U[UMX] = q.rho * q.u; + U[UMY] = 0.0; + U[UMZ] = 0.0; + U[UEDEN] = q.rho * (e + 0.5 * q.u * q.u); + U[UEINT] = q.rho * e; + U[UTEMP] = 0.0; + for (int k = 0; k < NUM_SPECIES; k++) { + U[UFS + k] = q.rho * q.Y[k]; + } + }; + cons_state(L, UL); + cons_state(R, UR); + + if (sL >= 0.0) { + for (int v = 0; v < NVAR; v++) { + flx[v] = FL[v]; + } + } else if (sR <= 0.0) { + for (int v = 0; v < NVAR; v++) { + flx[v] = FR[v]; + } + } else if (sM >= 0.0) { + star_state(L, sL, US); + for (int v = 0; v < NVAR; v++) { + flx[v] = FL[v] + sL * (US[v] - UL[v]); + } + } else { + star_state(R, sR, US); + for (int v = 0; v < NVAR; v++) { + flx[v] = FR[v] + sR * (US[v] - UR[v]); + } + } +} + +Real +mm(Real a, Real b) +{ + return (a * b <= 0.0) ? 0.0 : ((std::abs(a) < std::abs(b)) ? a : b); +} + +// One SSP-RK2 stage: conserved-variable MUSCL with minmod slopes. +// Optional constant-conductivity conduction in the mini solver, for C12: the +// energy flux gains -lambda dT/dx at every face. Zero (off) everywhere else. +Real g_lambda = 0.0; + +void +stage(const Field& in, Field& out, Real dx, Real dt) +{ + const int n = in.n; + std::vector flux(static_cast(n + 1) * NVAR, 0.0); + for (int i = 0; i <= n; i++) { + Real sl[NVAR], sr[NVAR]; + for (int v = 0; v < NVAR; v++) { + const Real dL = + mm(in.at(i - 1)[v] - in.at(i - 2)[v], in.at(i)[v] - in.at(i - 1)[v]); + const Real dR = + mm(in.at(i)[v] - in.at(i - 1)[v], in.at(i + 1)[v] - in.at(i)[v]); + sl[v] = in.at(i - 1)[v] + 0.5 * dL; + sr[v] = in.at(i)[v] - 0.5 * dR; + } + // Recover T for each face state so the EOS is self-consistent. + auto eos = pele::physics::PhysicsType::eos(); + auto to_prim = [&](Real* s) { + Prim q{}; + q.rho = std::max(s[URHO], 1e-12); + q.u = s[UMX] / q.rho; + Real ys = 0.0; + for (int k = 0; k < NUM_SPECIES; k++) { + q.Y[k] = std::max(s[UFS + k] / q.rho, 0.0); + ys += q.Y[k]; + } + for (int k = 0; k < NUM_SPECIES; k++) { + q.Y[k] /= ys; + } + const Real e = s[UEDEN] / q.rho - 0.5 * q.u * q.u; + q.T = s[UTEMP] > 0.0 ? s[UTEMP] : 300.0; + eos.REY2T(q.rho, e, q.Y, q.T); + eos.RTY2P(q.rho, q.T, q.Y, q.p); + return q; + }; + hllc(to_prim(sl), to_prim(sr), &flux[static_cast(i) * NVAR]); + if (g_lambda > 0.0) { + // Conduction between the adjacent CELL CENTRES, like PeleC's diffusion + // operator: at i = 0 and i = n this reads a ghost temperature, so the + // outflow's ghost closure IS the boundary heat flux here too. + // UEDEN only: stage() recomputes UEINT from UEDEN afterwards. + flux[static_cast(i) * NVAR + UEDEN] -= + g_lambda * (in.at(i)[UTEMP] - in.at(i - 1)[UTEMP]) / dx; + } + } + for (int i = 0; i < n; i++) { + for (int v = 0; v < NVAR; v++) { + out.at(i)[v] = + in.at(i)[v] - dt / dx * + (flux[static_cast(i + 1) * NVAR + v] - + flux[static_cast(i) * NVAR + v]); + } + // keep UTEMP consistent + auto eos = pele::physics::PhysicsType::eos(); + Real* s = out.at(i); + const Real rho = s[URHO]; + const Real u = s[UMX] / rho; + Real Y[NUM_SPECIES], ys = 0.0; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = std::max(s[UFS + k] / rho, 0.0); + ys += Y[k]; + } + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] /= ys; + } + const Real e = s[UEDEN] / rho - 0.5 * u * u; + Real T = s[UTEMP] > 0.0 ? s[UTEMP] : 300.0; + eos.REY2T(rho, e, Y, T); + s[UTEMP] = T; + s[UEINT] = rho * e; + } +} + +// PeleC and PelePhysics work in CGS: lengths in cm, pressures in dyn/cm^2, +// velocities in cm/s, densities in g/cm^3. Constants::PATM = 1.01325e6. +struct Case +{ + int n = 400; + Real L = 10.0; // cm + Real p0 = 1.01325e6; // dyn/cm^2 (1 atm) + Real T0 = 300.0; // K + Real u0 = 0.0; // cm/s + Real cfl = 0.4; +}; + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- +int n_pass = 0, n_fail = 0; + +void +check(bool ok, const std::string& name, const std::string& detail) +{ + if (ok) { + n_pass++; + std::printf(" PASS %-46s %s\n", name.c_str(), detail.c_str()); + } else { + n_fail++; + std::printf(" FAIL %-46s %s\n", name.c_str(), detail.c_str()); + } +} + +// A measurement worth recording that is not a pass/fail criterion: either the +// prediction it would test cannot be isolated in the configuration available, +// or the number is diagnostic rather than normative. +void +report(const std::string& name, const std::string& detail) +{ + std::printf(" .... %-46s %s\n", name.c_str(), detail.c_str()); +} + +// Air, for whatever mechanism this was built against. Must not assume a +// two-species mechanism: with LiDryer's nine species the old form +// ("0.233 if O2 else 0.767") sums to well over one and every check downstream +// fails for reasons that have nothing to do with the boundary condition. +Real +air_Y(int k) +{ + if (k == O2_ID) { + return 0.233; + } + if (k == N2_ID) { + return 0.767; + } + return 0.0; +} + +} // namespace + +// =========================================================================== +// Checks +// =========================================================================== + +// C1: a uniform state must be reproduced exactly in every ghost layer, for +// both inflow and outflow, at any sigma. If this fails, the kernel is +// manufacturing a gradient out of nothing and everything downstream is +// meaningless. +void +check_uniform() +{ + Case cs; + Field f(cs.n); + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + auto eos = pele::physics::PhysicsType::eos(); + Real rho0 = 0.0, e0 = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho0, e0); + for (int i = -NG; i < cs.n + NG; i++) { + set_state(f.at(i), rho0, cs.u0 + 2.0e3, cs.T0, Y); // 20 m/s + } + const Real dx = cs.L / cs.n; + + pc_nscbc::Params prm; + prm.L_ref = cs.L; + pc_nscbc::Target off, out, in; + out.type = pc_nscbc::Type::outflow; + out.p = cs.p0; + in.type = pc_nscbc::Type::inflow; + in.u[0] = 2.0e3; + in.T = cs.T0; + for (int k = 0; k < NUM_SPECIES; k++) { + in.Y[k] = Y[k]; + } + + for (bool mat : {false, true}) { + prm.extrap_material = mat; + for (Real sig : {0.0, 0.25, 1.0}) { + prm.sigma = sig; + prm.relax_u = sig; + prm.relax_t = sig; + Field g = f; + fill_bcs(g, in, out, prm, dx); + Real worst = 0.0; + for (int layer = 1; layer <= NG; layer++) { + for (int v = 0; v < NVAR; v++) { + const Real ref = std::max(std::abs(f.at(0)[v]), 1.0); + worst = std::max(worst, std::abs(g.at(-layer)[v] - f.at(0)[v]) / ref); + worst = std::max( + worst, + std::abs(g.at(cs.n - 1 + layer)[v] - f.at(cs.n - 1)[v]) / ref); + } + } + char buf[160]; + std::snprintf( + buf, sizeof(buf), "sigma=%.2f mat=%d max rel ghost error = %.3e", sig, + static_cast(mat), worst); +#ifdef USE_SRK_EOS + // SRK's (rho,e,Y)->T and (rho,Y,p)->T are Newton solves; round-trip + // convergence is ~1e-11, not machine epsilon. The check's meaning -- + // no manufactured gradients -- survives at that floor. + check(worst < 1e-9, "uniform state is reproduced exactly", buf); +#else + check(worst < 1e-12, "uniform state is reproduced exactly", buf); +#endif + } + } +} + +// C2: the relaxation must move the boundary TOWARD the target, for every +// target quantity and on both faces. This is the assertion that catches +// the sign errors the legacy Fortran leaked to its users as "relax_T must +// be negative". +void +check_relaxation_signs() +{ + Case cs; + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + auto eos = pele::physics::PhysicsType::eos(); + const Real dx = cs.L / cs.n; + pc_nscbc::Params prm; + prm.L_ref = cs.L; + prm.sigma = 0.25; + prm.relax_u = 2.0; + prm.relax_t = 0.2; + prm.order = 1; // isolate the relaxation from the extrapolation + + // --- outflow: interior pressure above target -> ghost pressure below the + // non-relaxed value (i.e. pulled toward the target) + for (int sgn : {+1, -1}) { + Real rho = 0.0, e = 0.0; + const Real p_int = 1.05 * cs.p0; + eos.PYT2RE(p_int, Y, cs.T0, rho, e); + Real sN[NVAR], sg_relaxed[NVAR], sg_free[NVAR]; + set_state(sN, rho, (sgn == +1) ? -3.0e3 : 3.0e3, cs.T0, Y); // outflow + pc_nscbc::Target t; + t.type = pc_nscbc::Type::outflow; + t.p = cs.p0; + pc_nscbc::apply(sN, sN, sN, 1, dx, 0, sgn, 1, t, prm, sg_relaxed); + pc_nscbc::Params p0prm = prm; + p0prm.sigma = 0.0; + pc_nscbc::apply(sN, sN, sN, 1, dx, 0, sgn, 1, t, p0prm, sg_free); + const Prim qr = get_prim(sg_relaxed), qf = get_prim(sg_free); + char buf[160]; + std::snprintf( + buf, sizeof(buf), "%s face: p_ghost %.3f vs %.3f dyn/cm2 unrelaxed", + (sgn == +1 ? "lo" : "hi"), qr.p, qf.p); + check(qr.p < qf.p, "outflow sigma pulls pressure toward target", buf); + } + + // --- inflow: interior normal velocity above target -> ghost moves down + // toward it; interior T above target -> ghost T moves down. + for (int sgn : {+1, -1}) { + Real rho = 0.0, e = 0.0; + eos.PYT2RE(cs.p0, Y, 400.0, rho, e); + const Real u_int = (sgn == +1) ? 4.0e3 : -4.0e3; // inflow through the face + Real sN[NVAR], sg[NVAR]; + set_state(sN, rho, u_int, 400.0, Y); + pc_nscbc::Target t; + t.type = pc_nscbc::Type::inflow; + t.u[0] = (sgn == +1) ? 2.0e3 : -2.0e3; // want less inflow + t.T = 300.0; // want colder + for (int k = 0; k < NUM_SPECIES; k++) { + t.Y[k] = Y[k]; + } + pc_nscbc::apply(sN, sN, sN, 1, dx, 0, sgn, 1, t, prm, sg); + const Prim q = get_prim(sg); + // "toward the target" in the outward frame + const Real n_sgn = -static_cast(sgn); + const Real uo_int = n_sgn * u_int, uo_g = n_sgn * q.u, + uo_t = n_sgn * t.u[0]; + char buf[200]; + std::snprintf( + buf, sizeof(buf), "%s face: u_out %.2f -> %.2f cm/s (target %.2f)", + (sgn == +1 ? "lo" : "hi"), uo_int, uo_g, uo_t); + check( + (uo_g - uo_t) * (uo_int - uo_t) >= 0.0 && + std::abs(uo_g - uo_t) < std::abs(uo_int - uo_t), + "inflow relax_u moves velocity toward target", buf); + std::snprintf( + buf, sizeof(buf), "%s face: T %.3f -> %.3f (target %.1f)", + (sgn == +1 ? "lo" : "hi"), 400.0, q.T, t.T); + check( + q.T < 400.0 && q.T > t.T, + "inflow relax_t moves temperature toward target", buf); + } +} + +// C3: species handling. At an outflow a composition gradient must be +// extrapolated, NOT clamped to any target -- the legacy code imposed the +// target composition at outflows, which over-specifies the problem. At an +// inflow the target composition must be imposed exactly. +void +check_species() +{ + Case cs; + auto eos = pele::physics::PhysicsType::eos(); + const Real dx = cs.L / cs.n; + pc_nscbc::Params prm; + prm.L_ref = cs.L; + prm.sigma = 0.25; + + // A linear composition ramp running out through a hi outflow face. + Real sN[NVAR], sNm1[NVAR], sNm2[NVAR], sg[NVAR]; + auto mk = [&](Real* s, Real yO2) { + // Zero-initialised: with a mechanism larger than air's two species, the + // remaining entries would otherwise be stack garbage -- which is exactly + // the "nothing here may assume a particular mechanism" trap the README + // documents, and it made this check fail under LiDryer while passing + // under air. + Real Y[NUM_SPECIES] = {0.0}; + Y[O2_ID] = yO2; + Y[N2_ID] = 1.0 - yO2; + Real rho = 0.0, e = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho, e); + set_state(s, rho, 3.0e3, cs.T0, Y); + }; + mk(sN, 0.30); + mk(sNm1, 0.28); + mk(sNm2, 0.26); + pc_nscbc::Target t; + t.type = pc_nscbc::Type::outflow; + t.p = cs.p0; + pc_nscbc::apply(sN, sNm1, sNm2, 3, dx, 0, -1, 1, t, prm, sg); + const Prim q = get_prim(sg); + char buf[200]; + std::snprintf( + buf, sizeof(buf), "Y(O2) 0.26,0.28,0.30 -> ghost %.6f (expect 0.32)", + q.Y[O2_ID]); + check( + std::abs(q.Y[O2_ID] - 0.32) < 1e-6, + "outflow extrapolates composition (not imposed)", buf); + + // Sum of mass fractions must be exactly one after extrapolation+clipping. + Real ysum = 0.0; + for (int k = 0; k < NUM_SPECIES; k++) { + ysum += sg[UFS + k]; + } + std::snprintf( + buf, sizeof(buf), "|sum(rhoY)/rho - 1| = %.3e", + std::abs(ysum / sg[URHO] - 1.0)); + check( + std::abs(ysum / sg[URHO] - 1.0) < 1e-14, "sum(Y) == 1 after outflow fill", + buf); + + // Inflow imposes the target exactly. + pc_nscbc::Target ti; + ti.type = pc_nscbc::Type::inflow; + ti.T = cs.T0; + ti.u[0] = -3.0e3; + ti.Y[O2_ID] = 1.0; + ti.Y[N2_ID] = 0.0; + Real sIn[NVAR]; + mk(sIn, 0.30); + set_state(sIn, get_prim(sIn).rho, -3.0e3, cs.T0, [&] { + static Real Y[NUM_SPECIES]; + Y[O2_ID] = 0.30; + Y[N2_ID] = 0.70; + return Y; + }()); + pc_nscbc::apply(sIn, sIn, sIn, 1, dx, 0, -1, 1, ti, prm, sg); + const Prim qi = get_prim(sg); + std::snprintf( + buf, sizeof(buf), "interior Y(O2)=0.30, target 1.0 -> ghost %.6f", + qi.Y[O2_ID]); + check( + std::abs(qi.Y[O2_ID] - 1.0) < 1e-12, "inflow imposes composition exactly", + buf); + + // Energy identity must hold exactly. + const Real ke = 0.5 * sg[UMX] * sg[UMX] / sg[URHO]; + const Real resid = + std::abs(sg[UEDEN] - sg[UEINT] - ke) / std::max(std::abs(sg[UEDEN]), 1.0); + std::snprintf(buf, sizeof(buf), "|UEDEN - UEINT - KE|/UEDEN = %.3e", resid); + check(resid < 1e-14, "UEDEN == UEINT + KE exactly", buf); +} + +// C4: acoustic reflection. Launch a Gaussian pressure pulse at an outflow and +// measure the amplitude that comes back. Reports the sigma sweep, which +// is the curve the AMReX regression test is checked against. +Real +reflection_coefficient( + Real sigma, + int n, + int order, + bool pin, + Real* p_drift, + bool mat = false, + bool ndnr = false) +{ + Case cs; + cs.n = n; + Field f(cs.n), g(cs.n), h(cs.n); + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + auto eos = pele::physics::PhysicsType::eos(); + const Real dx = cs.L / cs.n; + + const Real amp = 1.0e-3; // 0.1% pressure pulse -- linear regime + const Real x0 = 0.35 * cs.L, w = 0.04 * cs.L; + Real rho_ref = 0.0, e_ref = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho_ref, e_ref); + const Prim qref = [&] { + Prim q{}; + q.rho = rho_ref; + q.u = 0.0; + q.p = cs.p0; + q.T = cs.T0; + for (int k = 0; k < NUM_SPECIES; k++) { + q.Y[k] = Y[k]; + } + return q; + }(); + const Real c0 = sound_speed(qref); + + for (int i = -NG; i < cs.n + NG; i++) { + const Real x = (i + 0.5) * dx; + const Real dp = amp * cs.p0 * std::exp(-std::pow((x - x0) / w, 2)); + // Right-running isentropic acoustic pulse. + const Real p = cs.p0 + dp; + const Real rho = rho_ref + dp / (c0 * c0); + const Real u = dp / (rho_ref * c0); + Real T = 0.0; + eos.RYP2T(rho, Y, p, T); + set_state(f.at(i), rho, u, T, Y); + } + + pc_nscbc::Params prm; + prm.L_ref = cs.L; + prm.sigma = sigma; + prm.order = order; + prm.pin_farfield = pin; + prm.extrap_material = mat; + pc_nscbc::Target off, out; + out.type = pc_nscbc::Type::outflow; + out.p = cs.p0; + + const Real t_end = 1.6 * cs.L / c0; // pulse out, any reflection back in + Real t = 0.0; + Real peak_in = amp * cs.p0, peak_out = 0.0; + // measurement window: the left half, after the pulse has left + const Real t_measure = 0.9 * cs.L / c0; + + // NDNR register (queue item 4, phase B): a driver-held EMA of the + // boundary-cell pressure, tau = 3 t_a, updated once per step outside the + // stages. The kernel sees a composed target p_eff = p_b - EMA + p0, so + // its relaxation K (p_b - p_eff) = K (EMA - p0) acts on the slow mean + // only -- the acoustic fluctuation is stripped before sigma touches it. + Real ema_p = cs.p0; + const Real tau_ema = 3.0 * (2.0 * cs.L / c0); + + while (t < t_end) { + Real cmax = 0.0; + for (int i = 0; i < cs.n; i++) { + const Prim q = get_prim(f.at(i)); + cmax = std::max(cmax, std::abs(q.u) + sound_speed(q)); + } + Real dt = cs.cfl * dx / cmax; + dt = std::min(dt, t_end - t); + if (dt <= 0.0) { + break; + } + + pc_nscbc::Target out_eff = out; + if (ndnr) { + const Prim qb = get_prim(f.at(cs.n - 1)); + const Real w = dt / (tau_ema + dt); + ema_p += w * (qb.p - ema_p); + out_eff.p = qb.p - ema_p + cs.p0; + } + + fill_bcs(f, off, out_eff, prm, dx); + stage(f, g, dx, dt); + fill_bcs(g, off, out_eff, prm, dx); + stage(g, h, dx, dt); + for (int i = 0; i < cs.n; i++) { + for (int v = 0; v < NVAR; v++) { + f.at(i)[v] = 0.5 * (f.at(i)[v] + h.at(i)[v]); + } + } + t += dt; + + if (t > t_measure) { + // Measure the wave content only: the deviation from the instantaneous + // domain mean. Measuring against p0 instead would count the sigma + // anchoring transient (a uniform, non-propagating pressure adjustment) + // as if it were a reflected wave, which it is not. + Real pbar = 0.0; + for (int i = 0; i < cs.n; i++) { + pbar += get_prim(f.at(i)).p; + } + pbar /= cs.n; + for (int i = 0; i < cs.n / 2; i++) { + peak_out = std::max(peak_out, std::abs(get_prim(f.at(i)).p - pbar)); + } + } + } + if (p_drift != nullptr) { + Real pm = 0.0; + for (int i = 0; i < cs.n; i++) { + pm += get_prim(f.at(i)).p; + } + *p_drift = pm / cs.n - cs.p0; + } + return peak_out / peak_in; +} + +// The inflow-side analogue of the outflow measurement above: a LEFT-running +// pulse into a characteristic inflow holding a quiescent target, reflection +// measured in the right half after the bounce. A soft inflow (small relax_u) +// lets the pulse push the boundary and swallows most of it; a stiff one is a +// Dirichlet condition in disguise and reflects like a wall. This curve is +// how relax_u should be chosen, and it did not exist before: C2 checks only +// the relaxation DIRECTIONS at an inflow. +Real +inflow_reflection(Real relax_u, int n) +{ + Case cs; + cs.n = n; + Field f(cs.n), g(cs.n), h(cs.n); + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + auto eos = pele::physics::PhysicsType::eos(); + const Real dx = cs.L / cs.n; + + const Real amp = 1.0e-3; + const Real x0 = 0.65 * cs.L, w = 0.04 * cs.L; + Real rho_ref = 0.0, e_ref = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho_ref, e_ref); + const Prim qref = [&] { + Prim q{}; + q.rho = rho_ref; + q.u = 0.0; + q.p = cs.p0; + q.T = cs.T0; + for (int k = 0; k < NUM_SPECIES; k++) { + q.Y[k] = Y[k]; + } + return q; + }(); + const Real c0 = sound_speed(qref); + + // A base INFLOW is required: with a quiescent target the incident pulse + // pushes gas out through the inflow face, the kernel's reversal guard + // (correctly) drops to a zero-gradient copy, and the "inflow" being + // measured is not the inflow model at all -- every relax_u then returns + // R = 0. The pulse's velocity perturbation (~24 cm/s) rides on u0 = 2000 + // cm/s, so the face stays an inflow throughout. + const Real u0 = 2.0e3; + for (int i = -NG; i < cs.n + NG; i++) { + const Real x = (i + 0.5) * dx; + const Real dp = amp * cs.p0 * std::exp(-std::pow((x - x0) / w, 2)); + // LEFT-running isentropic pulse: u' = -dp/(rho c). + const Real p = cs.p0 + dp; + const Real rho = rho_ref + dp / (c0 * c0); + const Real u = u0 - dp / (rho_ref * c0); + Real T = 0.0; + eos.RYP2T(rho, Y, p, T); + set_state(f.at(i), rho, u, T, Y); + } + + pc_nscbc::Params prm; + prm.L_ref = cs.L; + prm.sigma = 0.0; // hi outflow: perfectly non-reflecting, out of the way + prm.relax_u = relax_u; + prm.relax_t = 0.2; + pc_nscbc::Target in, out; + in.type = pc_nscbc::Type::inflow; + in.u[0] = u0; + in.T = cs.T0; + for (int k = 0; k < NUM_SPECIES; k++) { + in.Y[k] = Y[k]; + } + out.type = pc_nscbc::Type::outflow; + out.p = cs.p0; + + const Real t_end = 1.6 * cs.L / c0; + const Real t_measure = 0.9 * cs.L / c0; // pulse hits the inflow at ~0.65 + Real t = 0.0; + Real peak_in = amp * cs.p0, peak_out = 0.0; + + while (t < t_end) { + Real cmax = 0.0; + for (int i = 0; i < cs.n; i++) { + const Prim q = get_prim(f.at(i)); + cmax = std::max(cmax, std::abs(q.u) + sound_speed(q)); + } + Real dt = cs.cfl * dx / cmax; + dt = std::min(dt, t_end - t); + if (dt <= 0.0) { + break; + } + fill_bcs(f, in, out, prm, dx); + stage(f, g, dx, dt); + fill_bcs(g, in, out, prm, dx); + stage(g, h, dx, dt); + for (int i = 0; i < cs.n; i++) { + for (int v = 0; v < NVAR; v++) { + f.at(i)[v] = 0.5 * (f.at(i)[v] + h.at(i)[v]); + } + } + t += dt; + + if (t > t_measure) { + Real pbar = 0.0; + for (int i = 0; i < cs.n; i++) { + pbar += get_prim(f.at(i)).p; + } + pbar /= cs.n; + for (int i = cs.n / 2; i < cs.n; i++) { + peak_out = std::max(peak_out, std::abs(get_prim(f.at(i)).p - pbar)); + } + } + } + return peak_out / peak_in; +} + +void +check_reflection(bool sweep) +{ + char buf[220]; + Real drift = 0.0; + const Real R2 = reflection_coefficient(0.25, 400, 2, false, &drift); + std::snprintf( + buf, sizeof(buf), "R = %.4f %% (sigma=0.25, n=400, order=2)", 100.0 * R2); + check(R2 < 0.01, "outflow reflection below 1%", buf); + + // Judge the extrapolation order at sigma = 0. At sigma = 0.25 the residual + // in the domain is dominated by the anchoring transient, not by reflection, + // so an order comparison there measures the wrong thing. + const Real R0o1 = reflection_coefficient(0.0, 400, 1, false, nullptr); + const Real R0o2 = reflection_coefficient(0.0, 400, 2, false, nullptr); + std::snprintf( + buf, sizeof(buf), "sigma=0: order1 R = %.5f %%, order2 R = %.5f %%", + 100.0 * R0o1, 100.0 * R0o2); + check( + R0o2 <= R0o1 * 1.05, "2nd-order extrapolation is not worse than 1st", buf); + + std::snprintf( + buf, sizeof(buf), "sigma=0 R = %.5f %%, sigma=0.25 R = %.5f %%", + 100.0 * R0o2, 100.0 * R2); + check(R0o2 <= R2 * 1.05, "sigma=0 is the least reflecting", buf); + + // The material-slope continuation must not disturb the ACOUSTIC behaviour: + // a right-running pulse has dR_- = 0 to linear order, so both the + // reflection and the sigma anchoring must come out essentially unchanged. + // This is the acoustic half of the extrap_material contract; C9(a) gates + // the material half. + Real drift_mat = 0.0; + const Real R2m = + reflection_coefficient(0.25, 400, 2, false, &drift_mat, true); + std::snprintf( + buf, sizeof(buf), + "R = %.4f %% (entropy %.4f %%), drift %.2f (entropy %.2f)", 100.0 * R2m, + 100.0 * R2, drift_mat, drift); + check(R2m < 0.01, "extrap_material keeps reflection below 1%", buf); + check( + std::abs(drift_mat) < 2.0 * std::abs(drift) + 0.5, + "extrap_material keeps the sigma anchoring", buf); + + // The inflow curve. R must rise monotonically with relax_u -- softer + // swallows more, stiffer walls more -- and the two ends must actually + // differ, or relax_u is a dial connected to nothing. + const Real Ri[4] = { + inflow_reflection(0.5, 400), inflow_reflection(2.0, 400), + inflow_reflection(10.0, 400), inflow_reflection(50.0, 400)}; + std::snprintf( + buf, sizeof(buf), + "R = %.3f / %.3f / %.3f / %.3f at relax_u = 0.5 / 2 / 10 / 50", Ri[0], + Ri[1], Ri[2], Ri[3]); + check( + (Ri[0] <= Ri[1] * 1.05) && (Ri[1] <= Ri[2] * 1.05) && + (Ri[2] <= Ri[3] * 1.05), + "inflow reflection rises monotonically with relax_u", buf); + check( + Ri[3] > 2.0 * Ri[0], "relax_u spans soft to stiff (the ends differ)", buf); + + if (sweep) { + std::printf("\n sigma sweep (n=400, order=2)\n"); + std::printf( + " %8s %12s %16s %14s\n", "sigma", "R [%]", "p drift [dyn/cm2]", + "tau_relax [s]"); + for (Real s : + {0.0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.5, 1.0, 2.0, 4.0, 8.0, + 16.0}) { + Real d = 0.0; + const Real R = reflection_coefficient(s, 400, 2, false, &d); + const Real tau = (s > 0.0) ? Case().L / (s * 34783.7) : 0.0; + std::printf(" %8.3f %12.5f %16.6e %14.4e\n", s, 100.0 * R, d, tau); + } + Real d = 0.0; + const Real Rp = reflection_coefficient(0.0, 400, 2, true, &d); + std::printf( + " %8s %12.5f %16.6e %14s (pin_farfield)\n", "--", 100.0 * Rp, d, + "0 (value pin)"); + } +} + +// C5: the relaxation rate must be a RATE -- grid-independent, and equal to +// K = sigma (1-M^2) c / L. This is the check that distinguishes the +// Poinsot-Lele parameterisation adopted here, as against a value-blend, +// whose effective rate is c/dx and therefore doubles when the mesh does. +void +check_relaxation_rate() +{ + Case cs; + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + auto eos = pele::physics::PhysicsType::eos(); + const Real sigma = 0.5; + + auto measure = [&](int n, bool mat = false) { + Case c2; + c2.n = n; + Field f(n), g(n), h(n); + const Real dx = c2.L / n; + const Real dp0 = 0.02 * c2.p0; + Real rho = 0.0, e = 0.0, T = 0.0; + eos.PYT2RE(c2.p0 + dp0, Y, c2.T0, rho, e); + eos.RYP2T(rho, Y, c2.p0 + dp0, T); + for (int i = -NG; i < n + NG; i++) { + set_state(f.at(i), rho, 0.0, T, Y); + } + const Prim q0 = get_prim(f.at(0)); + const Real c0 = sound_speed(q0); + + pc_nscbc::Params prm; + prm.L_ref = c2.L; + prm.sigma = sigma; + prm.extrap_material = mat; + pc_nscbc::Target out; + out.type = pc_nscbc::Type::outflow; + out.p = c2.p0; + // Both faces outflow so the box simply depressurises. + pc_nscbc::Target out2 = out; + + const Real t_end = 3.0 * c2.L / c0; + Real t = 0.0; + while (t < t_end) { + Real cmax = 0.0; + for (int i = 0; i < n; i++) { + const Prim q = get_prim(f.at(i)); + cmax = std::max(cmax, std::abs(q.u) + sound_speed(q)); + } + Real dt = std::min(c2.cfl * dx / cmax, t_end - t); + if (dt <= 0.0) { + break; + } + fill_bcs(f, out2, out, prm, dx); + stage(f, g, dx, dt); + fill_bcs(g, out2, out, prm, dx); + stage(g, h, dx, dt); + for (int i = 0; i < n; i++) { + for (int v = 0; v < NVAR; v++) { + f.at(i)[v] = 0.5 * (f.at(i)[v] + h.at(i)[v]); + } + } + t += dt; + } + Real pm = 0.0; + for (int i = 0; i < n; i++) { + pm += get_prim(f.at(i)).p; + } + pm /= n; + // exp decay over t_end + const Real ratio = std::max((pm - c2.p0) / dp0, 1e-12); + return std::make_pair(-std::log(ratio) / t_end, c0); + }; + + const auto r200 = measure(200); + const auto r800 = measure(800); + const Real K_expected = sigma * r200.second / Case().L; + char buf[240]; + std::snprintf( + buf, sizeof(buf), "K(n=200)=%.2f, K(n=800)=%.2f 1/s (ratio %.3f)", + r200.first, r800.first, r800.first / r200.first); + check( + std::abs(r800.first / r200.first - 1.0) < 0.15, + "relaxation rate is grid-independent", buf); + std::snprintf( + buf, sizeof(buf), "measured %.2f vs sigma*c/L = %.2f 1/s (ratio %.3f)", + r800.first, K_expected, r800.first / K_expected); + check( + r800.first / K_expected > 0.2 && r800.first / K_expected < 5.0, + "relaxation rate is within an order of K=sigma*c/L", buf); + + // The material-slope continuation adds a slope, not a rate: the relaxation + // must decay the same offset at the same K. + const auto r200m = measure(200, true); + std::snprintf( + buf, sizeof(buf), + "K = %.2f with extrap_material, %.2f without (ratio %.3f)", r200m.first, + r200.first, r200m.first / r200.first); + check( + std::abs(r200m.first / r200.first - 1.0) < 0.1, + "extrap_material leaves the relaxation rate unchanged", buf); +} + +// C6: robustness. Every fallback path must return a finite, physical state +// and must be counted. +void +check_fallbacks() +{ + Case cs; + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + auto eos = pele::physics::PhysicsType::eos(); + const Real dx = cs.L / cs.n; + pc_nscbc::Params prm; + prm.L_ref = cs.L; + pc_nscbc::Target out; + out.type = pc_nscbc::Type::outflow; + out.p = cs.p0; + + Real rho = 0.0, e = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho, e); + + amrex::Long diag[pc_nscbc::Diag::count] = {0}; + Real sN[NVAR], sg[NVAR]; + + // supersonic outflow + set_state(sN, rho, 8.0e4, cs.T0, Y); + pc_nscbc::apply(sN, sN, sN, 1, dx, 0, -1, 1, out, prm, sg, diag); + bool finite = true; + for (int v = 0; v < NVAR; v++) { + finite = finite && std::isfinite(sg[v]); + } + check( + finite && diag[pc_nscbc::Diag::supersonic] == 1 && sg[URHO] == sN[URHO], + "supersonic outflow -> exact zero-gradient copy", "counted, state finite"); + + // reversed flow at an outflow face + set_state(sN, rho, -3.0e3, cs.T0, Y); + pc_nscbc::apply(sN, sN, sN, 1, dx, 0, -1, 1, out, prm, sg, diag); + finite = true; + for (int v = 0; v < NVAR; v++) { + finite = finite && std::isfinite(sg[v]); + } + check( + finite && diag[pc_nscbc::Diag::reversed] == 1 && sg[URHO] > 0.0, + "reversed flow at outflow -> counted, standard closure", + "counted, state finite (continuity and relaxation gated by C13)"); + + // supersonic inflow: every characteristic is incoming, the FULL state is + // required. A Target without a pressure is counted (target_incomplete) + // and the interior pressure substituted -- visible, not silent; with the + // pressure supplied the imposition is exact. + { + pc_nscbc::Target sin_t; + sin_t.type = pc_nscbc::Type::inflow; + sin_t.T = 400.0; + sin_t.u[0] = -8.0e4; // into the domain through the hi face, supersonic + for (int k = 0; k < NUM_SPECIES; k++) { + sin_t.Y[k] = Y[k]; + } + set_state(sN, rho, -8.0e4, cs.T0, Y); + char buf[220]; + + pc_nscbc::apply(sN, sN, sN, 3, dx, 0, -1, 1, sin_t, prm, sg, diag); + const pc_nscbc::CellPrim qg0 = pc_nscbc::cell_primitives(sg); + std::snprintf( + buf, sizeof(buf), + "counted %lld; ghost T %.1f (target 400), p %.4g (interior %.4g " + "substituted)", + static_cast(diag[pc_nscbc::Diag::target_incomplete]), qg0.T, + qg0.p, cs.p0); + check( + (diag[pc_nscbc::Diag::target_incomplete] == 1) && + (std::abs(qg0.T - 400.0) < 1.0e-6 * 400.0) && + (std::abs(qg0.p - cs.p0) < 1.0e-6 * cs.p0), + "supersonic inflow without Target.p -> counted substitution", buf); + + sin_t.p = 1.2 * cs.p0; + pc_nscbc::apply(sN, sN, sN, 3, dx, 0, -1, 1, sin_t, prm, sg, diag); + const pc_nscbc::CellPrim qg1 = pc_nscbc::cell_primitives(sg); + std::snprintf( + buf, sizeof(buf), + "ghost p %.6g vs target %.6g, T %.2f, counter still " + "%lld", + qg1.p, 1.2 * cs.p0, qg1.T, + static_cast(diag[pc_nscbc::Diag::target_incomplete])); + check( + (diag[pc_nscbc::Diag::target_incomplete] == 1) && + (std::abs(qg1.p - 1.2 * cs.p0) < 1.0e-6 * cs.p0) && + (std::abs(qg1.T - 400.0) < 1.0e-6 * 400.0), + "supersonic inflow with Target.p -> exact full-state imposition", buf); + } + + // EB body state in the stencil + Real body[NVAR]; + for (int v = 0; v < NVAR; v++) { + body[v] = -1.0; + } + set_state(sN, rho, 3.0e3, cs.T0, Y); + const amrex::Long before = diag[pc_nscbc::Diag::body_state]; + pc_nscbc::apply(sN, body, body, 3, dx, 0, -1, 1, out, prm, sg, diag); + finite = true; + for (int v = 0; v < NVAR; v++) { + finite = finite && std::isfinite(sg[v]); + } + check( + finite && diag[pc_nscbc::Diag::body_state] > before && sg[URHO] > 0.0, + "covered cells in stencil -> order degraded, no FPE", + "counted, state finite"); + + // fully covered boundary cell + pc_nscbc::apply(body, body, body, 3, dx, 0, -1, 1, out, prm, sg, diag); + finite = true; + for (int v = 0; v < NVAR; v++) { + finite = finite && std::isfinite(sg[v]); + } + check(finite, "covered boundary cell -> finite fallback", "state finite"); +} + +// C13: the outflow closure must be CONTINUOUS through u_out = 0, and a +// transient reversal must still be RELAXED -- the ghost normal velocity +// has to respond outward when the interior is over-pressured, because +// that response is the only feedback resisting counter-gradient inflow. +// +// This is the NSCBC-Chamber defect gate +// (Docs/NSCBC-reversal-branch-defect.md): the dedicated reversal branch +// produced a ghost state that dropped dR+, S_p and T_in relative to the +// forward branch -- an O(1) discontinuity at u_out = 0 exactly where a +// flame finishes its transit -- and froze the ghost velocity, so a +// breathing vent saw no restoring push. The production A/B showed the +// flow dithering across the branch boundary with growing amplitude +// (-185 -> -962 cm/s in 0.25 ms) and a spurious 0.3 atm chamber spike. +// +// (a) sweeps the interior normal velocity through zero over a flame-like +// graded stencil (live dR+, and in reacting builds live S_p via +// beta_s = 0) and asserts the crossing jump in ghost p and ghost u_out +// is no outlier against the sweep's own smooth variation. +// (b) holds a uniform over-pressured reversed state and asserts the +// ghost pressure moves toward the target AND the ghost normal velocity +// moves outward from the interior's -- a frozen ghost velocity fails. +void +check_reversal_continuity() +{ + Case cs; + auto eos = pele::physics::PhysicsType::eos(); + const Real dx = cs.L / cs.n; + pc_nscbc::Params prm; + prm.L_ref = cs.L; + prm.sigma = 0.25; + prm.beta_s = 0.0; // the flame-crossing recipe: the reaction source is live + pc_nscbc::Target out; + out.type = pc_nscbc::Type::outflow; + out.p = cs.p0; + + // A flame-like face: hot boundary cell, mild inward T and u gradients, 8% + // overpressure so the relaxation has a defined restoring direction. + Real Y[NUM_SPECIES] = {0.0}; +#if NUM_REACTIONS == 0 + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } +#else + Y[H2_ID] = 0.0285; + Y[O2_ID] = 0.2265; + Y[N2_ID] = 0.7450; +#endif + const Real T_face = 1400.0; + const Real p_in = 1.08 * cs.p0; + const Real du = 500.0; // cm/s of normal-velocity gradient per cell + + // hi face: sgn = -1, so u_out = +u and positive u is outflow. + auto fill = [&](Real u_face, bool graded, Real* sg) { + Real sN[NVAR], sNm1[NVAR], sNm2[NVAR]; + const Real gT = graded ? 150.0 : 0.0; + const Real gu = graded ? du : 0.0; + Real rho = 0.0, e = 0.0; + eos.PYT2RE(p_in, Y, T_face, rho, e); + set_state(sN, rho, u_face, T_face, Y); + eos.PYT2RE(p_in, Y, T_face - gT, rho, e); + set_state(sNm1, rho, u_face - gu, T_face - gT, Y); + eos.PYT2RE(p_in, Y, T_face - 2.0 * gT, rho, e); + set_state(sNm2, rho, u_face - 2.0 * gu, T_face - 2.0 * gT, Y); + pc_nscbc::apply(sN, sNm1, sNm2, 3, dx, 0, -1, 1, out, prm, sg, nullptr); + }; + + // (a) the sweep. 10 cm/s steps across [-1200, 1200] cm/s -- fine enough to + // resolve the material-upwinding band below u_out = 0 -- and the crossing + // pair's jump in ghost p, u AND T must sit inside the sweep's own + // variation, not an order above it. + const int ns = 241; + const Real u_lo = -1200.0, u_hi = 1200.0; + std::vector pg(ns), ug(ns), tg(ns), uu(ns); + for (int i = 0; i < ns; i++) { + uu[i] = u_lo + (u_hi - u_lo) * static_cast(i) / (ns - 1); + Real sg[NVAR]; + fill(uu[i], true, sg); + const pc_nscbc::CellPrim qg = pc_nscbc::cell_primitives(sg); + pg[i] = qg.p; + ug[i] = qg.u[0]; // n_sgn = +1 on the hi face + tg[i] = qg.T; + } + Real jp_cross = 0.0, ju_cross = 0.0, jt_cross = 0.0; + Real jp_other = 0.0, ju_other = 0.0, jt_other = 0.0; + for (int i = 0; i + 1 < ns; i++) { + const Real jp = std::abs(pg[i + 1] - pg[i]); + const Real ju = std::abs(ug[i + 1] - ug[i]); + const Real jt = std::abs(tg[i + 1] - tg[i]); + const bool crossing = (uu[i] < 0.0) && (uu[i + 1] >= 0.0); + if (crossing) { + jp_cross = jp; + ju_cross = ju; + jt_cross = jt; + } else { + jp_other = std::max(jp_other, jp); + ju_other = std::max(ju_other, ju); + jt_other = std::max(jt_other, jt); + } + } + char buf[220]; + std::snprintf( + buf, sizeof(buf), + "crossing jump: p %.3e (<= %.3e elsewhere), u %.3e (<= %.3e), " + "T %.3e (<= %.3e)", + jp_cross, jp_other, ju_cross, ju_other, jt_cross, jt_other); + check( + (jp_cross <= 3.0 * jp_other + 1.0e-9 * cs.p0) && + (ju_cross <= 3.0 * ju_other + 1.0e-9 * 1.0e5) && + (jt_cross <= 3.0 * jt_other + 1.0e-6 * cs.T0), + "ghost state is continuous through u_out = 0", buf); + + // (b) reversal is still relaxed. Uniform over-pressured stencil, firmly + // reversed: no extrapolation content, so the only ghost response is the + // incoming-wave relaxation, and it must move p toward the target and u_out + // outward. A ghost velocity frozen at the interior's value is exactly the + // no-feedback closure that let the chamber runaway feed. + { + const Real u_rev = -600.0; + Real sg[NVAR], sN[NVAR]; + Real rho = 0.0, e = 0.0; + eos.PYT2RE(p_in, Y, T_face, rho, e); + set_state(sN, rho, u_rev, T_face, Y); + fill(u_rev, false, sg); + const pc_nscbc::CellPrim qN = pc_nscbc::cell_primitives(sN); + const pc_nscbc::CellPrim qg = pc_nscbc::cell_primitives(sg); + std::snprintf( + buf, sizeof(buf), "ghost du_out = %+.4e cm/s, ghost dp = %+.4e dyn/cm^2", + qg.u[0] - qN.u[0], qg.p - qN.p); + check( + (qg.u[0] > qN.u[0] + 1.0e-10 * qN.c) && (qg.p < qN.p), + "over-pressured reversal relaxes p AND pushes u_out outward", buf); + } + + // (c) reversal must NOT extrapolate material content. The stencil here is + // the chamber's fatal configuration: temperature falling TOWARD the face + // (cold gas at the boundary). Under firm backflow an extrapolated ghost T + // continues that ramp downward, the inflow advects the colder ghost gas + // back in, and the loop refrigerates the boundary cell to cryogenic + // temperatures while 1/(rho c) amplifies the relaxation -- the production + // vent run went 385 -> 241 -> 89 K in 42 us and NaN'd. The ghost + // temperature under firm reversal must be the interior cell's own. + { + const Real u_rev = -600.0; + Real sN[NVAR], sNm1[NVAR], sNm2[NVAR], sg[NVAR]; + Real rho = 0.0, e = 0.0; + const Real T_cold = 400.0; // face cell, coldest; T RISES inward + eos.PYT2RE(p_in, Y, T_cold, rho, e); + set_state(sN, rho, u_rev, T_cold, Y); + eos.PYT2RE(p_in, Y, T_cold + 300.0, rho, e); + set_state(sNm1, rho, u_rev, T_cold + 300.0, Y); + eos.PYT2RE(p_in, Y, T_cold + 600.0, rho, e); + set_state(sNm2, rho, u_rev, T_cold + 600.0, Y); + pc_nscbc::apply(sN, sNm1, sNm2, 3, dx, 0, -1, 1, out, prm, sg, nullptr); + const pc_nscbc::CellPrim qN = pc_nscbc::cell_primitives(sN); + const pc_nscbc::CellPrim qg = pc_nscbc::cell_primitives(sg); + std::snprintf( + buf, sizeof(buf), "ghost T = %.2f K vs interior %.2f K", qg.T, qN.T); + check( + std::abs(qg.T - qN.T) < 1.0e-3 * qN.T, + "firm reversal freezes material: ghost T is the interior's", buf); + } +} + +// C14: sustained recirculation. A duct held in steady INFLOW through a face +// configured as an outflow: the lo face relaxes hard toward a low +// pressure (the sink), the hi face is an outflow at ambient whose +// Target also carries the reservoir state (T, Y). Gas enters through +// the hi face for the whole run -- not a breath but a regime, the +// configuration queue item 2 was written for. The material question is +// what the entering gas carries: the frozen-material reversal closure +// recycles the interior state, so a hot domain drawing from a cold +// reservoir stays hot on its own exhaust forever (the measured negative +// control); with backflow_material the lambda_0 ghost content ramps to +// the reservoir state under firm reversal and the domain flushes cold +// on the advective clock. A static continuity probe confirms the ramp +// is inert at breathing amplitudes (|M| < 1e-3). +Real +c14_run_flush(bool backflow, Real T_hot, Real T_res, Real dp_sink, Real t_end) +{ + Case cs; + cs.n = 100; + cs.L = 2.5; + Field f(cs.n), g(cs.n), h(cs.n); + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + auto eos = pele::physics::PhysicsType::eos(); + const Real dx = cs.L / cs.n; + Real rho_h = 0.0, e_h = 0.0; + eos.PYT2RE(cs.p0, Y, T_hot, rho_h, e_h); + for (int i = -NG; i < cs.n + NG; i++) { + set_state(f.at(i), rho_h, 0.0, T_hot, Y); + } + + pc_nscbc::Params prm; + prm.L_ref = cs.L; + prm.sigma = 2.0; + prm.backflow_material = backflow; + pc_nscbc::Target sink, res; + sink.type = pc_nscbc::Type::outflow; + sink.p = cs.p0 - dp_sink; + res.type = pc_nscbc::Type::outflow; + res.p = cs.p0; + res.T = T_res; // the reservoir state the backflow may draw on + for (int k = 0; k < NUM_SPECIES; k++) { + res.Y[k] = Y[k]; + } + + Real t = 0.0; + while (t < t_end) { + Real cmax = 0.0; + for (int i = 0; i < cs.n; i++) { + const Prim q = get_prim(f.at(i)); + cmax = std::max(cmax, std::abs(q.u) + sound_speed(q)); + } + Real dt = cs.cfl * dx / cmax; + dt = std::min(dt, t_end - t); + if (dt <= 0.0) { + break; + } + fill_bcs(f, sink, res, prm, dx); + stage(f, g, dx, dt); + fill_bcs(g, sink, res, prm, dx); + stage(g, h, dx, dt); + for (int i = 0; i < cs.n; i++) { + for (int v = 0; v < NVAR; v++) { + f.at(i)[v] = 0.5 * (f.at(i)[v] + h.at(i)[v]); + } + } + t += dt; + } + // Mean temperature of the half nearest the reversed (hi) face. + Real Tm = 0.0; + for (int i = cs.n / 2; i < cs.n; i++) { + Tm += get_prim(f.at(i)).T; + } + return Tm / (cs.n - cs.n / 2); +} + +void +check_sustained_recirculation() +{ + const Real T_hot = 600.0, T_res = 300.0, dp_sink = 5.0e4; + const Real t_end = 4.0e-3; + char buf[220]; + + const Real T_frozen = c14_run_flush(false, T_hot, T_res, dp_sink, t_end); + const Real T_flush = c14_run_flush(true, T_hot, T_res, dp_sink, t_end); + std::snprintf( + buf, sizeof(buf), + "hi-half mean T: frozen closure %.1f K, backflow_material %.1f K " + "(interior 600, reservoir 300)", + T_frozen, T_flush); + check( + T_frozen > 0.8 * T_hot, + "frozen material recycles: the domain feeds on its own exhaust", buf); + check( + std::abs(T_flush - T_res) < 0.15 * T_res, + "backflow_material flushes to the reservoir state", buf); + + // Continuity at breathing amplitude: at |M| ~ 1e-4 the ramp must be inert + // -- the ghost with the flag on is the ghost with it off. + { + Case cs; + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + auto eos = pele::physics::PhysicsType::eos(); + const Real dx = cs.L / cs.n; + Real rho = 0.0, e = 0.0; + eos.PYT2RE(1.05 * cs.p0, Y, 500.0, rho, e); + Real sN[NVAR], sg_off[NVAR], sg_on[NVAR]; + set_state(sN, rho, -4.0, 500.0, Y); // |M| ~ 1e-4: a breath + pc_nscbc::Params prm; + prm.L_ref = cs.L; + pc_nscbc::Target res; + res.type = pc_nscbc::Type::outflow; + res.p = cs.p0; + res.T = 300.0; + for (int k = 0; k < NUM_SPECIES; k++) { + res.Y[k] = Y[k]; + } + prm.backflow_material = false; + pc_nscbc::apply(sN, sN, sN, 3, dx, 0, -1, 1, res, prm, sg_off); + prm.backflow_material = true; + pc_nscbc::apply(sN, sN, sN, 3, dx, 0, -1, 1, res, prm, sg_on); + Real dmax = 0.0; + for (int v = 0; v < NVAR; v++) { + dmax = std::max( + dmax, std::abs(sg_on[v] - sg_off[v]) / + std::max(std::abs(sg_off[v]), 1.0e-30)); + } + std::snprintf(buf, sizeof(buf), "max relative ghost difference %.2e", dmax); + check( + dmax < 1.0e-12, "the ramp is inert at breathing amplitudes (|M| ~ 1e-4)", + buf); + } +} + +// C8: does the ghost carry a sensible TEMPERATURE gradient? +// +// This is not a hyperbolic question. PeleC's diffusion operator forms the +// conductive and species fluxes at a physical boundary face from these same +// ghost cells, so whatever normal temperature gradient the ghost happens to +// carry IS the heat flux leaving the domain. Nothing in the characteristic +// algebra is chosen with that in mind: at an outflow the ghost density +// comes from the extrapolated entropy invariant and the ghost pressure from +// the acoustic pair, and T is then whatever the EOS returns. +// +// The test puts a flame-like temperature ramp on the boundary stencil at +// uniform pressure -- 300 K rising at 2e4 K/cm, which is what a hydrocarbon +// flame does -- and asks how far the ghost temperature is from the linear +// continuation of the interior profile, as a fraction of one cell's dT. +// A boundary with a controlled diffusive flux should sit near zero. +void +check_diffusive_gradient() +{ + const Case cs; + auto eos = pele::physics::PhysicsType::eos(); + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + + const Real dx = 1.0e-2; // 0.01 cm, ~10 cells through a flame + const Real dTdx = 2.0e4; // K/cm + const Real p0 = cs.p0; + const Real u0 = 3.0e3; // outflow, subsonic + + // Interior stencil N, N-1, N-2 at uniform pressure with a linear T ramp, + // T increasing toward the boundary as it does on the burnt side of a flame. + Real sN[NVAR], sM1[NVAR], sM2[NVAR], sg[NVAR]; + const Real T_N = 1400.0; + const Real T_M1 = T_N - dTdx * dx; + const Real T_M2 = T_N - 2.0 * dTdx * dx; + auto rho_of = [&](const Real T) { + Real r = 0.0, e = 0.0; + eos.PYT2RE(p0, Y, T, r, e); + return r; + }; + set_state(sN, rho_of(T_N), u0, T_N, Y); + set_state(sM1, rho_of(T_M1), u0, T_M1, Y); + set_state(sM2, rho_of(T_M2), u0, T_M2, Y); + + pc_nscbc::Params prm; + prm.sigma = 0.25; + prm.L_ref = 10.0; + prm.order = 2; + pc_nscbc::Target tgt; + tgt.type = pc_nscbc::Type::outflow; + tgt.p = p0; + + char buf[256]; + // Layer 1 is the one that sets the face flux. + pc_nscbc::apply(sN, sM1, sM2, 3, dx, 0, -1, 1, tgt, prm, sg); + const Real T_g = get_prim(sg).T; + const Real T_lin = T_N + dTdx * dx; // linear continuation + const Real dT_cell = dTdx * dx; // one cell's worth + const Real err = (T_g - T_lin) / dT_cell; + + std::snprintf( + buf, sizeof(buf), + "ghost T = %.2f K, linear continuation %.2f K, error %.3f of a cell dT", + T_g, T_lin, err); + // A tolerance of one full cell dT is deliberately loose: this check exists to + // MEASURE the discrepancy and put a number in the log, not to gate on a + // tight bound the current formulation was never designed to meet. + check( + std::abs(err) < 1.0, "ghost T within one cell dT of the interior ramp", + buf); + + // The implied conductive flux error, in the only units that matter. Reported + // rather than gated: lambda is problem-dependent, so the fraction is the + // transferable number. + std::snprintf( + buf, sizeof(buf), "implied face dT/dx is %.1f%% of the interior value", + 100.0 * (T_g - get_prim(sN).T) / dT_cell); + check(true, " (reported) face temperature gradient", buf); + + // Species: Y is minmod-extrapolated, so it should be a clean continuation. + // Uniform composition here, so the ghost must reproduce it exactly. + const Prim qg = get_prim(sg); + Real dYmax = 0.0; + for (int k = 0; k < NUM_SPECIES; k++) { + dYmax = std::max(dYmax, std::abs(qg.Y[k] - Y[k])); + } + std::snprintf(buf, sizeof(buf), "max |dY| = %.3e", dYmax); + check(dYmax < 1.0e-12, "uniform composition passes through unchanged", buf); + + // Now the temperature closure, which exists precisely to fix the above. + prm.extrap_temperature = true; + pc_nscbc::apply(sN, sM1, sM2, 3, dx, 0, -1, 1, tgt, prm, sg); + const Real T_gT = get_prim(sg).T; + const Real errT = (T_gT - T_lin) / dT_cell; + std::snprintf( + buf, sizeof(buf), "ghost T = %.2f K vs %.2f K linear, error %.2e of a cell", + T_gT, T_lin, errT); + check( + std::abs(errT) < 1.0e-9, "extrap_temperature reproduces the ramp exactly", + buf); + + std::snprintf( + buf, sizeof(buf), + "face dT/dx: entropy closure %.1f%%, temperature closure " + "%.1f%% of the interior value", + 100.0 * (T_g - get_prim(sN).T) / dT_cell, + 100.0 * (T_gT - get_prim(sN).T) / dT_cell); + check(true, " (reported) the two closures side by side", buf); + + // The point of the closure is the DIFFUSIVE flux, so it must not have cost + // anything on the hyperbolic side: a uniform state must still come back + // exactly, at every layer. + Real su[NVAR], sgu[NVAR]; + set_state(su, rho_of(cs.T0), u0, cs.T0, Y); + Real worst = 0.0; + for (int layer = 1; layer <= 4; layer++) { + pc_nscbc::apply(su, su, su, 3, dx, 0, -1, layer, tgt, prm, sgu); + for (int v = 0; v < NVAR; v++) { + const Real ref = std::abs(su[v]) > 1.0e-30 ? std::abs(su[v]) : 1.0; + worst = std::max(worst, std::abs(sgu[v] - su[v]) / ref); + } + } + std::snprintf(buf, sizeof(buf), "max rel ghost error = %.3e", worst); + check( + worst < 1.0e-12, "extrap_temperature still reproduces a uniform state", + buf); +} + +// C9: the ghost-pressure bias -- the mechanism blamed in +// Exec/RegTests/NSCBC-FlameOutflow for the mean-pressure error at a +// front-crossing outflow, reproduced here where nothing else can be +// responsible. +// +// The claim is that extrapolating the OUTGOING invariant R_+ = u_out + +// p/(rho c) across a region with a normal velocity gradient manufactures a +// ghost pressure that has nothing to do with any acoustic wave, because +// dR_+/dn there is dominated by dilatation. Two parts: +// +// (a) STATIC. With p_N already at the target the relaxation contributes +// nothing, so the algebra predicts exactly +// +// p_ghost - p_N = 1/2 rho c * layer * du_out +// +// and predicts zero at order = 1, where the slope is discarded. No +// fitted quantity: if the mechanism is what it is claimed to be, this +// is an identity. +// +// (b) DYNAMIC, and NOT an isolation of (a) -- see the note at the order +// control below. A prescribed heat band straddling the outflow, +// against a matched-heat band in the interior as a control. The +// difference is what the boundary added, it falls with sigma, and it +// has the same shape as the sigma sweep in +// Exec/RegTests/NSCBC-FlameOutflow. But the order = 1 control shows it +// is dominated by the unmodelled energy source in the boundary cells +// rather than by the extrapolation bias of part (a). Reported, not +// gated. +void +check_ghost_pressure_bias() +{ + const Case cs; + auto eos = pele::physics::PhysicsType::eos(); + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + char buf[256]; + + // ---- (a) static ------------------------------------------------------- + { + const Real dx = 2.5e-2; + const Real u_N = 4.0e3; // 40 m/s at the boundary cell + const Real du = 5.0e2; // 5 m/s per cell of ramp -> du/dn = 2e4 1/s + Real rho = 0.0, e = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho, e); + const Prim qref = [&] { + Prim q{}; + q.rho = rho; + q.u = u_N; + q.p = cs.p0; + q.T = cs.T0; + for (int k = 0; k < NUM_SPECIES; k++) { + q.Y[k] = Y[k]; + } + return q; + }(); + const Real c0 = sound_speed(qref); + + Real sN[NVAR], sM1[NVAR], sM2[NVAR], sg[NVAR]; + set_state(sN, rho, u_N, cs.T0, Y); + set_state(sM1, rho, u_N - du, cs.T0, Y); + set_state(sM2, rho, u_N - 2.0 * du, cs.T0, Y); + + pc_nscbc::Params prm; + prm.L_ref = cs.L; + prm.sigma = 0.25; + prm.order = 2; + pc_nscbc::Target out; + out.type = pc_nscbc::Type::outflow; + out.p = cs.p0; // p_N is already on target + + for (int layer = 1; layer <= 2; layer++) { + pc_nscbc::apply(sN, sM1, sM2, 3, dx, 0, -1, layer, out, prm, sg); + const Real p_g = get_prim(sg).p; + const Real predicted = 0.5 * rho * c0 * layer * du; + const Real rel = (p_g - cs.p0 - predicted) / predicted; + std::snprintf( + buf, sizeof(buf), + "layer %d: p_ghost - p_N = %.2f, predicted 1/2 rho c l du = %.2f " + "(rel %.2e)", + layer, p_g - cs.p0, predicted, rel); + check( + std::abs(rel) < 1.0e-6, "ghost pressure bias matches the algebra", buf); + } + + prm.order = 1; + pc_nscbc::apply(sN, sM1, sM2, 3, dx, 0, -1, 1, out, prm, sg); + const Real p_g1 = get_prim(sg).p; + std::snprintf( + buf, sizeof(buf), "order 1: p_ghost - p_N = %.3e (bias term discarded)", + p_g1 - cs.p0); + check( + std::abs(p_g1 - cs.p0) < 1.0e-6 * cs.p0, + "the bias is carried entirely by the extrapolation", buf); + + // The transit guard must stay quiet here: a velocity ramp at uniform + // density has dS = 0, and quiet-on-acoustics is the guard's whole value. + { + amrex::Long diag[pc_nscbc::Diag::count] = {0}; + prm.order = 2; + pc_nscbc::apply(sN, sM1, sM2, 3, dx, 0, -1, 1, out, prm, sg, diag); + std::snprintf( + buf, sizeof(buf), "structure count = %lld on a dS = 0 ramp", + static_cast(diag[pc_nscbc::Diag::structure])); + check( + diag[pc_nscbc::Diag::structure] == 0, + "transit guard is quiet without entropy structure", buf); + } + + // The fix, on the structure it exists for. The uniform-density ramp + // above is a synthetic state -- steady continuity does not admit it -- so + // the entropy-family bound in extrap_material correctly sees nothing + // there. Rebuild the ramp as the mass-conserving structure of C10 + // (rho u uniform, pressure carrying the momentum flux): the measured + // slope of R_- and the entropy bound then agree, the ghost continues the + // interior's u and p slopes, and the 1/2 rho c du bias is gone while + // order = 2 keeps the full structure that order = 1 throws away. + { + const Real mdot = rho * u_N; + auto ramp_state = [&](Real* s, const Real u) { + const Real rr = mdot / u; + const Real pp = cs.p0 + mdot * (u_N - u); // p_N lands on the target + Real TT = 0.0; + eos.RYP2T(rr, Y, pp, TT); + set_state(s, rr, u, TT, Y); + }; + ramp_state(sN, u_N); + ramp_state(sM1, u_N - du); + ramp_state(sM2, u_N - 2.0 * du); + const Real dp_cell = -mdot * du; // exact per-cell momentum-flux slope + const Real bias0 = 0.5 * rho * c0 * du; // what the entropy closure adds + + prm.order = 2; + prm.extrap_material = true; + amrex::Long diag[pc_nscbc::Diag::count] = {0}; + for (int layer = 1; layer <= 2; layer++) { + pc_nscbc::apply(sN, sM1, sM2, 3, dx, 0, -1, layer, out, prm, sg, diag); + const Prim qg = get_prim(sg); + const Real p_resid = qg.p - (cs.p0 + layer * dp_cell); + std::snprintf( + buf, sizeof(buf), + "layer %d: p_ghost - p_expected = %.2f (entropy closure bias %.1f), " + "u_ghost = %.1f (slope continued: %.1f)", + layer, p_resid, layer * bias0, qg.u, u_N + layer * du); + check( + std::abs(p_resid) < 0.05 * layer * bias0, + "extrap_material removes the ghost-pressure bias", buf); + check( + std::abs(qg.u - (u_N + layer * du)) < 0.05 * layer * du, + "extrap_material keeps the full du/dn in the ghost", buf); + } + prm.extrap_material = false; + + // ... and the transit guard must FIRE here: this ramp's per-cell + // density change is far past the 5% threshold, and it is exactly the + // structure whose crossing the sigma = 0.25 default does not survive. + std::snprintf( + buf, sizeof(buf), "structure count = %lld on the mass-conserving ramp", + static_cast(diag[pc_nscbc::Diag::structure])); + check( + diag[pc_nscbc::Diag::structure] > 0, + "transit guard fires on material structure", buf); + } + } + + // ---- (b) dynamic ------------------------------------------------------ + // A prescribed heat band sustains a velocity gradient. Two placements at + // matched total heat release: + // + // INTERIOR band at L/2 -- the flow has finished accelerating long before + // the outflow, so du/dn at the boundary is ~0 + // BOUNDARY band at L -- the gradient is IN the boundary cells, exactly + // as the flame straddles the outflow in + // Exec/RegTests/NSCBC-FlameOutflow + // + // Heating a duct at fixed inflow raises the mean pressure whatever the + // boundary does -- that is real physics, set by mass and energy balance, and + // it is the same in both placements once the total heat is matched. The + // DIFFERENCE is what the boundary added, and it is the only quantity here + // that the bias can be responsible for. + auto run_heated = [&]( + const Real sigma, const Real Qmax, const int n, + const Real xq_frac, const int order, Real& dudn_out) { + const Real dx = cs.L / n; + Field f(n), g(n), h(n); + Real rho0 = 0.0, e0 = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho0, e0); + const Real u_in = 3.0e3; + for (int i = -NG; i < n + NG; i++) { + set_state(f.at(i), rho0, u_in, cs.T0, Y); + } + + pc_nscbc::Params prm; + prm.L_ref = cs.L; + prm.sigma = sigma; + prm.order = order; + pc_nscbc::Target off, out; + out.type = pc_nscbc::Type::outflow; + out.p = cs.p0; + + const Real xq = xq_frac * cs.L, wq = 0.4; + std::vector qd(n, 0.0); + Real qtot = 0.0; + for (int i = 0; i < n; i++) { + qd[i] = std::exp(-std::pow(((i + 0.5) * dx - xq) / wq, 2)); + qtot += qd[i] * dx; + } + // Normalise so both placements deliver the same integrated heat, despite + // the boundary band being half outside the domain. + for (int i = 0; i < n; i++) { + qd[i] *= Qmax * wq * std::sqrt(M_PI) / qtot; + } + + const Real t_end = 4.0 * cs.L / u_in; + Real t = 0.0; + while (t < t_end) { + Real cmax = 0.0; + for (int i = 0; i < n; i++) { + const Prim q = get_prim(f.at(i)); + cmax = std::max(cmax, std::abs(q.u) + sound_speed(q)); + } + Real dt = std::min(cs.cfl * dx / cmax, t_end - t); + if (dt <= 0.0) { + break; + } + + for (int layer = 1; layer <= NG; layer++) { + set_state(f.at(-layer), rho0, u_in, cs.T0, Y); + } + fill_bcs(f, off, out, prm, dx); + stage(f, g, dx, dt); + for (int layer = 1; layer <= NG; layer++) { + set_state(g.at(-layer), rho0, u_in, cs.T0, Y); + } + fill_bcs(g, off, out, prm, dx); + stage(g, h, dx, dt); + for (int i = 0; i < n; i++) { + for (int v = 0; v < NVAR; v++) { + f.at(i)[v] = 0.5 * (f.at(i)[v] + h.at(i)[v]); + } + f.at(i)[UEDEN] += dt * qd[i]; + f.at(i)[UEINT] += dt * qd[i]; + Real* sp = f.at(i); + const Real rr = sp[URHO]; + const Real uu = sp[UMX] / rr; + Real Yl[NUM_SPECIES], ys = 0.0; + for (int k = 0; k < NUM_SPECIES; k++) { + Yl[k] = sp[UFS + k] / rr; + ys += Yl[k]; + } + for (int k = 0; k < NUM_SPECIES; k++) { + Yl[k] /= ys; + } + Real Tl = sp[UTEMP]; + eos.REY2T(rr, sp[UEDEN] / rr - 0.5 * uu * uu, Yl, Tl); + sp[UTEMP] = Tl; + } + t += dt; + } + + dudn_out = (get_prim(f.at(n - 1)).u - get_prim(f.at(n - 2)).u) / dx; + Real psum = 0.0; + for (int i = 0; i < n; i++) { + psum += get_prim(f.at(i)).p; + } + return psum / n - cs.p0; + }; + + const Real Q = 2.0e9; // erg/cm^3/s + const int NN = 200; + std::printf( + "\n %7s %9s %11s %11s %11s %11s\n", "sigma", "order", "du/dn int", + "du/dn bnd", "

int", "

bnd"); + Real bias[4]; + const Real sigs[4] = {0.25, 1.0, 4.0, 16.0}; + for (int k = 0; k < 4; k++) { + Real di = 0.0, db = 0.0; + const Real oi = run_heated(sigs[k], Q, NN, 0.5, 2, di); + const Real ob = run_heated(sigs[k], Q, NN, 1.0, 2, db); + bias[k] = ob - oi; + std::printf( + " %7.2f %9d %11.0f %11.0f %11.1f %11.1f\n", sigs[k], 2, di, db, oi, + ob); + } + std::printf(" bias (bnd - int): "); + for (int k = 0; k < 4; k++) { + std::printf("%10.1f", bias[k]); + } + std::printf("\n"); + + std::snprintf( + buf, sizeof(buf), "bias %.1f -> %.1f as sigma 0.25 -> 16 (x%.2f)", bias[0], + bias[3], bias[0] / (std::abs(bias[3]) > 1e-30 ? bias[3] : 1.0)); + check( + std::abs(bias[0]) > 2.0 * std::abs(bias[3]), + "a source AT the boundary adds an offset that sigma suppresses", buf); + + // The order control, and the reason the dynamic part of this check reports + // rather than gates. + // + // Statically (part a) order = 1 gives EXACTLY zero ghost-pressure bias. If + // the dynamic offset above were the extrapolation bias, dropping to first + // order would remove most of it. It does not -- the two agree to ~2%. So + // what the heat band actually measures is an unmodelled ENERGY SOURCE in the + // boundary cells, which is the beta_s situation, not the beta one, and at + // ~12% of ambient it swamps the extrapolation term entirely. + // + // A heat band cannot do better: there is no way to hold a velocity gradient + // at the boundary with a local source without also putting that source in the + // boundary cells. Isolating the extrapolation dynamically needs a + // source-free sustainer -- an established velocity/density ramp advected out + // through the face -- which is not built yet. Until it is, part (a) is the + // isolation and this part is context. + Real d1i = 0.0, d1b = 0.0; + const Real o1i = run_heated(1.0, Q, NN, 0.5, 1, d1i); + const Real o1b = run_heated(1.0, Q, NN, 1.0, 1, d1b); + std::snprintf( + buf, sizeof(buf), + "order 2 %.1f vs order 1 %.1f -- agree, so this is NOT the extrapolation", + bias[1], o1b - o1i); + check(true, " (reported) order control on the dynamic offset", buf); +} + +// --------------------------------------------------------------------------- +// fit_profile_ghosts -- the C11x profile-fit ghost closure, shared by the +// C10 (release) and C11 (sustained front) experiments. +// +// The closure knows a tanh profile FAMILY -- end states (u0, ratio_a*u0), +// rho = mdot/u, p = p0 + mdot*(u0 - u) -- but not its position or thickness; +// both are fitted per call by inverting T at the last two interior cells +// through the family (a closed-form value-and-slope match, stateless). It +// overwrites only the MATERIAL content of the kernel-filled ghosts: T from +// the fitted profile, rho from the EOS at the kernel's ghost pressure, and +// (with_u) the profile's u -- the dilatation structure. p always stays the +// kernel's. +// +// q_src >= 0 applies the SOURCE-CONSISTENCY bound: a steady front obeys +// du/dn = dp/dt|_src/(rho c^2) with dp/dt|_src = (gamma-1) q, so the +// continuation blends toward the plain kernel by +// w = min(1, du/dn_sustainable / du/dn_measured) -- inert on a front the +// source sustains, a full release on one it cannot. +// --------------------------------------------------------------------------- +void +fit_profile_ghosts( + Field& w, + const Real dx, + const Real u0, + const Real ratio_a, + const Real mdot, + const Real p0, + const Real Y[NUM_SPECIES], + const bool with_u, + const Real q_src) +{ + auto eos = pele::physics::PhysicsType::eos(); + auto T_of_u = [&](const Real u) { + const Real rho = mdot / u; + const Real p = p0 + mdot * (u0 - u); + Real T = 0.0; + eos.RYP2T(rho, Y, p, T); + return T; + }; + const Real ua = u0 * (1.0 + 1.0e-9); + const Real ub = u0 * ratio_a * (1.0 - 1.0e-9); + auto u_of_T = [&](const Real T) { + if (T <= T_of_u(ua)) { + return ua; + } + if (T >= T_of_u(ub)) { + return ub; + } + Real a = ua, b = ub; + for (int it = 0; it < 60; it++) { + const Real m = 0.5 * (a + b); + (T_of_u(m) < T ? a : b) = m; + } + return 0.5 * (a + b); + }; + const int N = w.n - 1; + const Prim qN = get_prim(w.at(N)); + const Prim qM = get_prim(w.at(N - 1)); + auto arg = [&](const Real T) { + Real g = (u_of_T(T) / u0 - 1.0) / (ratio_a - 1.0); + g = std::min(std::max(g, 1.0e-9), 1.0 - 1.0e-9); + return std::atanh(2.0 * g - 1.0); + }; + const Real aN = arg(qN.T); + const Real aM = arg(qM.T); + if (!(aN > aM) || !std::isfinite(aN) || !std::isfinite(aM)) { + return; // no usable structure in T; the kernel's ghosts stand + } + Real wgt = 1.0; + if (q_src >= 0.0) { + const Real c_N = sound_speed(qN); + const Real gam = qN.rho * c_N * c_N / qN.p; + const Real g_sus = (gam - 1.0) * q_src / (qN.rho * c_N * c_N); + const Real g_meas = (qN.u - qM.u) / dx; + wgt = (g_meas > 1.0e-12 * u0 / dx) ? std::min(1.0, g_sus / g_meas) : 0.0; + if (wgt <= 0.0) { + return; // nothing sustainable; released to the plain kernel + } + } + const Real lam = dx / (aN - aM); + const Real sh = (N + 0.5) * dx - lam * aN; + for (int layer = 1; layer <= NG; layer++) { + const int i = N + layer; + const Real x = (i + 0.5) * dx; + const Real gg = 0.5 * (1.0 + std::tanh((x - sh) / lam)); + const Real uf = u0 * (1.0 + (ratio_a - 1.0) * gg); + const Real rf = mdot / uf; + const Real pf = p0 + mdot * (u0 - uf); + Real Tf = 0.0; + eos.RYP2T(rf, Y, pf, Tf); + const Prim qg = get_prim(w.at(i)); // the kernel's fill: p always kept + const Real Tb = wgt * Tf + (1.0 - wgt) * qg.T; + const Real ub2 = with_u ? (wgt * uf + (1.0 - wgt) * qg.u) : qg.u; + Real rho_g = 0.0, e_g = 0.0; + eos.PYT2RE(qg.p, Y, Tb, rho_g, e_g); + set_state(w.at(i), rho_g, ub2, Tb, Y); + } +} + +// C10: does the ghost-pressure bias of C9(a) actually DRIVE the solution? +// +// C9(a) establishes the bias exactly, but statically. C9(b) tried to make +// it dynamic with a heat source and failed: a source strong enough to hold +// a velocity gradient at the boundary also deposits energy there, and the +// order control showed the source, not the extrapolation, setting the +// offset. C10 removes every source instead. +// +// The sustainer is a steady-flame structure with the chemistry taken out: +// uniform mass flux rho*u, velocity rising through a tanh ramp by an +// expansion ratio, density falling to match, and the pressure carrying the +// momentum flux so that rho*u^2 + p is uniform as well. It straddles the +// outflow, exactly as the sheet does in Exec/RegTests/NSCBC-FlameOutflow. +// Truth is the identical initial condition on a domain five times longer, +// whose own outflow is 40 cm from the common region -- far enough that +// going from three times to five times longer moved the answer by 0.1 in +// 9754, which is the shielding argument checked rather than asserted. +// +// THE RESULT IS NEGATIVE, AND IT IS THE POINT OF THE CHECK. A source-free +// expansion cannot be sustained in a constant-area duct. Mass, momentum +// and energy together admit no smooth steady state with du/dx != 0: with +// rho*u and rho*u^2 + p held uniform the energy flux still varies as +// (cp/R) p du/dx ~ 3.5e6 * du/dx, which is ~20% of rho*E per 3e-4 s. The +// run confirms it -- du/dn at the measurement point falls from 449 to -2 +// in the REFERENCE, whose boundary is 40 cm away and cannot be blamed. A +// flame's expansion is held up by its heat release; take the chemistry out +// and the expansion does not survive long enough to be advected out. +// +// So the two quantitative predictions from C9(a) -- error proportional to +// du_out, error proportional to 1/sigma -- cannot be tested here, and are +// reported rather than gated. C9(b) and C10 are the two horns: hold the +// gradient with a source and the source dominates; remove the source and +// the gradient does not persist. +// +// What survives is the order control, which changes ONLY the outgoing +// extrapolation and leaves everything else identical. Source-free it +// moves the accumulated pressure error by 5x (1477 -> 7591), so the +// extrapolation does drive the solution. But the sign is the opposite of +// the one assumed: order 1, which has no slope and therefore none of the +// C9(a) bias, is five times WORSE. Removing the bias term is not a fix. +// Likewise sigma: relaxing harder toward p_inf makes the error grow, not +// shrink, because p_inf is not the correct pressure while a structure is +// crossing the boundary. +void +check_extrapolation_drives_solution() +{ + const Case cs; + auto eos = pele::physics::PhysicsType::eos(); + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + char buf[256]; + + const int n = 200; // cells over cs.L = 10 cm, so dx = 0.05 cm + const Real dx = cs.L / n; + const Real u0 = 3.0e2; // cm/s, M ~ 0.01: quasi-frozen + const Real wr = 1.0; // ramp half-width, cm -- 20 cells, and wide enough + // that advecting it 0.2 cm leaves the gradient at the + // boundary essentially unchanged over the run + const Real t_end = 3.0e-4; // ~1 relaxation time at sigma = 1 + const int NS = 6; // history samples + + Real rho0 = 0.0, e0 = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho0, e0); + const Real mdot = rho0 * u0; + + // The ramp, centred on the outflow face at x = cs.L. + auto uofx = [&](const Real x, const Real ratio) { + const Real g = 0.5 * (1.0 + std::tanh((x - cs.L) / wr)); + return u0 * (1.0 + (ratio - 1.0) * g); + }; + + // Fill `nc` cells. rho*u is uniform, and the pressure carries the momentum + // flux so that rho*u^2 + p is uniform too -- otherwise the initial state is + // out of momentum balance by mdot*u0*(ratio-1) and rings acoustically from + // the first step for reasons that have nothing to do with the boundary. + auto init = [&](Field& f, const int nc, const Real ratio) { + for (int i = -NG; i < nc + NG; i++) { + const Real x = (i + 0.5) * dx; + const Real u = uofx(x, ratio); + const Real rho = mdot / u; + const Real p = cs.p0 + mdot * (u0 - u); + Real T = 0.0; + eos.RYP2T(rho, Y, p, T); + set_state(f.at(i), rho, u, T, Y); + } + }; + + // Advance `nc` cells to t_end with a characteristic outflow, sampling the + // mean pressure over the FIRST n cells (the region the short and the long + // domain share) and du/dn at the outflow face at NS times. + // bmode: 0 = plain kernel fill, 1 = extrap_material, 2 = profile-fitU -- + // the C11x closure (fit the tanh family's position and thickness to T at + // the last two interior cells each fill; overwrite ghost T and u from the + // fitted profile, rho from the EOS at the kernel's ghost pressure; p stays + // the relaxation's). Here the family is the run's own initial ramp, but + // the STRUCTURE IS DECAYING: the release question is whether the stateless + // re-fit lets it go (tracking the weakening interior slope, and bailing to + // the plain kernel when the structure is gone) or holds it alive at the + // face the way extrap_material measurably does. + auto run = [&]( + const int nc, const Real sigma, const int order, + const Real ratio, const int bmode, Real* pm, Real* gr) { + Field f(nc), g(nc), h(nc); + init(f, nc, ratio); + + pc_nscbc::Params prm; + prm.L_ref = nc * dx; + prm.sigma = sigma; + prm.order = order; + prm.extrap_material = (bmode == 1); + pc_nscbc::Target off, out; + out.type = pc_nscbc::Type::outflow; + out.p = cs.p0; + + auto sample = [&](const int k) { + Real psum = 0.0; + for (int i = 0; i < n; i++) { + psum += get_prim(f.at(i)).p; + } + pm[k] = psum / n; + gr[k] = (get_prim(f.at(n - 1)).u - get_prim(f.at(n - 2)).u) / dx; + }; + + Real t = 0.0; + int k = 0; + sample(k++); + while (k < NS) { + const Real t_next = t_end * static_cast(k) / (NS - 1); + while (t < t_next) { + Real cmax = 0.0; + for (int i = 0; i < nc; i++) { + const Prim q = get_prim(f.at(i)); + cmax = std::max(cmax, std::abs(q.u) + sound_speed(q)); + } + Real dt = std::min(cs.cfl * dx / cmax, t_next - t); + if (dt <= 0.0) { + break; + } + // Fixed inflow: this test is about the outflow only. + for (int layer = 1; layer <= NG; layer++) { + set_state(f.at(-layer), rho0, u0, cs.T0, Y); + } + fill_bcs(f, off, out, prm, dx); + if (bmode >= 2) { + fit_profile_ghosts( + f, dx, u0, ratio, mdot, cs.p0, Y, true, (bmode == 3) ? 0.0 : -1.0); + } + stage(f, g, dx, dt); + for (int layer = 1; layer <= NG; layer++) { + set_state(g.at(-layer), rho0, u0, cs.T0, Y); + } + fill_bcs(g, off, out, prm, dx); + if (bmode >= 2) { + fit_profile_ghosts( + g, dx, u0, ratio, mdot, cs.p0, Y, true, (bmode == 3) ? 0.0 : -1.0); + } + stage(g, h, dx, dt); + for (int i = 0; i < nc; i++) { + for (int v = 0; v < NVAR; v++) { + f.at(i)[v] = 0.5 * (f.at(i)[v] + h.at(i)[v]); + } + } + t += dt; + } + sample(k++); + } + }; + + // One shielded reference per (sigma, order, ratio). Its own outflow is 40 cm + // from the common region: the expanded gas reaches ~1200 K, where c ~ 7e4 + // cm/s, so 20 cm would only just be shielded over t_end and 40 cm is not. + auto err = [&]( + const Real sigma, const int order, const Real ratio, + const int bmode, Real* eh, Real* gh, Real* gr) { + Real pr[NS], pt[NS]; + run(5 * n, sigma, order, ratio, bmode, pr, gr); + run(n, sigma, order, ratio, bmode, pt, gh); + for (int k = 0; k < NS; k++) { + eh[k] = pt[k] - pr[k]; + } + }; + + Real eh[NS], gh[NS], gr[NS]; + auto row = [&]( + const Real sigma, const int order, const Real ratio, + const int bmode = 0) { + const char* bl[4] = {" ", " m", " f", " b"}; + err(sigma, order, ratio, bmode, eh, gh, gr); + std::printf(" %6.2f %6d %6.1f%s", sigma, order, ratio, bl[bmode]); + for (int k = 1; k < NS; k++) { + std::printf(" %9.1f", eh[k]); + } + std::printf( + " | du/dn %5.0f ->%5.0f (ref %5.0f ->%5.0f)\n", gh[0], gh[NS - 1], + gr[0], gr[NS - 1]); + return eh[NS - 1]; + }; + + std::printf( + "\n %6s %6s %6s %s\n", "sigma", "order", "ratio", + "

-

_ref at t_end/5 .. t_end"); + + // --- prediction 1: error proportional to du_out ------------------------ + const Real e_r2 = row(1.0, 2, 2.0); + const Real g_r2 = gh[0]; + const Real e_r4 = row(1.0, 2, 4.0); + const Real g_r4 = gh[0]; + const Real g_ratio = g_r4 / (std::abs(g_r2) > 1e-30 ? g_r2 : 1.0); + const Real e_ratio = e_r4 / (std::abs(e_r2) > 1e-30 ? e_r2 : 1.0); + std::snprintf( + buf, sizeof(buf), + "du/dn(0) x%.2f -> error x%.2f -- not a law, the ramp is gone by t_end", + g_ratio, e_ratio); + report("error vs the velocity gradient", buf); + + // --- prediction 2: error proportional to 1/sigma ----------------------- + const Real e_lo = row(0.5, 2, 4.0); + row(2.0, 2, 4.0); + const Real e_hi = row(8.0, 2, 4.0); + std::snprintf( + buf, sizeof(buf), + "sigma 0.5 -> 8 (x16): error %.1f -> %.1f -- stronger relaxation is WORSE", + e_lo, e_hi); + report("error vs the relaxation strength", buf); + + // --- the order control, which is the whole point ----------------------- + const Real e_o1 = row(1.0, 1, 4.0); + std::snprintf( + buf, sizeof(buf), "order 2 error %.1f, order 1 error %.1f (order 1 worse)", + e_r4, e_o1); + check( + std::abs(e_o1 - e_r4) > 0.25 * std::abs(e_r4), + "the outgoing extrapolation drives the solution", buf); + + // --- extrap_material on the same configuration ------------------------- + // Reported, not gated, and the sign of the result matters more than its + // size: the continuation HOLDS the ramp at the boundary (du/dn stays high + // where the reference decays to zero), so the short domain keeps venting a + // structure whose exact solution is busy dying. That is not the fix + // misbehaving -- it is C10's own negative result seen from the other side: + // a source-free ramp is not sustainable, so a boundary condition that + // faithfully continues the structure it sees disagrees with a reference in + // which that structure decays. The gate for the fix is C11, where the + // ramp is SUSTAINED and the exact answer is to hold it. What this row + // establishes is the honest cost: do not leave extrap_material on at a + // boundary whose structure is transient and should be allowed to die out. + const Real e_mat = row(1.0, 2, 4.0, 1); + std::snprintf( + buf, sizeof(buf), + "error at t_end: %.1f without, %.1f with -- the continuation holds a " + "DECAYING ramp alive at the face", + e_r4, e_mat); + report("extrap_material on a decaying (unsustainable) ramp", buf); + + // --- C11x release test: the profile-fit on the same dying ramp --------- + // The C11 result (fitU holds a SUSTAINED front at oracle level) is only + // half a qualification: extrap_material also helps there and then fails + // HERE, holding this unsustainable ramp alive at the face while the + // reference lets it die. A usable structure closure must pass both. The + // hoped-for release mechanism is in the fit itself: value-and-slope + // matching tracks the weakening interior gradient (lambda grows as the + // ramp dies), and when no monotone structure remains the fit declines and + // the plain kernel fill stands. Reported, not gated. + const Real e_fit = row(1.0, 2, 4.0, 2); + const Real g_fit_end = gh[NS - 1]; + std::snprintf( + buf, sizeof(buf), + "error at t_end: plain %.1f, extrap_material %.1f, profile-fitU %.1f; " + "du/dn at face -> %.0f (ref -> %.0f)", + e_r4, e_mat, e_fit, g_fit_end, gr[NS - 1]); + report("C11x release: the fit on a ramp that must be let go", buf); + + // --- and the same fit behind the source-consistency bound --------------- + // The closure measures the local source (here exactly zero), computes the + // sustainable dilatation, and refuses any continuation beyond it. On a + // source-free ramp that is a full refusal, so this row should reproduce + // the plain-kernel row: the release is complete, by construction rather + // than by decay-tracking. + const Real e_fitB = row(1.0, 2, 4.0, 3); + std::snprintf( + buf, sizeof(buf), + "error at t_end: plain %.1f, unbounded fitU %.1f, source-bounded %.1f " + "-- the bound releases what the source cannot sustain", + e_r4, e_fit, e_fitB); + report("C11x release under the source-consistency bound", buf); +} + +// C11: the sustained ramp -- a front crossing the outflow, with an exact +// steady solution. This is the test C9(b) and C10 both said was +// missing: C9(b)'s heat band could not hold a velocity gradient at the +// boundary without dumping unmodelled energy into the boundary cells, +// and C10's source-free ramp could not hold it at all. The resolution +// is manufactured: take the mass- and momentum-consistent ramp of C10 +// (rho u uniform, p carrying the momentum flux) and add the energy +// source S_E(x) = mdot dH/dx that makes it an EXACT steady solution of +// the sourced Euler equations -- which is precisely a flame's mechanical +// structure sustained by its heat release, minus the chemistry. +// +// The exact solution is the initial condition, indefinitely. A perfect +// boundary holds it; every departure is boundary error. The entropy +// closure converts the ramp's du/dn into ghost pressure (C9(a)) and the +// domain drifts to the equilibrium offset that NSCBC-FlameOutflow +// measures; extrap_material continues the structure and must hold both +// the mean pressure AND the du/dn at the face. +void +check_sustained_ramp() +{ + const Case cs; + auto eos = pele::physics::PhysicsType::eos(); + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + char buf[256]; + + const int n = 200; + const Real dx = cs.L / n; + const Real u0 = 3.0e2; + const Real ratio = 4.0; + const Real wr = 1.0; + const Real t_end = 6.0e-4; // ~2 relaxation times at sigma = 1 + const int NS = 4; + + Real rho0 = 0.0, e0 = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho0, e0); + const Real mdot = rho0 * u0; + + // The TRUE front shape. 0 = tanh (the family the fit closure assumes); + // 1 = a Richards curve with k = 3, centred so g = 1/2 at the boundary: + // the same end states and width scale but genuinely outside the tanh + // family -- e^{3 xi} approach on the fresh side, a slow e^{-xi} tail on + // the burnt side, the way a real flame's preheat structure is one-sided. + int true_shape = 0; + auto uofx = [&](const Real x) { + const Real xi = (x - cs.L) / wr; + Real g; + if (true_shape == 0) { + g = 0.5 * (1.0 + std::tanh(xi)); + } else { + const Real x0 = 1.3475805944; // -ln(2^{1/3} - 1): g(0) = 1/2 + g = std::pow(1.0 + std::exp(-(xi + x0)), -3.0); + } + return u0 * (1.0 + (ratio - 1.0) * g); + }; + // Total enthalpy of the exact profile at x. + auto Hofx = [&](const Real x) { + const Real u = uofx(x); + const Real rho = mdot / u; + const Real p = cs.p0 + mdot * (u0 - u); + Real T = 0.0, e = 0.0; + eos.RYP2T(rho, Y, p, T); + eos.RTY2E(rho, T, Y, e); + return e + p / rho + 0.5 * u * u; + }; + + auto init = [&](Field& f, const int nc) { + for (int i = -NG; i < nc + NG; i++) { + const Real x = (i + 0.5) * dx; + const Real u = uofx(x); + const Real rho = mdot / u; + const Real p = cs.p0 + mdot * (u0 - u); + Real T = 0.0; + eos.RYP2T(rho, Y, p, T); + set_state(f.at(i), rho, u, T, Y); + } + }; + + // The manufactured energy source, cell-centred, frozen in time. + auto make_source = [&](const int nc) { + std::vector q(nc, 0.0); + for (int i = 0; i < nc; i++) { + const Real x = (i + 0.5) * dx; + q[i] = mdot * (Hofx(x + 0.5 * dx) - Hofx(x - 0.5 * dx)) / dx; + } + return q; + }; + + auto add_source = [&](Field& f, const std::vector& q, const Real dt) { + for (int i = 0; i < f.n; i++) { + Real* s = f.at(i); + s[UEDEN] += dt * q[i]; + s[UEINT] += dt * q[i]; + const Real rho = s[URHO]; + Real Yl[NUM_SPECIES], ys = 0.0; + for (int k = 0; k < NUM_SPECIES; k++) { + Yl[k] = std::max(s[UFS + k] / rho, 0.0); + ys += Yl[k]; + } + for (int k = 0; k < NUM_SPECIES; k++) { + Yl[k] /= ys; + } + const Real e = s[UEINT] / rho; + Real T = s[UTEMP] > 0.0 ? s[UTEMP] : 300.0; + eos.REY2T(rho, e, Yl, T); + s[UTEMP] = T; + } + }; + + // Advance nc cells to t_end; sample mean p over the first n cells and + // du/dn at x = cs.L (the short domain's outflow face) at NS times. + // mode: 0 = entropy closure, 1 = extrap_material, 2 = ORACLE -- the hi + // ghosts are overwritten with the exact profile every fill, i.e. the best + // any ghost-cell closure can possibly do. The oracle is the yardstick the + // closures are gated against: the truncated discrete problem has its own + // attractor (see below), and no ghost fill can beat the oracle's. + auto run = + [&](const int nc, const Real sigma, const int mode, Real* pm, Real* gr) { + Field f(nc), g(nc), h(nc); + init(f, nc); + const auto q = make_source(nc); + + auto oracle_ghosts = [&](Field& w) { + auto eos2 = pele::physics::PhysicsType::eos(); + for (int layer = 1; layer <= NG; layer++) { + const int i = w.n - 1 + layer; + const Real x = (i + 0.5) * dx; + const Real u = uofx(x); + const Real rho = mdot / u; + const Real p = cs.p0 + mdot * (u0 - u); + Real T = 0.0; + eos2.RYP2T(rho, Y, p, T); + set_state(w.at(i), rho, u, T, Y); + } + }; + + pc_nscbc::Params prm; + prm.L_ref = nc * dx; + prm.sigma = sigma; + prm.extrap_material = (mode == 1); + prm.extrap_temperature = (mode == 3); + pc_nscbc::Target off, out; + out.type = pc_nscbc::Type::outflow; + out.p = cs.p0 + mdot * (u0 - uofx(cs.L)); // the exact face pressure + + auto sample = [&](const int k) { + Real psum = 0.0; + for (int i = 0; i < n; i++) { + psum += get_prim(f.at(i)).p; + } + pm[k] = psum / n; + gr[k] = (get_prim(f.at(n - 1)).u - get_prim(f.at(n - 2)).u) / dx; + }; + + Real t = 0.0; + int k = 0; + sample(k++); + while (k < NS) { + const Real t_next = t_end * static_cast(k) / (NS - 1); + while (t < t_next) { + Real cmax = 0.0; + for (int i = 0; i < nc; i++) { + const Prim qq = get_prim(f.at(i)); + cmax = std::max(cmax, std::abs(qq.u) + sound_speed(qq)); + } + Real dt = std::min(cs.cfl * dx / cmax, t_next - t); + if (dt <= 0.0) { + break; + } + for (int layer = 1; layer <= NG; layer++) { + set_state(f.at(-layer), rho0, u0, cs.T0, Y); + } + fill_bcs(f, off, out, prm, dx); + if (mode == 2) { + oracle_ghosts(f); + } else if (mode >= 4) { + fit_profile_ghosts( + f, dx, u0, (mode == 5 || mode == 7) ? 0.85 * ratio : ratio, mdot, + cs.p0, Y, mode >= 6, (mode == 8) ? q[nc - 1] : -1.0); + } + stage(f, g, dx, dt); + add_source(g, q, dt); + for (int layer = 1; layer <= NG; layer++) { + set_state(g.at(-layer), rho0, u0, cs.T0, Y); + } + fill_bcs(g, off, out, prm, dx); + if (mode == 2) { + oracle_ghosts(g); + } else if (mode >= 4) { + fit_profile_ghosts( + g, dx, u0, (mode == 5 || mode == 7) ? 0.85 * ratio : ratio, mdot, + cs.p0, Y, mode >= 6, (mode == 8) ? q[nc - 1] : -1.0); + } + stage(g, h, dx, dt); + add_source(h, q, dt); + for (int i = 0; i < nc; i++) { + for (int v = 0; v < NVAR; v++) { + f.at(i)[v] = 0.5 * (f.at(i)[v] + h.at(i)[v]); + } + } + t += dt; + } + sample(k++); + } + }; + + const Real g0 = + (uofx(cs.L - 0.5 * dx) - uofx(cs.L - 1.5 * dx)) / dx; // initial du/dn + + // ---- The face flux, statically ----------------------------------------- + // Before any dynamics: fill the ghosts from the exact interior state with + // each closure, reconstruct the boundary face exactly as stage() does, and + // compare the HLLC flux against the exact steady flux the face must carry + // (mdot, mdot u + p, mdot H). This is the flux-level form of C9(a), and it + // has no translational mode to hide behind (see below). + { + auto face_flux = [&](const bool mat, Real* flx) { + Field f(n); + init(f, n); + pc_nscbc::Params prm; + prm.L_ref = cs.L; + prm.sigma = 1.0; + prm.extrap_material = mat; + pc_nscbc::Target off, out; + out.type = pc_nscbc::Type::outflow; + out.p = cs.p0 + mdot * (u0 - uofx(cs.L)); + fill_bcs(f, off, out, prm, dx); + Real sl[NVAR], sr[NVAR]; + for (int v = 0; v < NVAR; v++) { + const Real dL = + mm(f.at(n - 1)[v] - f.at(n - 2)[v], f.at(n)[v] - f.at(n - 1)[v]); + const Real dR = + mm(f.at(n)[v] - f.at(n - 1)[v], f.at(n + 1)[v] - f.at(n)[v]); + sl[v] = f.at(n - 1)[v] + 0.5 * dL; + sr[v] = f.at(n)[v] - 0.5 * dR; + } + auto to_prim_face = [&](Real* s) { + Prim q{}; + q.rho = s[URHO]; + q.u = s[UMX] / q.rho; + Real ys = 0.0; + for (int k = 0; k < NUM_SPECIES; k++) { + q.Y[k] = std::max(s[UFS + k] / q.rho, 0.0); + ys += q.Y[k]; + } + for (int k = 0; k < NUM_SPECIES; k++) { + q.Y[k] /= ys; + } + const Real e = s[UEDEN] / q.rho - 0.5 * q.u * q.u; + q.T = 300.0; + eos.REY2T(q.rho, e, q.Y, q.T); + eos.RTY2P(q.rho, q.T, q.Y, q.p); + return q; + }; + hllc(to_prim_face(sl), to_prim_face(sr), flx); + }; + const Real F_mass = mdot; + const Real F_ener = mdot * Hofx(cs.L); + Real fe[NVAR], fm[NVAR]; + face_flux(false, fe); + face_flux(true, fm); + const Real ee_ent = fe[UEDEN] - F_ener; + const Real ee_mat = fm[UEDEN] - F_ener; + const Real em_ent = fe[URHO] - F_mass; + const Real em_mat = fm[URHO] - F_mass; + std::snprintf( + buf, sizeof(buf), + "energy flux error: entropy %.3e, extrap_material %.3e (exact %.3e); " + "mass: %.2e vs %.2e (exact %.2e)", + ee_ent, ee_mat, F_ener, em_ent, em_mat, F_mass); + check( + std::abs(ee_mat) < 0.35 * std::abs(ee_ent) && + std::abs(em_mat) < 0.5 * std::abs(em_ent), + "extrap_material corrects the boundary-face flux", buf); + } + + // The reference: same sourced problem, outflow 4 L downstream. It must + // HOLD the steady state; its residual drift is the discretisation floor. + Real pr[NS], gref[NS]; + run(5 * n, 1.0, 0, pr, gref); + std::snprintf( + buf, sizeof(buf), + "reference

drift %.1f dyn/cm2 over %.0e s, du/dn %.0f -> %.0f (of " + "%.0f)", + pr[NS - 1] - pr[0], t_end, gref[0], gref[NS - 1], g0); + check( + std::abs(pr[NS - 1] - pr[0]) < 150.0, + "the manufactured steady state holds in the long domain", buf); + + // The truncated domain has its OWN discrete attractor: cutting the ramp + // mid-structure and representing its continuation with 4 ghost cells + // shifts the balance point, for ANY ghost fill. The oracle row measures + // that attractor -- it is the floor no ghost-cell closure can beat -- so + // the closures are judged by their distance from the oracle, not from the + // long reference. + std::printf( + "\n sigma ghost

-

_ref at t_end/3 .. t_end | du/dn at the " + "face\n"); + Real pt[NS], gt[NS]; + const char* label[9] = {"ent", "mat", "orc", "e_T", "fit", + "fitX", "fitU", "fitUX", "fitB"}; + auto row = [&](const Real sigma, const int mode) { + run(n, sigma, mode, pt, gt); + std::printf(" %6.2f %5s ", sigma, label[mode]); + for (int k = 1; k < NS; k++) { + std::printf(" %9.1f", pt[k] - pr[k]); + } + std::printf(" | %5.0f -> %5.0f (exact %.0f)\n", gt[0], gt[NS - 1], g0); + return pt[NS - 1] - pr[NS - 1]; + }; + + const Real e_orc = row(1.0, 2); + const Real g_orc = gt[NS - 1]; + const Real g1_orc = gt[1]; + const Real e1_orc = pt[1] - pr[1]; + const Real e_ent1 = row(1.0, 0); + const Real g_ent = gt[NS - 1]; + const Real g1_ent = gt[1]; + const Real e1_ent = pt[1] - pr[1]; + const Real e_ent4 = row(4.0, 0); + const Real e_mat1 = row(1.0, 1); + const Real g_mat = gt[NS - 1]; + const Real g1_mat = gt[1]; + const Real e1_mat = pt[1] - pr[1]; + row(1.0, 3); + const Real g_eT = gt[NS - 1]; + const Real e1_eT = pt[1] - pr[1]; + row(1.0, 4); + const Real g_fit = gt[NS - 1]; + const Real e1_fit = pt[1] - pr[1]; + row(1.0, 5); + const Real g_fitX = gt[NS - 1]; + const Real e1_fitX = pt[1] - pr[1]; + const Real eE_fitU = row(1.0, 6); + const Real g_fitU = gt[NS - 1]; + const Real e1_fitU = pt[1] - pr[1]; + row(1.0, 7); + const Real g_fitUX = gt[NS - 1]; + const Real e1_fitUX = pt[1] - pr[1]; + const Real eE_fitB = row(1.0, 8); + const Real g_fitB = gt[NS - 1]; + const Real e1_fitB = pt[1] - pr[1]; + + // What the oracle row establishes: a ghost fill that carries the exact + // continuation HOLDS the sustained front, in this same truncated domain, + // with this same solver -- so nothing below can be blamed on the + // architecture. What the late-time columns then measure is an artefact of + // the MANUFACTURED source: q(x) is frozen in space, so once a closure's + // early flux error has nudged the structure off its source the mismatch + // feeds itself and every non-oracle run walks to the same shifted + // equilibrium (~+27000 here, sigma-independent -- note sigma 1 vs 4). A + // real flame carries its source with its front and has no such mode, which + // is why the gates below sit inside the boundary-equilibration window + // (t_end/3 ~ 0.7 relaxation times), where the columns still measure the + // boundary. + std::snprintf( + buf, sizeof(buf), + "late-time error: ent %.1f, mat %.1f, oracle %.1f -- the frozen source, " + "not the boundary", + e_ent1, e_mat1, e_orc); + report("the truncated MMS walks off its source at late time", buf); + std::snprintf( + buf, sizeof(buf), + "sigma 1 -> 4 at late time: %.1f -> %.1f (the C9(a) " + "bias would fall x4)", + e_ent1, e_ent4); + report("the late-time offset is not the C9(a) bias", buf); + + std::snprintf( + buf, sizeof(buf), + "error at 0.7 tau: %.1f with extrap_material, %.1f without, oracle %.1f", + e1_mat, e1_ent, e1_orc); + check( + std::abs(e1_mat - e1_orc) < 0.35 * std::abs(e1_ent - e1_orc), + "extrap_material holds the front while the boundary equilibrates", buf); + + // Gated at 0.7 tau, the same window as the pressure gate and for the same + // reason (see the frozen-source caveat above); the t_end values are + // reported for context but sit in the artifact-dominated region. + std::snprintf( + buf, sizeof(buf), + "du/dn at 0.7 tau: %.0f with, %.0f without, oracle %.0f (exact %.0f; " + "t_end: %.0f / %.0f / %.0f)", + g1_mat, g1_ent, g1_orc, g0, g_mat, g_ent, g_orc); + check( + std::abs(g1_mat - g1_orc) < 0.5 * std::abs(g1_ent - g1_orc), + "extrap_material preserves the structure the oracle preserves", buf); + + // ---- C11x: the profile-fit experiment (reported, not gated) ------------ + // The question: how much of the oracle's advantage does a FITTED library + // profile recover, when it supplies material structure only? The fraction + // below is (e_T - fit) / (e_T - oracle) in the early window -- 1.0 means + // the fit is as good as knowing the exact answer, 0.0 means it bought + // nothing over linear-in-T ghosts. fitX is the same fit through a family + // whose end state is 15% wrong: the stretch/curvature analogue. + { + const Real denom = e1_eT - e1_orc; + const Real frac = + (std::abs(denom) > 1.0e-30) ? (e1_eT - e1_fit) / denom : 0.0; + const Real fracX = + (std::abs(denom) > 1.0e-30) ? (e1_eT - e1_fitX) / denom : 0.0; + std::snprintf( + buf, sizeof(buf), + "at 0.7 tau: ent %.1f, e_T %.1f, fit %.1f (fitX %.1f), mat %.1f, " + "fitU %.1f (fitUX %.1f), oracle %.1f", + e1_ent, e1_eT, e1_fit, e1_fitX, e1_mat, e1_fitU, e1_fitUX, e1_orc); + report("C11x the ladder from line to oracle", buf); + const Real denomU = e1_mat - e1_orc; + const Real fracU = + (std::abs(denomU) > 1.0e-30) ? (e1_mat - e1_fitU) / denomU : 0.0; + std::snprintf( + buf, sizeof(buf), + "material-only fit recovers %.0f%% of the e_T -> oracle gap (%.0f%% " + "with the 15%%-wrong family); with the profile's u, %.0f%% of the " + "mat -> oracle gap", + 100.0 * frac, 100.0 * fracX, 100.0 * fracU); + report("C11x profile-fit recovery fractions", buf); + std::snprintf( + buf, sizeof(buf), + "du/dn at t_end: e_T %.0f, fit %.0f, mat %.0f, fitU %.0f (fitUX " + "%.0f), oracle %.0f (exact %.0f)", + g_eT, g_fit, g_mat, g_fitU, g_fitUX, g_orc, g0); + report("C11x structure at the face", buf); + std::snprintf( + buf, sizeof(buf), + "source-bounded fitU on the SUSTAINED front: %.1f at 0.7 tau, %.1f at " + "t_end, du/dn -> %.0f (unbounded fitU %.1f / %.1f / %.0f)", + e1_fitB, eE_fitB, g_fitB, e1_fitU, eE_fitU, g_fitU); + report("C11x the bound must not tax the sustained front", buf); + } + + // ---- C11x shape distortion: the truth is not in the family ------------- + // The fitX row above distorted the family's END STATE and the fit absorbed + // it; this block distorts the SHAPE. The truth becomes the Richards front + // (asymmetric, outside any tanh), the manufactured source and the oracle + // follow it automatically, and the fit still assumes tanh. What survives + // of the 97% is the measure of how much the closure leans on the library + // profile actually matching the flame. + { + true_shape = 1; + Real prd[NS], grd[NS], p_td[NS], g_td[NS]; + run(5 * n, 1.0, 0, prd, grd); // fresh shielded reference, distorted truth + const Real g0d = (uofx(cs.L - 0.5 * dx) - uofx(cs.L - 1.5 * dx)) / dx; + std::printf( + "\n distorted truth (Richards k=3), tanh-family fit; exact du/dn " + "%.0f\n", + g0d); + auto rowd = [&](const int mode) { + run(n, 1.0, mode, p_td, g_td); + std::printf(" %6.2f %5s ", 1.0, label[mode]); + for (int k = 1; k < NS; k++) { + std::printf(" %9.1f", p_td[k] - prd[k]); + } + std::printf( + " | %5.0f -> %5.0f (exact %.0f)\n", g_td[0], g_td[NS - 1], g0d); + return p_td[1] - prd[1]; + }; + const Real d1_orc = rowd(2); + const Real d1_ent = rowd(0); + const Real d1_fitU = rowd(6); + const Real dE_fitU = p_td[NS - 1] - prd[NS - 1]; + const Real d1_fitB = rowd(8); + const Real denom = d1_ent - d1_orc; + const Real frac = + (std::abs(denom) > 1.0e-30) ? (d1_ent - d1_fitU) / denom : 0.0; + std::snprintf( + buf, sizeof(buf), + "at 0.7 tau: ent %.1f, fitU %.1f (t_end %.1f), fitB %.1f, oracle %.1f " + "-- the tanh fit recovers %.0f%% of the gap on a non-tanh truth", + d1_ent, d1_fitU, dE_fitU, d1_fitB, d1_orc, 100.0 * frac); + report("C11x shape-distorted truth", buf); + true_shape = 0; + } +} + +// C7: the reaction source term. Chemistry changes the pressure only through +// the composition, so the closed-form expression in reaction_dpdt() is +// checked against a DIRECTIONAL finite difference along the reaction path +// at fixed rho and e -- refining tau, which should show first-order +// convergence toward the analytic value. That FD is also exactly what the +// real-gas branch of reaction_dpdt() computes, so this validates both +// paths at once. +void +check_reaction_source() +{ +#if NUM_REACTIONS == 0 + std::printf( + " SKIP reaction source: %s has no reactions\n", + pele::physics::PhysicsType::identifier().c_str()); +#else + auto eos = pele::physics::PhysicsType::eos(); + // Stoichiometric H2/air, hot enough to react briskly. + Real Y[NUM_SPECIES] = {0.0}; + Y[H2_ID] = 0.0285; + Y[O2_ID] = 0.2265; + Y[N2_ID] = 0.7450; + const Real p0 = 1.01325e6, T0 = 1400.0; + Real rho = 0.0, e0 = 0.0; + eos.PYT2RE(p0, Y, T0, rho, e0); + + Real s[NVAR]; + set_state(s, rho, 0.0, T0, Y); + const pc_nscbc::CellPrim q = pc_nscbc::cell_primitives(s); + bool ok = true; + const Real analytic = pc_nscbc::reaction_dpdt(q, ok); + + // Directional FD: Y'(tau) = Y + tau * wdot / rho, which preserves sum Y = 1 + // because sum wdot = 0; then T' from (rho, e) and p' from (rho, T', Y'). + Real wdot[NUM_SPECIES]; + eos.RTY2WDOT(q.rho, q.T, q.Y, wdot); + Real wsum = 0.0, wmax = 0.0; + for (int n = 0; n < NUM_SPECIES; n++) { + wsum += wdot[n]; + wmax = std::max(wmax, std::abs(wdot[n])); + } + auto fd = [&](Real tau) { + Real Yp[NUM_SPECIES], sum = 0.0; + for (int n = 0; n < NUM_SPECIES; n++) { + Yp[n] = std::max(q.Y[n] + tau * wdot[n] / q.rho, 0.0); + sum += Yp[n]; + } + for (int n = 0; n < NUM_SPECIES; n++) { + Yp[n] /= sum; + } + Real Tp = q.T, pp = 0.0; + eos.REY2T(q.rho, q.e, Yp, Tp); + eos.RTY2P(q.rho, Tp, Yp, pp); + return (pp - q.p) / tau; + }; + + char buf[220]; + std::snprintf( + buf, sizeof(buf), "|sum wdot| / max|wdot| = %.3e", + std::abs(wsum) / std::max(wmax, 1e-300)); + check( + std::abs(wsum) / std::max(wmax, 1e-300) < 1e-10, + "chemistry conserves mass (sum wdot = 0)", buf); + + const Real tau0 = 1.0e-6 / (wmax / q.rho); +#ifdef USE_SRK_EOS + // Under SRK the kernel's reaction_dpdt IS a directional FD, so refining a + // second FD toward it measures nothing but the difference of two step + // sizes. The meaningful gate is agreement at a matching step. + const Real v0 = fd(tau0); + std::snprintf( + buf, sizeof(buf), "kernel %.6e vs test FD %.6e (rel %.2e)", analytic, v0, + std::abs(analytic - v0) / std::abs(v0)); + check( + ok && std::abs(analytic - v0) / std::abs(v0) < 1e-2, + "real-gas FD path agrees with an independent FD", buf); +#else + // A refining FD trades truncation error for cancellation error, and where + // the crossover lands depends on the toolchain (llvm/arm64 hits the floor + // one refinement earlier than gcc/x86). So the gate is floor-aware: + // errors must decrease monotonically UNTIL the minimum, and the minimum + // must sit at the round-off floor -- not monotone-to-the-end, which gates + // the machine rather than the mathematics. + Real err_k[4], min_err = 1e300; + int argmin = 0; + std::printf(" analytic dp/dt|_react = %.6e dyn/(cm^2 s)\n", analytic); + for (int k = 0; k < 4; k++) { + const Real tau = tau0 / std::pow(4.0, k); + const Real v = fd(tau); + err_k[k] = std::abs(v - analytic) / std::abs(analytic); + std::printf( + " tau = %.3e FD = %.6e rel err = %.3e\n", tau, v, err_k[k]); + if (err_k[k] < min_err) { + min_err = err_k[k]; + argmin = k; + } + } + bool converging = true; + for (int k = 1; k <= argmin; k++) { + converging = converging && (err_k[k] < err_k[k - 1] * 1.05); + } + std::snprintf(buf, sizeof(buf), "final relative error %.3e", err_k[3]); + check( + ok && err_k[3] < 1e-3, "closed-form dp/dt matches the directional FD", buf); + std::snprintf( + buf, sizeof(buf), + "error falls monotonically to %.2e at tau/%.0f, then sits at the " + "round-off floor", + min_err, std::pow(4.0, argmin)); + check( + converging && (min_err < 1e-6), + "FD converges to the closed form's round-off floor", buf); +#endif + + // A frozen (cold) state must give exactly zero, so beta_s is a no-op there. + Real s_cold[NVAR]; + Real rho_c = 0.0, e_c = 0.0; + eos.PYT2RE(p0, Y, 300.0, rho_c, e_c); + set_state(s_cold, rho_c, 0.0, 300.0, Y); + const pc_nscbc::CellPrim qc = pc_nscbc::cell_primitives(s_cold); + bool ok_c = true; + const Real cold = pc_nscbc::reaction_dpdt(qc, ok_c); + std::snprintf( + buf, sizeof(buf), "dp/dt = %.3e at 300 K vs %.3e at 1400 K", cold, + analytic); + check( + ok_c && std::abs(cold) < 1e-6 * std::abs(analytic), + "cold, unreacting state gives a negligible source", buf); +#endif +} + +// C12: where the diffusive capability gap actually lives. +// +// Real conduction is switched on in the mini solver (g_lambda), so the +// boundary heat flux is formed from the ghost temperatures exactly as +// PeleC's diffusion operator forms it, and a temperature ramp is parked +// with its high-curvature flank in the short domain's outflow cells. +// Against a 5x shielded reference (the C10/C11 protocol), this gates +// DYNAMICALLY what C8 gates statically: the conductive boundary error +// belongs to the ghost TEMPERATURE CLOSURE, and extrap_temperature +// removes most of it. +// +// It is also where a would-be "viscous condition" for the incoming wave +// went to die, and the numbers are worth keeping: a modelled +// dp/dt|_diffusion of the boundary cell (normal conduction + species +// diffusion of the resolved fields, EOS-exact on quadratic profiles to +// 1e-9) moved the error measured here from +104 to -911 dyn/cm2, and +// PeleC's flame-outflow at sigma = 1 from +1200 to +1771. In the +// ghost-cell form the diffusion operator READS the ghosts, so a correct +// ghost closure already carries the diffusive physics and an +// amplitude-side term double-counts it. The term was removed; this +// check keeps the closure honest instead. +void +check_diffusive_dynamics(const Real lam_cond) +{ + const Case cs; + auto eos = pele::physics::PhysicsType::eos(); + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + char buf[256]; + + const int n = 200; + const Real dxs = cs.L / n; + const Real u0 = 3.0e2; + const Real wr = 1.0; + const Real Tratio = 4.0; + const Real xc = cs.L - wr; // high-curvature flank in the outflow cells + const Real t_end = 3.0e-4; + const int NS = 4; + + Real rho0 = 0.0, e0 = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho0, e0); + + auto Tofx = [&](const Real x) { + const Real g = 0.5 * (1.0 + std::tanh((x - xc) / wr)); + return cs.T0 * (1.0 + (Tratio - 1.0) * g); + }; + auto init = [&](Field& f, const int nc) { + for (int i = -NG; i < nc + NG; i++) { + const Real x = (i + 0.5) * dxs; + const Real T = Tofx(x); + Real rho = 0.0, e = 0.0; + eos.PYT2RE(cs.p0, Y, T, rho, e); + set_state(f.at(i), rho, u0, T, Y); + } + }; + + auto run = [&](const int nc, const bool extrap_T, Real* pm) { + Field f(nc), g(nc), h(nc); + init(f, nc); + pc_nscbc::Params prm; + prm.L_ref = nc * dxs; + prm.sigma = 1.0; + prm.extrap_temperature = extrap_T; + pc_nscbc::Target off, out; + out.type = pc_nscbc::Type::outflow; + out.p = cs.p0; + Real t = 0.0; + int k = 0; + auto sample = [&](const int kk) { + Real psum = 0.0; + for (int i = 0; i < n; i++) { + psum += get_prim(f.at(i)).p; + } + pm[kk] = psum / n; + }; + sample(k++); + while (k < NS) { + const Real t_next = t_end * static_cast(k) / (NS - 1); + while (t < t_next) { + Real cmax = 0.0; + for (int i = 0; i < nc; i++) { + const Prim q = get_prim(f.at(i)); + cmax = std::max(cmax, std::abs(q.u) + sound_speed(q)); + } + Real dt = std::min(cs.cfl * dxs / cmax, t_next - t); + if (dt <= 0.0) { + break; + } + for (int layer = 1; layer <= NG; layer++) { + set_state(f.at(-layer), rho0, u0, cs.T0, Y); + } + fill_bcs(f, off, out, prm, dxs); + stage(f, g, dxs, dt); + for (int layer = 1; layer <= NG; layer++) { + set_state(g.at(-layer), rho0, u0, cs.T0, Y); + } + fill_bcs(g, off, out, prm, dxs); + stage(g, h, dxs, dt); + for (int i = 0; i < nc; i++) { + for (int v = 0; v < NVAR; v++) { + f.at(i)[v] = 0.5 * (f.at(i)[v] + h.at(i)[v]); + } + } + t += dt; + } + sample(k++); + } + }; + + g_lambda = lam_cond; // conduction ON, in both domains + Real pr[NS], p_ent[NS], p_tmp[NS]; + run(5 * n, false, pr); + run(n, false, p_ent); + run(n, true, p_tmp); + g_lambda = 0.0; + + const Real e_ent = p_ent[NS - 1] - pr[NS - 1]; + const Real e_tmp = p_tmp[NS - 1] - pr[NS - 1]; + std::snprintf( + buf, sizeof(buf), + "error vs shielded reference: entropy closure %.1f, extrap_temperature " + "%.1f (x%.2f)", + e_ent, e_tmp, e_tmp / (std::abs(e_ent) > 1e-30 ? e_ent : 1.0)); + check( + std::abs(e_tmp) < 0.5 * std::abs(e_ent), + "resolved conduction is handled by the ghost T closure", buf); +} + +// =========================================================================== +// Duct modes (CLI: t1 / t3 / t5) -- the injection-fidelity bed. +// +// One duct, two experiments, run only when asked (not part of the default +// suite): an NSCBC value-relaxation INFLOW at the lo face and a hard +// pressure ghost at the hi face (FOExtrap with p pinned to p0 -- the fully +// reflecting open end, R ~ -1). +// +// T1 (Dupuy 2026 Figs 5a/8a): step the inlet velocity target at t = 0 and +// measure the convergence time versus relax_u. The CLR theory predicts a +// narrow viable band with an interior optimum near 0.3 and growth on both +// sides (drift below, reflection-delayed convergence above); that +// relationship is the gate. +// +// T3 (Daviller 2019 sec. 4-5): force the inlet target harmonically, +// u^t = u0 + A sin(2 pi f t), at frequencies straddling the duct's +// quarter-wave resonance, and measure what actually enters: the achieved +// velocity amplitude at the boundary cell over A (I_u), and the incoming +// invariant's amplitude over the ideal injector's 2A (I_in). Our inflow +// has no amplitude slot (design doc I.3e) -- a time-varying target rides +// the same relaxation that holds the mean -- so injection fidelity is +// relax_u- and frequency-dependent BY CONSTRUCTION, and relax_u = 0 +// injects nothing at all. Those two properties are the gates; the +// deterioration near resonance is the reported measurement, with the +// stiff-limit analytic response 1/|1 + e^{i theta}| (theta the Doppler- +// corrected round-trip phase; divergent at the quarter-wave) as the +// reference column. +// +// T5 (Daviller 2019 sec. 7, laminar core): same runs, but the deliverable +// is P_RMS(x) against the analytic standing-wave envelope |sin| shape -- +// node/antinode geometry gated by shape correlation, antinode amplitude +// reported. +// =========================================================================== + +// FOExtrap with pressure pinned: the fully reflecting hi end of the duct. +void +hard_p_fill_hi(Field& f, Real p0) +{ + auto eos = pele::physics::PhysicsType::eos(); + const int n = f.n; + const Prim q = get_prim(f.at(n - 1)); + Real rho = 0.0, e = 0.0; + eos.PYT2RE(p0, q.Y, q.T, rho, e); + for (int layer = 1; layer <= NG; layer++) { + set_state(f.at(n - 1 + layer), rho, q.u, q.T, q.Y); + } +} + +struct DuctForcedResult +{ + Real I_u = 0.0; // achieved u' amplitude at the inlet cell / target A + Real I_in = 0.0; // incoming-invariant amplitude / ideal injector's 2A + std::vector prms; // P_RMS(x) per cell over the measurement window +}; + +// Harmonic forcing of the inflow target against the reflecting far end. +// Settles for n_settle acoustic round trips (forcing on throughout), then +// projects n_per integer periods. The Fourier projection at f ignores DC, +// so the mean-flow offset never needs subtracting. +// +// nri = true prototypes the BOUNDARY-REGISTER architecture (design doc II.2, +// queue item 4) without touching the kernel: the driver -- standing in for +// the once-per-advance register update PeleC would own -- keeps an EMA of +// the outgoing invariant R- = u - p/(rho c) at the boundary cell +// (tau = 3 t_a, the NDNR prescription), splits the instantaneous outgoing +// wave off it, u_minus = (R- - EMA)/2, and hands the kernel a target +// u^t + u_minus. The relaxation then fights only the incoming content and +// the mean drift; the wave the duct sends back is granted passage instead +// of being mistaken for error. The kernel remains a pure function of +// (state, Target): all the state lives in the caller, which is the entire +// architectural claim being measured. +// mode: 0 = classical, 1 = driver-held NRI register, 2 = stateless +// feed-forward (Target::dudt), 3 = both. +DuctForcedResult +duct_forced( + Real relax_u, Real freq, int n, int n_settle, int n_per, int mode = 0) +{ + Case cs; + cs.n = n; + Field f(cs.n), g(cs.n), h(cs.n); + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + auto eos = pele::physics::PhysicsType::eos(); + const Real dx = cs.L / cs.n; + Real rho0 = 0.0, e0 = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho0, e0); + Prim q0{}; + q0.rho = rho0; + q0.u = 0.0; + q0.p = cs.p0; + q0.T = cs.T0; + for (int k = 0; k < NUM_SPECIES; k++) { + q0.Y[k] = Y[k]; + } + const Real c0 = sound_speed(q0); + const Real u0 = 2.0e3; // mean inflow; forcing amplitude rides well below it + const Real A = 1.0e-3 * c0; // ~35 cm/s: linear regime + + for (int i = -NG; i < cs.n + NG; i++) { + set_state(f.at(i), rho0, u0, cs.T0, Y); + } + + pc_nscbc::Params prm; + prm.L_ref = cs.L; + prm.relax_u = relax_u; + prm.relax_t = 0.2; + pc_nscbc::Target in, off; + in.type = pc_nscbc::Type::inflow; + in.T = cs.T0; + for (int k = 0; k < NUM_SPECIES; k++) { + in.Y[k] = Y[k]; + } + + const Real t_a = 2.0 * cs.L / c0; + const Real t_meas0 = n_settle * t_a; + const Real T_w = n_per / freq; + const Real t_end = t_meas0 + T_w; + const Real om = 2.0 * M_PI * freq; + + DuctForcedResult r; + r.prms.assign(cs.n, 0.0); + std::vector psum(cs.n, 0.0), p2sum(cs.n, 0.0); + Real us = 0.0, uc = 0.0, rs = 0.0, rc = 0.0, wsum = 0.0; + + // The register: an EMA of the outgoing invariant at the boundary cell. + // Seeded with the quiescent value; tau = 3 t_a per the NDNR prescription. + const Real rhoc0 = rho0 * c0; + Real ema_Rm = u0 - cs.p0 / rhoc0; + const Real tau_ema = 3.0 * (2.0 * cs.L / c0); + + Real t = 0.0; + while (t < t_end) { + Real cmax = 0.0; + for (int i = 0; i < cs.n; i++) { + const Prim q = get_prim(f.at(i)); + cmax = std::max(cmax, std::abs(q.u) + sound_speed(q)); + } + Real dt = cs.cfl * dx / cmax; + dt = std::min(dt, t_end - t); + if (dt <= 0.0) { + break; + } + // The register update: ONCE per step, outside the stages, from the + // completed state -- exactly the cadence PeleC's once-per-advance + // update would have. The stages below read it frozen. + Real u_minus = 0.0; + if ((mode == 1) || (mode == 3)) { + const Prim qb = get_prim(f.at(0)); + // mode >= 4 flag reserved; local-impedance probe via env is clunky -- + // hardwire the experiment: LOCAL rho c instead of the frozen ambient. + const Real rhoc_loc = qb.rho * sound_speed(qb); + const bool local_rc = (std::getenv("NSCBC1D_LOCAL_RC") != nullptr); + const Real rc = local_rc ? rhoc_loc : rhoc0; + const Real Rm = qb.u - qb.p / rc; + const Real w = dt / (tau_ema + dt); + ema_Rm += w * (Rm - ema_Rm); + u_minus = 0.5 * (Rm - ema_Rm); + } + // The RK2 stages see the target at their own stage times. The hard-p + // fill runs AFTER fill_bcs: the hi target is `off` there, whose + // zero-gradient copy would otherwise overwrite the reflecting ghost. + const bool ff = (mode == 2) || (mode == 3); + in.u[0] = u0 + A * std::sin(om * t) + u_minus; + in.dudt = ff ? A * om * std::cos(om * t) : 0.0; + fill_bcs(f, in, off, prm, dx); + hard_p_fill_hi(f, cs.p0); + stage(f, g, dx, dt); + in.u[0] = u0 + A * std::sin(om * (t + dt)) + u_minus; + in.dudt = ff ? A * om * std::cos(om * (t + dt)) : 0.0; + fill_bcs(g, in, off, prm, dx); + hard_p_fill_hi(g, cs.p0); + stage(g, h, dx, dt); + for (int i = 0; i < cs.n; i++) { + for (int v = 0; v < NVAR; v++) { + f.at(i)[v] = 0.5 * (f.at(i)[v] + h.at(i)[v]); + } + } + t += dt; + + if (t > t_meas0) { + const Prim qb = get_prim(f.at(0)); + const Real sn = std::sin(om * t), cn = std::cos(om * t); + us += qb.u * sn * dt; + uc += qb.u * cn * dt; + const Real Rin = qb.u + qb.p / (rho0 * c0); // enters through the lo face + rs += Rin * sn * dt; + rc += Rin * cn * dt; + wsum += dt; + for (int i = 0; i < cs.n; i++) { + const Real p = get_prim(f.at(i)).p; + psum[i] += p * dt; + p2sum[i] += p * p * dt; + } + } + } + + const Real amp_u = 2.0 / wsum * std::sqrt(us * us + uc * uc); + const Real amp_R = 2.0 / wsum * std::sqrt(rs * rs + rc * rc); + r.I_u = amp_u / A; + r.I_in = amp_R / (2.0 * A); + for (int i = 0; i < cs.n; i++) { + const Real pm = psum[i] / wsum; + const Real var = std::max(p2sum[i] / wsum - pm * pm, 0.0); + r.prms[i] = std::sqrt(var); + } + return r; +} + +// Doppler-corrected round-trip phase and the duct's quarter-wave frequency. +Real +duct_roundtrip_phase(Real freq, Real L, Real c, Real u0) +{ + return 2.0 * (2.0 * M_PI * freq) * L * c / (c * c - u0 * u0); +} + +// T1: step the inlet target, measure convergence time vs relax_u. +Real +duct_step_tconv(Real relax_u, int n, Real du, Real t_end_ta) +{ + Case cs; + cs.n = n; + Field f(cs.n), g(cs.n), h(cs.n); + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + auto eos = pele::physics::PhysicsType::eos(); + const Real dx = cs.L / cs.n; + Real rho0 = 0.0, e0 = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho0, e0); + Prim q0{}; + q0.rho = rho0; + q0.p = cs.p0; + q0.T = cs.T0; + for (int k = 0; k < NUM_SPECIES; k++) { + q0.Y[k] = Y[k]; + } + const Real c0 = sound_speed(q0); + const Real t_a = 2.0 * cs.L / c0; + + // Quiescent duct; at t = 0 the inlet target is already at du (the step). + for (int i = -NG; i < cs.n + NG; i++) { + set_state(f.at(i), rho0, 0.0, cs.T0, Y); + } + pc_nscbc::Params prm; + prm.L_ref = cs.L; + prm.relax_u = relax_u; + prm.relax_t = 0.2; + pc_nscbc::Target in, off; + in.type = pc_nscbc::Type::inflow; + in.u[0] = du; + in.T = cs.T0; + for (int k = 0; k < NUM_SPECIES; k++) { + in.Y[k] = Y[k]; + } + + const Real t_end = t_end_ta * t_a; + Real t = 0.0, t_last = 0.0; + while (t < t_end) { + Real cmax = 0.0; + for (int i = 0; i < cs.n; i++) { + const Prim q = get_prim(f.at(i)); + cmax = std::max(cmax, std::abs(q.u) + sound_speed(q)); + } + Real dt = cs.cfl * dx / cmax; + dt = std::min(dt, t_end - t); + if (dt <= 0.0) { + break; + } + fill_bcs(f, in, off, prm, dx); + hard_p_fill_hi(f, cs.p0); + stage(f, g, dx, dt); + fill_bcs(g, in, off, prm, dx); + hard_p_fill_hi(g, cs.p0); + stage(g, h, dx, dt); + for (int i = 0; i < cs.n; i++) { + for (int v = 0; v < NVAR; v++) { + f.at(i)[v] = 0.5 * (f.at(i)[v] + h.at(i)[v]); + } + } + t += dt; + + Real ubar = 0.0; + for (int i = 0; i < cs.n; i++) { + ubar += get_prim(f.at(i)).u; + } + ubar /= cs.n; + if (std::abs(ubar - du) > 1.0e-3 * du) { + t_last = t; // still outside the band: convergence not yet held + } + } + return t_last / t_a; +} + +void +run_t1(int n) +{ + const Real ks[] = {0.1, 0.2, 0.3, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 100.0}; + const int nk = static_cast(sizeof(ks) / sizeof(ks[0])); + std::printf( + "\nT1 step-change convergence vs relax_u (duct, hard-p far end, " + "n = %d)\n", + n); + std::printf( + " t_conv = last time | - u^t| > 0.1%% of the step, in units of " + "t_a = 2L/c\n\n"); + std::printf(" %8s %12s\n", "relax_u", "t_conv/t_a"); + Real tc[nk]; + int imin = 0; + for (int i = 0; i < nk; i++) { + tc[i] = duct_step_tconv(ks[i], n, 2.0e3, 40.0); + std::printf(" %8.2f %12.2f\n", ks[i], tc[i]); + if (tc[i] < tc[imin]) { + imin = i; + } + } + char buf[220]; + std::snprintf( + buf, sizeof(buf), + "argmin relax_u = %.2f, t_conv %.2f t_a (ends: %.2f, %.2f)", ks[imin], + tc[imin], tc[0], tc[nk - 1]); + check( + (imin > 0) && (imin < nk - 1) && (tc[0] > 1.2 * tc[imin]) && + (tc[nk - 1] > 1.2 * tc[imin]), + "t_conv has an interior minimum (CLR band)", buf); + check( + ks[imin] >= 0.1 && ks[imin] <= 1.0, + "the optimum sits near the CLR prediction (~0.3)", buf); +} + +void +run_t3_t5(bool profiles, int n, Real relax_cli) +{ + Case cs; + Real Y[NUM_SPECIES]; + for (int k = 0; k < NUM_SPECIES; k++) { + Y[k] = air_Y(k); + } + auto eos = pele::physics::PhysicsType::eos(); + Real rho0 = 0.0, e0 = 0.0; + eos.PYT2RE(cs.p0, Y, cs.T0, rho0, e0); + Prim q0{}; + q0.rho = rho0; + q0.p = cs.p0; + q0.T = cs.T0; + for (int k = 0; k < NUM_SPECIES; k++) { + q0.Y[k] = Y[k]; + } + const Real c0 = sound_speed(q0); + const Real u0 = 2.0e3; + // Quarter-wave frequency, Doppler-corrected: round-trip phase = pi. + const Real f0 = (c0 * c0 - u0 * u0) / (4.0 * cs.L * c0); + const Real fr[] = {0.8 * f0, f0, 1.2 * f0}; + + if (!profiles) { + const Real kv[] = {0.0, 0.5, 2.0, 5.0}; + std::printf( + "\nT3 forced-inlet injection fidelity (duct, hard-p far end, " + "f0 = %.1f Hz)\n", + f0); + std::printf( + " I_u = achieved u' at the boundary cell / target amplitude " + "(1 = faithful);\n" + " I_in = incoming-invariant amplitude / ideal injector's; its\n" + " velocity-Dirichlet limit is the duct gain 1/|1+e^{i theta}| " + "(last column,\n divergent at f0).\n\n"); + std::printf( + " %8s %10s %8s %8s %10s\n", "relax_u", "f/f0", "I_u", "I_in", + "I_in stiff"); + Real Iu_row[4] = {0.0}; + for (int m = 0; m < 4; m++) { + for (int j = 0; j < 3; j++) { + const DuctForcedResult r = duct_forced(kv[m], fr[j], n, 6, 4); + const Real th = duct_roundtrip_phase(fr[j], cs.L, c0, u0); + const Real stiff = + 1.0 / std::max(std::hypot(1.0 + std::cos(th), std::sin(th)), 1.0e-12); + if (stiff < 999.0) { + std::printf( + " %8.2f %10.2f %8.3f %8.3f %10.2f\n", kv[m], fr[j] / f0, r.I_u, + r.I_in, stiff); + } else { + std::printf( + " %8.2f %10.2f %8.3f %8.3f %10s\n", kv[m], fr[j] / f0, r.I_u, + r.I_in, "inf"); + } + if (j == 0) { // off-resonance column used for the monotonicity gate + Iu_row[m] = r.I_u; + } + } + } + char buf[220]; + std::snprintf( + buf, sizeof(buf), "I_u(relax_u=0) = %.4f at f = 0.8 f0", Iu_row[0]); + check( + Iu_row[0] < 0.05, "relax_u = 0 injects nothing (no amplitude slot: I.3e)", + buf); + std::snprintf( + buf, sizeof(buf), "I_u = %.3f -> %.3f -> %.3f for relax_u 0.5 -> 2 -> 5", + Iu_row[1], Iu_row[2], Iu_row[3]); + check( + (Iu_row[1] < Iu_row[2]) && (Iu_row[2] < Iu_row[3]), + "off-resonance injection stiffens with relax_u", buf); + + // The queue-item-4 prototypes (reported, not gated -- gates belong to + // real implementations). Mode 1: driver-held NRI register (Daviller's + // I = 1 claim). Mode 2: the stateless feed-forward amplitude slot + // (Target::dudt). Mode 3: both together. + for (int md = 1; md <= 3; md++) { + const char* names[] = { + "", "NRI register", "dudt feed-forward", "register + feed-forward"}; + std::printf("\n same matrix, %s:\n", names[md]); + Real imin = 1.0e30, imax = 0.0; + for (int m2 = 1; m2 < 4; m2++) { + for (int j = 0; j < 3; j++) { + const DuctForcedResult rn = duct_forced(kv[m2], fr[j], n, 6, 4, md); + imin = std::min(imin, rn.I_in); + imax = std::max(imax, rn.I_in); + std::snprintf( + buf, sizeof(buf), + "relax_u %.2f, f/f0 %.2f: I_in = %.3f (I_u = %.3f)", kv[m2], + fr[j] / f0, rn.I_in, rn.I_u); + report(names[md], buf); + } + } + if (md == 3) { + // Phase-A driver gate: the full NRI property, I_in ~ 1 at every + // stiffness and frequency measured, resonance included. + std::snprintf( + buf, sizeof(buf), "I_in in [%.3f, %.3f] over the matrix", imin, imax); + check( + (imin > 0.85) && (imax < 1.15), + "register + feed-forward: I_in ~ 1 at any K and f", buf); + } + } + } else { + const Real k = relax_cli > 0.0 ? relax_cli : 2.0; + std::printf( + "\nT5 standing-wave pattern under forcing (relax_u = %.2f, " + "f0 = %.1f Hz)\n", + k, f0); + std::printf( + " P_RMS(x) in dyn/cm^2 at 17 stations, against the analytic\n" + " envelope shape |sin(k_eff (L-x))|, k_eff = round-trip phase / " + "2L.\n\n"); + for (int j = 0; j < 3; j++) { + const DuctForcedResult r = duct_forced(k, fr[j], n, 6, 4); + // Analytic envelope shape for the reflecting end: |sin(k_eff (L-x))| + // with k_eff from the Doppler-mean wavenumber. + const Real keff = + duct_roundtrip_phase(fr[j], cs.L, c0, u0) / (2.0 * cs.L); + Real num = 0.0, mag_m = 0.0, mag_a = 0.0, pk = 0.0; + std::printf(" f/f0 = %.2f\n x/L :", fr[j] / f0); + for (int s = 0; s < 17; s++) { + std::printf(" %6.2f", s / 16.0); + } + std::printf("\n Prms:"); + for (int s = 0; s < 17; s++) { + const int i = + std::min(static_cast((s / 16.0) * (n - 1) + 0.5), n - 1); + std::printf(" %6.1f", r.prms[i]); + } + std::printf("\n anly:"); + for (int s = 0; s < 17; s++) { + const int i = + std::min(static_cast((s / 16.0) * (n - 1) + 0.5), n - 1); + const Real x = (i + 0.5) * (cs.L / n); + const Real a = std::abs(std::sin(keff * (cs.L - x))); + const Real m = r.prms[i]; + num += a * m; + mag_m += m * m; + mag_a += a * a; + pk = std::max(pk, m); + std::printf(" %6.2f", a); + } + const Real corr = num / std::max(std::sqrt(mag_m * mag_a), 1.0e-300); + char buf[220]; + std::snprintf( + buf, sizeof(buf), "shape correlation %.3f, antinode P_RMS %.1f", corr, + pk); + if (std::abs(fr[j] / f0 - 1.0) > 0.05) { + check(corr > 0.95, "standing-wave geometry matches the envelope", buf); + } else { + report("on-resonance profile (amplitude is the finding)", buf); + } + } + } +} + +// Phase-B driver gate: the NDNR register must collapse the reflection at +// strong anchoring while keeping the anchoring itself. sigma = 16 is the +// measured 28%-reflection price; NDNR's claim is that the price vanishes +// because the acoustics never reach the relaxation. +void +run_ndnr() +{ + std::printf( + "\nNDNR EMA-mean relaxation vs classical (pulse reflection, " + "n = 400)\n\n"); + std::printf( + " %8s %12s %12s %14s %14s\n", "sigma", "R_cl [%]", "R_ndnr [%]", + "drift_cl", "drift_ndnr"); + Real Rc16 = 0.0, Rn16 = 0.0, dc16 = 0.0, dn16 = 0.0; + for (const Real sg : {0.25, 4.0, 16.0}) { + Real dc = 0.0, dn = 0.0; + const Real Rc = reflection_coefficient(sg, 400, 2, false, &dc); + const Real Rn = reflection_coefficient(sg, 400, 2, false, &dn, false, true); + std::printf( + " %8.2f %12.3f %12.3f %14.2f %14.2f\n", sg, 100.0 * Rc, 100.0 * Rn, dc, + dn); + if (sg == 16.0) { + Rc16 = Rc; + Rn16 = Rn; + dc16 = dc; + dn16 = dn; + } + } + char buf[220]; + std::snprintf( + buf, sizeof(buf), "R 16: %.2f%% -> %.2f%%; drift %.1f -> %.1f", + 100.0 * Rc16, 100.0 * Rn16, dc16, dn16); + check( + Rn16 < 0.2 * Rc16, "NDNR collapses the sigma = 16 reflection (>5x)", buf); + check( + std::abs(dn16) < 3.0 * std::abs(dc16) + 5.0, + "NDNR keeps the anchoring (drift comparable)", buf); +} + +int +main(int argc, char* argv[]) +{ + amrex::Initialize(argc, argv, false); + { + // CLI: [mode] [key=value ...]. Modes: (none) = the check suite, + // "sweep" = suite + sigma table, "t1"/"t3"/"t5" = the duct + // injection-fidelity modes (Part III of the design doc), which run + // alone. Keys: n= (duct resolution), relax_u= (T5 inlet stiffness). + const std::string mode = (argc > 1) ? argv[1] : ""; + int duct_n = 200; + Real relax_cli = -1.0; + for (int a = 2; a < argc; a++) { + const std::string s(argv[a]); + const auto eq = s.find('='); + if (eq == std::string::npos) { + continue; + } + const std::string key = s.substr(0, eq); + const Real val = std::atof(s.c_str() + eq + 1); + if (key == "n") { + duct_n = static_cast(val); + } else if (key == "relax_u") { + relax_cli = val; + } + } + if (mode == "t1" || mode == "t3" || mode == "t5" || mode == "ndnr") { + std::printf("\nnscbc1d duct mode: %s\n", mode.c_str()); + if (mode == "t1") { + run_t1(std::min(duct_n, 200) / 2); // step response resolves fine coarse + } else if (mode == "ndnr") { + run_ndnr(); + } else { + run_t3_t5(mode == "t5", duct_n, relax_cli); + } + std::printf("\n%d passed, %d failed\n\n", n_pass, n_fail); + amrex::Finalize(); + return (n_fail == 0) ? 0 : 1; + } + + const bool sweep = (mode == "sweep"); + std::printf("\nnscbc1d -- standalone verification of Source/NSCBC.H\n"); + std::printf( + "EOS: %s, NUM_SPECIES = %d, NVAR = %d\n\n", + pele::physics::PhysicsType::identifier().c_str(), NUM_SPECIES, NVAR); + + std::printf("C1 uniform-state consistency\n"); + check_uniform(); + std::printf("\nC2 relaxation directions\n"); + check_relaxation_signs(); + std::printf("\nC3 species and state identities\n"); + check_species(); +#ifndef USE_SRK_EOS + std::printf("\nC4 acoustic reflection\n"); + check_reflection(sweep); + std::printf("\nC5 relaxation rate is a rate\n"); + check_relaxation_rate(); +#else + // The dynamic checks (C4, C5, C9b, C10, C11, C12) integrate the mini + // solver for thousands of steps; under SRK every step pays several + // Newton solves per cell and the full suite runs for the better part of + // an hour to re-verify algebra that is EOS-independent and already + // gated under Fuego. The SRK build's purpose is the kernel's + // EOS-portability -- every fill is an algebraic function of EOS calls -- + // which the static checks exercise completely. + amrex::ignore_unused(sweep); + std::printf( + "\nC4/C5 dynamic acoustic checks: SKIPPED under SRK (EOS-independent; " + "gated under Fuego)\n"); +#endif + std::printf("\nC6 fallbacks\n"); + check_fallbacks(); + std::printf("\nC13 outflow reversal: continuity and relaxation\n"); + check_reversal_continuity(); +#ifndef USE_SRK_EOS + std::printf("\nC14 sustained recirculation: the backflow material\n"); + check_sustained_recirculation(); +#else + std::printf( + "\nC14 sustained recirculation: dynamic halves SKIPPED under SRK " + "(EOS-independent; gated under Fuego)\n"); +#endif + std::printf("\nC7 reaction source term\n"); + check_reaction_source(); + std::printf("\nC8 diffusive behaviour of the ghost\n"); + check_diffusive_gradient(); + std::printf("\nC9 ghost-pressure bias from the outgoing extrapolation\n"); + check_ghost_pressure_bias(); +#ifndef USE_SRK_EOS + std::printf("\nC10 does that bias drive the solution? (source-free)\n"); + check_extrapolation_drives_solution(); + std::printf( + "\nC11 the sustained ramp: a front on the outflow with an exact steady " + "solution\n"); + check_sustained_ramp(); +#else + std::printf( + "\nC10/C11/C12 dynamic checks: SKIPPED under SRK (EOS-independent; " + "gated under Fuego)\n"); +#endif + +#ifndef USE_SRK_EOS + // Conductivity for C12, boosted ~100x above air so the conductive + // boundary error is well above every other error in the test. + std::printf("\nC12 the diffusive gap lives in the ghost T closure\n"); + check_diffusive_dynamics(2.6e5); +#endif + + std::printf("\n%d passed, %d failed\n\n", n_pass, n_fail); + } + amrex::Finalize(); + return (n_fail == 0) ? 0 : 1; +} diff --git a/Verification/NSCBC1D/source_sign_check.py b/Verification/NSCBC1D/source_sign_check.py new file mode 100644 index 000000000..d8246d4e2 --- /dev/null +++ b/Verification/NSCBC1D/source_sign_check.py @@ -0,0 +1,72 @@ +""" +Convention-free check of the sign of the reaction source term in the modelled +incoming wave of a ghost-cell NSCBC. + +1-D linear acoustics with a volumetric pressure source S (= dp/dt|_{rho,e} of +heat release) confined to the cells next to a subsonic outflow: + + dp/dt + rho c^2 du/dx = S(x) + du/dt + (1/rho) dp/dx = 0 + +Left end: rigid wall (u = 0). Right end: the branch's ghost-cell fill, written +exactly as Source/NSCBC.H does it in its own variables -- + + R+ = u + p/(rho c) extrapolated with its minmod slope (order 2) + R- = u - p/(rho c) R-_g = R-_N + dx * L_in / (c rho c) + L_in = K (p_N - p_t) + s * S_p(N) s = +1, 0, -1 + +Godunov (exact linear Riemann) fluxes everywhere, including the boundary face. +The exact steady state of the PDE with a far-field pressure p_t has p == p_t +and du/dx = S/(rho c^2). The equilibrium offset (p_N - p_t) K / S_p tells +which sign is right: 0 means the boundary reproduces the exact solution. +""" +import numpy as np + +rho, c = 1.17e-3, 3.48e4 # cgs air +L, N = 40.0, 400 +dx = L / N +x = (np.arange(N) + 0.5) * dx +p_t = 1.0e6 +sigma = 1.0 +K = sigma * c / L + +S = np.zeros(N) +S[-1] = 2.0e7 # dp/dt|chem in the boundary cell, dyn/cm2/s +Sp_N = S[-1] + +def minmod(a, b): + return np.where(a * b > 0, np.sign(a) * np.minimum(abs(a), abs(b)), 0.0) + +def run(s, nsteps=60000, cfl=0.5): + p = np.full(N, p_t) + u = np.zeros(N) + dt = cfl * dx / c + rc = rho * c + for _ in range(nsteps): + # ghost at right (the kernel's fill, one layer) + Rp = u + p / rc + Rm = u - p / rc + dRp = minmod(Rp[-1] - Rp[-2], Rp[-2] - Rp[-3]) + Rp_g = Rp[-1] + dRp + L_in = K * (p[-1] - p_t) + s * Sp_N + Rm_g = Rm[-1] + dx * L_in / (c * rc) + u_g = 0.5 * (Rp_g + Rm_g) + p_g = 0.5 * rc * (Rp_g - Rm_g) + # left ghost: rigid wall (reflect u) + pe = np.concatenate(([p[0]], p, [p_g])) + ue = np.concatenate(([-u[0]], u, [u_g])) + pL, pR = pe[:-1], pe[1:] + uL, uR = ue[:-1], ue[1:] + pf = 0.5 * (pL + pR) + 0.5 * rc * (uL - uR) + uf = 0.5 * (uL + uR) + 0.5 * (pL - pR) / rc + p = p - dt * rho * c * c * (uf[1:] - uf[:-1]) / dx + dt * S + u = u - dt * (pf[1:] - pf[:-1]) / (rho * dx) + return p, u + +print(f"K = {K:.3g} 1/s, S_p = {Sp_N:.3g}, predicted offsets S_p/K = {Sp_N/K:.4g}") +for s, name in [(+1, "+S_p (NSCBC.H:1029, Phase 0 onward)"), (0, "term off (beta_s=1)"), + (-1, "-S_p (as written before Phase 0)")]: + p, u = run(s) + off = p[-1] - p_t + print(f"s={s:+d} {name:32s} p_N - p_t = {off:10.2f} (p_N-p_t)K/S_p = {off*K/Sp_N:6.3f}" + f" du/dx at face = {(u[-1]-u[-2])/dx:.4g} exact {Sp_N/(rho*c*c):.4g}") diff --git a/Verification/NSCBCFields/.gitignore b/Verification/NSCBCFields/.gitignore new file mode 100644 index 000000000..589a15ed5 --- /dev/null +++ b/Verification/NSCBCFields/.gitignore @@ -0,0 +1,4 @@ +build/ +__pycache__/ +*.ex +tmp_build_dir/ diff --git a/Verification/NSCBCFields/CMakeLists.txt b/Verification/NSCBCFields/CMakeLists.txt new file mode 100644 index 000000000..4fe5f06ee --- /dev/null +++ b/Verification/NSCBCFields/CMakeLists.txt @@ -0,0 +1,26 @@ +# Analysis tooling for the multi-dimensional NSCBC tests +# (Exec/RegTests/NSCBC-COVO, NSCBC-Acoustic, NSCBC-FlameOutflow). +# +# cmake -S . -B build -DAMReX_DIR=/lib/cmake/AMReX +# cmake -S . -B build3d -DAMReX_DIR=/lib/cmake/AMReX +# cmake --build build +# ./build/fielddump pressure out.dat +# python3 metrics.py circularity out.dat # 2-D +# python3 metrics.py sphericity out.dat # 3-D +# +# The dimensionality comes from the AMReX build it is pointed at; fielddump.cpp +# is written against AMREX_SPACEDIM and metrics.py detects 2-D or 3-D from the +# header fielddump writes. Needs nothing from PeleC or PelePhysics. +cmake_minimum_required(VERSION 3.20) +project(nscbcfields CXX) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +find_package(AMReX REQUIRED) +add_executable(fielddump fielddump.cpp) +if(TARGET AMReX::amrex_3d) + target_link_libraries(fielddump PRIVATE AMReX::amrex_3d) +elseif(TARGET AMReX::amrex_2d) + target_link_libraries(fielddump PRIVATE AMReX::amrex_2d) +else() + target_link_libraries(fielddump PRIVATE AMReX::amrex_1d) +endif() diff --git a/Verification/NSCBCFields/GNUmakefile b/Verification/NSCBCFields/GNUmakefile new file mode 100644 index 000000000..f35165cfe --- /dev/null +++ b/Verification/NSCBCFields/GNUmakefile @@ -0,0 +1,42 @@ +# GNUmake build for the NSCBC field-dump tool, for those not using CMake. +# Modelled on amrex/Tools/Plotfile/GNUmakefile: fielddump.cpp carries its own +# main(), so the multiple_executables pattern builds it directly. +# +# make -j -> fielddump..ex +# make COMP=llvm -j -> match whatever you built PeleC with +# +# DIM must match the plotfiles being read; fielddump.cpp is written against +# AMREX_SPACEDIM, so DIM=3 works and metrics.py detects the dimensionality from +# the header it writes. +# +# make DIM=3 -j +# make AMREX_HOME=/path/to/PelePhysics/Submodules/amrex DIM=3 -j + +PELEC_HOME ?= ../.. +AMREX_HOME ?= $(PELEC_HOME)/Submodules/PelePhysics/Submodules/amrex + +DEBUG = FALSE +DIM ?= 2 +COMP ?= gnu +PRECISION = DOUBLE +USE_MPI = FALSE +USE_OMP = FALSE +USE_CUDA = FALSE + +BL_NO_FORT = TRUE + +programs += fielddump + +include $(AMREX_HOME)/Tools/GNUMake/Make.defs + +multiple_executables = $(addsuffix .$(machineSuffix).ex, $(programs)) +default: $(multiple_executables) + +include $(AMREX_HOME)/Src/Base/Make.package +include $(AMREX_HOME)/Src/Boundary/Make.package +include $(AMREX_HOME)/Src/AmrCore/Make.package + +include $(AMREX_HOME)/Tools/GNUMake/Make.rules + +clean:: + $(SILENT) $(RM) $(multiple_executables) diff --git a/Verification/NSCBCFields/fielddump.cpp b/Verification/NSCBCFields/fielddump.cpp new file mode 100644 index 000000000..ccb7a1841 --- /dev/null +++ b/Verification/NSCBCFields/fielddump.cpp @@ -0,0 +1,130 @@ +// ============================================================================ +// fielddump -- flatten one variable of a single-level AMReX plotfile onto a +// regular array, for the NSCBC multi-dimensional metrics. Builds at whatever +// AMREX_SPACEDIM the AMReX it links against was configured with. +// +// (Named fielddump rather than pltdump: the repository .gitignore carries a +// `plt*` rule for plotfiles, which silently swallows any source file whose +// name begins with "plt".) +// +// The 2-D boundary tests need the whole field, not a line-out, because the +// quantity of interest is the SHAPE of the wavefront. fextract gives 1-D +// slices only, so this exists. +// +// fielddump +// +// Writes an ASCII header followed by the values, deliberately trivial to +// parse: +// +// 2-D: # nx ny xlo ylo dx dy time then nx*ny values, j slowest +// 3-D: # nx ny nz xlo ylo zlo dx dy dz time then nx*ny*nz values, k +// slowest +// +// The 2-D header is byte-for-byte what it always was, so every existing +// reader keeps working; a reader that wants to handle both can branch on the +// number of tokens on the header line. +// ============================================================================ + +#include +#include +#include + +#include +#include +#include + +int +main(int argc, char* argv[]) +{ + amrex::Initialize(argc, argv, false); + int rc = 0; + { + if (argc < 4) { + amrex::Print() << "usage: fielddump \n"; + amrex::Finalize(); + return 1; + } + const std::string pf(argv[1]); + const std::string var(argv[2]); + const std::string out(argv[3]); + + amrex::PlotFileData plotfile(pf); + const int lev = 0; // these tests are single level by construction + const amrex::Box dom = plotfile.probDomain(lev); + const auto plo = plotfile.probLo(); + const auto dx = plotfile.cellSize(lev); + + bool found = false; + for (const auto& n : plotfile.varNames()) { + found = found || (n == var); + } + if (!found) { + amrex::Print() << "fielddump: variable '" << var << "' not in " << pf + << "\n available:"; + for (const auto& n : plotfile.varNames()) { + amrex::Print() << " " << n; + } + amrex::Print() << "\n"; + amrex::Finalize(); + return 1; + } + + const amrex::MultiFab mf = plotfile.get(lev, var); + + const int nx = dom.length(0); + const int ny = (AMREX_SPACEDIM > 1) ? dom.length(1) : 1; + const int nz = (AMREX_SPACEDIM > 2) ? dom.length(2) : 1; + const size_t ntot = static_cast(nx) * ny * nz; + std::vector a(ntot, std::numeric_limits::quiet_NaN()); + + // Index of (i,j,k) in the flattened array, with the last dimension + // slowest. Written once so the write loop below cannot disagree with it. + const auto idx = [=](const int i, const int j, const int k) { + return (static_cast(k) * ny + j) * nx + i; + }; + + for (amrex::MFIter mfi(mf); mfi.isValid(); ++mfi) { + const amrex::Box& bx = mfi.validbox(); + const auto& arr = mf.const_array(mfi); + const auto lo = amrex::lbound(bx); + const auto hi = amrex::ubound(bx); + for (int k = lo.z; k <= hi.z; ++k) { + for (int j = lo.y; j <= hi.y; ++j) { + for (int i = lo.x; i <= hi.x; ++i) { + a[idx( + i - dom.smallEnd(0), + (AMREX_SPACEDIM > 1) ? j - dom.smallEnd(1) : 0, + (AMREX_SPACEDIM > 2) ? k - dom.smallEnd(2) : 0)] = arr(i, j, k); + } + } + } + } + + FILE* f = std::fopen(out.c_str(), "w"); +#if AMREX_SPACEDIM == 3 + std::fprintf( + f, + "# nx ny nz xlo ylo zlo dx dy dz time\n" + "%d %d %d %.17g %.17g %.17g %.17g %.17g %.17g %.17g\n", + nx, ny, nz, plo[0], plo[1], plo[2], dx[0], dx[1], dx[2], plotfile.time()); +#else + std::fprintf( + f, "# nx ny xlo ylo dx dy time\n%d %d %.17g %.17g %.17g %.17g %.17g\n", + nx, ny, plo[0], plo[1], dx[0], dx[1], plotfile.time()); +#endif + for (int k = 0; k < nz; ++k) { + for (int j = 0; j < ny; ++j) { + for (int i = 0; i < nx; ++i) { + std::fprintf(f, "%.17e ", a[idx(i, j, k)]); + } + std::fprintf(f, "\n"); + } + } + std::fclose(f); + amrex::Print() << "fielddump: wrote " << nx << " x " << ny << " x " << nz + << " '" << var << "' at t = " << plotfile.time() << " to " + << out << "\n"; + } + amrex::Finalize(); + return rc; +} diff --git a/Verification/NSCBCFields/metrics.py b/Verification/NSCBCFields/metrics.py new file mode 100644 index 000000000..76808ab27 --- /dev/null +++ b/Verification/NSCBCFields/metrics.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +""" +Metrics for the multi-dimensional NSCBC tests. + +Reads the regular arrays written by fielddump -- 2-D or 3-D, detected from the +header -- and reports: + + circularity -- for the circular-pulse case, the spread in the radius of the + outgoing wavefront over polar angle, restricted to the rays + along which the front is still inside the domain. The exact + solution is a circle, so any spread is boundary error. This + is the diagnostic used by Motheau et al. (2017): a good + non-reflecting boundary lets the pressure contours stay + circular as the wave crosses; a poor one flattens and buckles + them, worst at the corners where the wave arrives obliquely. + + sphericity -- the 3-D form, for Exec/RegTests/NSCBC-Acoustic with + prob.pulse_type = 1. Same idea, but the rays are spread over + the sphere and the report is binned by CHI, the angle between + the ray and the nearest face normal: chi = 0 is a face centre, + chi = 45 deg an edge, chi = 54.7 deg a corner. That single + coordinate covers all three, which is what makes this the test + of corner and edge ownership rather than of the 1-D algebra. + + Read it as follows. Early on every ray is still inside and + the spread measures nothing but discretisation. Once the + front has crossed the faces only the oblique rays survive the + "still inside" filter -- and those are exactly the ones aimed + at edges and corners. So a spread that grows after the face + crossing is error injected at the faces and carried into the + still-interior part of the front, which is the thing a corner + bug produces. + + residual -- what is left in the domain after the wave has gone, relative + to the incident amplitude. For the vortex case this is the + whole story: a vortex carries no acoustic content, so any + pressure signal left behind was manufactured by the boundary. + +Usage: + metrics.py circularity [ ...] + metrics.py sphericity [ ...] + metrics.py residual [ ...] +""" +import sys +import numpy as np + + +def load(fn): + """2-D loader, kept exactly as it was so the 2-D callers are untouched.""" + with open(fn) as f: + f.readline() + nx, ny, xlo, ylo, dx, dy, t = f.readline().split() + nx, ny = int(nx), int(ny) + a = np.loadtxt(f) + a = a.reshape(ny, nx) + x = float(xlo) + (np.arange(nx) + 0.5) * float(dx) + y = float(ylo) + (np.arange(ny) + 0.5) * float(dy) + return x, y, a, float(t) + + +def load_nd(fn): + """Dimension-agnostic loader: returns (axes, array, t) with axes a list of + coordinate vectors, slowest axis first in the array as fielddump writes it. + """ + with open(fn) as f: + f.readline() + tok = f.readline().split() + a = np.loadtxt(f) + if len(tok) == 7: + nx, ny = int(tok[0]), int(tok[1]) + xlo, ylo, dx, dy, t = (float(v) for v in tok[2:]) + axes = [xlo + (np.arange(nx) + 0.5) * dx, + ylo + (np.arange(ny) + 0.5) * dy] + return axes, a.reshape(ny, nx), t + if len(tok) == 10: + nx, ny, nz = (int(v) for v in tok[:3]) + xlo, ylo, zlo, dx, dy, dz, t = (float(v) for v in tok[3:]) + axes = [xlo + (np.arange(nx) + 0.5) * dx, + ylo + (np.arange(ny) + 0.5) * dy, + zlo + (np.arange(nz) + 0.5) * dz] + return axes, a.reshape(nz, ny, nx), t + raise ValueError(f"{fn}: header has {len(tok)} fields, expected 7 or 10") + + +def trilinear(axes, a, q): + """Sample a 3-D array on a regular grid at scattered points q[:, 3].""" + x, y, z = axes + d = (x[1] - x[0], y[1] - y[0], z[1] - z[0]) + o = (x[0], y[0], z[0]) + n = (len(x), len(y), len(z)) + f = [(q[:, m] - o[m]) / d[m] for m in range(3)] + i0 = [np.clip(np.floor(f[m]).astype(int), 0, n[m] - 2) for m in range(3)] + tt = [np.clip(f[m] - i0[m], 0.0, 1.0) for m in range(3)] + out = 0.0 + for bz in (0, 1): + for by in (0, 1): + for bx in (0, 1): + wgt = ((tt[0] if bx else 1 - tt[0]) * + (tt[1] if by else 1 - tt[1]) * + (tt[2] if bz else 1 - tt[2])) + out = out + wgt * a[i0[2] + bz, i0[1] + by, i0[0] + bx] + return out + + +def fibonacci_directions(n): + """n roughly equal-area directions on the unit sphere.""" + k = np.arange(n) + 0.5 + cz = 1.0 - 2.0 * k / n + r = np.sqrt(np.maximum(0.0, 1.0 - cz * cz)) + phi = np.pi * (1.0 + 5.0 ** 0.5) * k + return np.stack([r * np.cos(phi), r * np.sin(phi), cz], axis=1) + + +def sphericity(p_amb, c, files, ndir=4000): + print() + print(" Wavefront sphericity -- radius of the outgoing front vs direction") + print(" (exact solution is a sphere; every deviation is boundary error)") + print(" chi = angle to the nearest face normal: 0 deg face, 45 edge, " + "54.7 corner") + print() + hdr = (" %-18s %9s %7s %10s %9s %9s %s" % + ("file", "t [s]", "rays", "r_mean", "spread%", "amp_sprd%", + "spread% by chi [0-20|20-40|40-55]")) + print(hdr) + print(" " + "-" * (len(hdr) - 2)) + for fn in files: + axes, a, t = load_nd(fn) + if a.ndim != 3: + print(f" {fn}: not a 3-D dump") + continue + x, y, z = axes + ctr = np.array([0.5 * (x[0] + x[-1]), 0.5 * (y[0] + y[-1]), + 0.5 * (z[0] + z[-1])]) + half = np.array([0.5 * (x[-1] - x[0]), 0.5 * (y[-1] - y[0]), + 0.5 * (z[-1] - z[0])]) + dp = a - p_amb + r_th = c * t + nh = fibonacci_directions(ndir) + # Distance from the centre to the boundary along each ray. + with np.errstate(divide="ignore"): + dbnd = np.min(half / np.maximum(np.abs(nh), 1e-300), axis=1) + keep = r_th < 0.90 * dbnd + if (keep.sum() < 16) or (r_th <= 0): + print(" %-18s %9.3e %7s" % (fn.split("/")[-1], t, "front gone")) + continue + nk = nh[keep] + # chi: angle to the nearest face normal, i.e. to the largest |n| + chi = np.degrees(np.arccos(np.max(np.abs(nk), axis=1))) + rr = np.linspace(0.55 * r_th, 1.45 * r_th, 2400) + r_peak = np.empty(len(nk)) + amp = np.empty(len(nk)) + # Chunked so the sample array stays a sane size. + step = max(1, 2_000_000 // len(rr)) + for s0 in range(0, len(nk), step): + sub = nk[s0:s0 + step] + q = (ctr[None, None, :] + + rr[:, None, None] * sub[None, :, :]).reshape(-1, 3) + vals = trilinear(axes, a * 0 + dp, q).reshape(len(rr), len(sub)) + kmax = np.argmax(np.abs(vals), axis=0) + r_peak[s0:s0 + len(sub)] = rr[kmax] + amp[s0:s0 + len(sub)] = np.abs(vals[kmax, np.arange(len(sub))]) + spread = (r_peak.max() - r_peak.min()) / r_peak.mean() + amp_spread = (amp.max() - amp.min()) / amp.mean() + byc = [] + for lo_, hi_ in ((0, 20), (20, 40), (40, 55)): + m = (chi >= lo_) & (chi < hi_) + byc.append("%.3f" % (100 * (r_peak[m].max() - r_peak[m].min()) / + r_peak[m].mean()) if m.sum() > 8 else " -- ") + print(" %-18s %9.3e %7d %10.4f %9.3f %9.3f %s" % + (fn.split("/")[-1], t, keep.sum(), r_peak.mean(), 100 * spread, + 100 * amp_spread, " | ".join(byc))) + + +def bilinear(x, y, a, xq, yq): + """Sample a on a regular grid at scattered (xq, yq).""" + dx, dy = x[1] - x[0], y[1] - y[0] + fi = (xq - x[0]) / dx + fj = (yq - y[0]) / dy + i0 = np.clip(np.floor(fi).astype(int), 0, len(x) - 2) + j0 = np.clip(np.floor(fj).astype(int), 0, len(y) - 2) + tx = np.clip(fi - i0, 0.0, 1.0) + ty = np.clip(fj - j0, 0.0, 1.0) + return ((1 - tx) * (1 - ty) * a[j0, i0] + tx * (1 - ty) * a[j0, i0 + 1] + + (1 - tx) * ty * a[j0 + 1, i0] + tx * ty * a[j0 + 1, i0 + 1]) + + +def dist_to_boundary(theta, x, y): + """Distance from the origin to the domain boundary along each ray.""" + xmax, ymax = x[-1], y[-1] + xmin, ymin = x[0], y[0] + ct, st = np.cos(theta), np.sin(theta) + big = 1e30 + with np.errstate(divide="ignore", invalid="ignore"): + dx1 = np.where(ct > 0, xmax / ct, np.where(ct < 0, xmin / ct, big)) + dy1 = np.where(st > 0, ymax / st, np.where(st < 0, ymin / st, big)) + return np.minimum(dx1, dy1) + + +def circularity(p_amb, c, files, ntheta=720): + print() + print(" Wavefront circularity -- radius of the outgoing front vs polar angle") + print(" (exact solution is a circle; every deviation is boundary error)") + print() + print(" %-16s %8s %8s %10s %10s %10s %10s" % + ("file", "t/tau", "rays", "r_mean", "spread%", "amp_sprd%", "peak|dp|")) + print(" " + "-" * 82) + for fn in files: + x, y, a, t = load(fn) + dp = a - p_amb + r_th = c * t + theta = np.linspace(0.0, 2 * np.pi, ntheta, endpoint=False) + dbnd = dist_to_boundary(theta, x, y) + # Only rays whose front is still comfortably inside the domain. + keep = r_th < 0.90 * dbnd + if keep.sum() < 8 or r_th <= 0: + print(" %-16s %8.3f %8s" % (fn.split("/")[-1], t, "front gone")) + continue + th = theta[keep] + # Scan each retained ray for the front. + # The radial scan must be finer than the effect being measured: at 400 + # samples one increment is ~0.2 % of r_th, which quantises the radius + # spread and makes two genuinely different boundaries report the same + # number. 2400 puts the quantisation an order of magnitude below the + # grid spacing. + rr = np.linspace(0.55 * r_th, 1.45 * r_th, 2400) + R, TH = np.meshgrid(rr, th, indexing="ij") + vals = bilinear(x, y, dp, R * np.cos(TH), R * np.sin(TH)) + k = np.argmax(np.abs(vals), axis=0) + r_peak = rr[k] + amp = np.abs(vals[k, np.arange(len(th))]) + spread = (r_peak.max() - r_peak.min()) / r_peak.mean() + amp_spread = (amp.max() - amp.min()) / amp.mean() + print(" %-16s %8.3f %8d %10.4f %10.3f %10.3f %10.4e" % + (fn.split("/")[-1], r_th / max(x[-1], 1e-30), keep.sum(), + r_peak.mean(), 100 * spread, 100 * amp_spread, amp.mean())) + + +def residual(p_amb, ref_file, files): + _, a0, _ = load_nd(ref_file) + inc = np.abs(a0 - p_amb).max() + print() + print(" Residual pressure disturbance, relative to the incident amplitude") + print(" incident |dp|_max = %.5e dyn/cm^2" % inc) + print() + print(" %-16s %10s %12s %12s %14s" % + ("file", "t [s]", "max|dp|/inc", "L2|dp|/inc", "mean p")) + print(" " + "-" * 68) + for fn in files: + _, a, t = load_nd(fn) + dp = a - p_amb + l2 = np.sqrt((dp ** 2).mean()) + print(" %-16s %10.4e %12.5f %12.6f %14.4f" % + (fn.split("/")[-1], t, np.abs(dp).max() / inc, l2 / inc, a.mean())) + + +if __name__ == "__main__": + mode = sys.argv[1] + if mode == "circularity": + circularity(float(sys.argv[2]), float(sys.argv[3]), sys.argv[4:]) + elif mode == "sphericity": + sphericity(float(sys.argv[2]), float(sys.argv[3]), sys.argv[4:]) + elif mode == "residual": + residual(float(sys.argv[2]), sys.argv[3], sys.argv[4:]) + else: + print(__doc__) + sys.exit(1)