Repository navigation
Source term method for preferential diffusion - #2970
joshkellyjak wants to merge 6 commits into
Conversation
…te fixes Adds the resolved Eq. (14) preferential diffusion flux formulation (model B2 of Schepers & van Oijen, C&F 280 (2025) 114332) to the flamelet scalar transport, alongside the existing beta correction, and fixes two defects that left the existing model inert or wrong. Model - PREFERENTIAL_DIFFUSION_METHOD selects BETA_CORRECTION (default, unchanged) or SOURCE_TERM. - PREFERENTIAL_DIFFUSION_MAJOR_SPECIES names the species carrying the resolved flux. The manifold variables (Res_<cv>, Y-<sp>, D_<cv>_<sp>, DT_<cv>) are composed from the configured controlling variable and species names. - The Eq. (14) fluxes are a new branch of CScalarFlux_Flamelet, driven by the CFD-resolved gradients of the major species and of temperature. Explicit, with a stabilising self-diffusion added to the Jacobian alone so the converged solution is unchanged. - The Eq. (16) closure sources are gated on manifold validity only. Fixes - nAuxVar was never set, so SetAuxVar_Gradient_* looped zero times and every beta gradient stayed zero. The beta term then evaluated grad(beta) - grad(phi) as -grad(phi), cancelling the ordinary diffusion of the controlling variables rather than correcting it. - LEAST_SQUARES fell through the auxiliary gradient switch, with the same effect. - Soret coefficients are zeroed on viscous walls, where the manifold carries no quench layer data. - A collective in the volume output ran behind an "iPoint == 0" test inside the per point loop; a rank owning no points would have hung the others. Moved to a new per write COutput::PrepareVolumeData hook. - The cached preferential diffusion source is cleared at off manifold nodes, and the Newton non convergence message is rank guarded. Diagnostics - FLAME_QUALITY volume group, hull miss deviations, FLAMELET_VERBOSE_MISSES, and per location probe history files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…le gradient The stabilising diffusivity is a bound on the Eq. (14) flux recast as a self diffusion, |J| <= D_stab |grad phi|, so it has to be divided by the controlling variable's own gradient. Without that division D_stab sits at the raw coefficient magnitude everywhere, which damps the update far more than the explicit flux warrants: on the burner case every stabilised run plateaued near rms[h] = -2.45 whatever the CFL, so the higher ceiling bought no convergence. With the division restored, CFL 2 reaches -2.530 against -2.528 at CFL 1, and the ceiling moves from (1, 2) without the term to (2, 3) with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| coefficient D^T_{phi_k}. ---*/ | ||
| if (source_term_active) { | ||
| pd_terms_per_cv = FlameletPDTermsPerCV(flamelet_config_options.n_pd_major_species); | ||
| pd_flux_coeff.resize(nPoint, flamelet_config_options.n_control_vars * pd_terms_per_cv) = su2double(0.0); |
…spaces) (#2935) ## Proposed Changes `SU2_PY/SU2/run/interface.py` builds the command as `os.path.join(SU2_RUN, "SU2_CFD") + " config_CFD.cfg"` and runs it with `shell=True`. The executable path is quoted only on Windows (`quote = '"' if sys.platform == "win32" else ""`), so on Linux/macOS any SU2_PY script (shape_optimization.py, parallel_computation.py, ...) fails if `SU2_RUN` contains a space. This PR moves the quoting into `build_command`: the first word of the command is joined with `SU2_RUN` and quoted (`shlex.quote` on POSIX, double quotes on Windows as before), the callers pass plain `"SU2_CFD config_CFD.cfg"` strings. The quoted path is then inserted into the MPI template as before (`mpirun -n %i %s`, `srun`, `SU2_MPI_COMMAND`). The module-level `quote` variable is removed; it was not used outside `interface.py`. Reproduction on macOS, `SU2_RUN=".../su2 bin"`, `shape_opt_euler_py` from serial_regression.py: ``` before: Command = /.../su2 bin/SU2_CFD config_CFD.cfg SU2 process returned error '127' /bin/sh: /.../su2: No such file or directory after: Command = '/.../su2 bin/SU2_CFD' config_CFD.cfg optimization runs to the end ``` With the change and `SU2_RUN` containing a space, `history_project.csv` and the optimizer output of `shape_opt_euler_py` are identical to develop with a `SU2_RUN` without spaces. The MPI path was checked with `NUMBER_PART=2`: `mpirun -n 2 '/.../su2 bin/SU2_CFD' config_CFD.cfg` runs with 2 ranks. The Windows branch produces the same string as before. ## Related Work None found. Separate from #2934 (also SU2_PY). ## PR Checklist - [X] I am submitting my contribution to the develop branch. - [X] My contribution generates no new compiler warnings (try with --warnlevel=3 when using meson). - [X] My contribution is commented and consistent with SU2 style (https://su2code.github.io/docs_v7/Style-Guide/). - [X] I used the pre-commit hook to prevent dirty commits and used `pre-commit run --all` to format old commits. - [X] I have added a test case that demonstrates my contribution, if necessary. — Not necessary: the change only affects how the executable path is quoted; `shape_opt_euler_py` passes unchanged, and the reproduction above (SU2_RUN with a space) fails before and passes after. - [X] I have updated appropriate documentation (Tutorials, Docs Page, config_template.cpp), if necessary. — Not necessary: no user-facing option changed. Co-authored-by: Nijso <bigfootedrockmidget@hotmail.com>
This is the correct behavior as described in Everts thesis, the ordinary diffusion should be cancelled, leaving only the beta correction term. |
This description is wrong the beta correction side is unchanged from develop other than the nAuxVar fix |
|
OK, I checked what was really going on with the nAuxvar fix. This bug actually puts the cross-diffusion term of the gradient of beta to zero. On orthogonal meshes this nAuxvar contribution is zero. |
|
OK, so I checked the paper of Stijn. So it's an extension of Nithin's paper for mixture averaged diffusion (instead of constant Lewis per species) and he adds Soret diffusion. And Stijn uses only major species and then has some truncation source term. |
Yes, as the forthcoming PR to DataMiner will show, when you have a PV definition with non-major species you end up with some additional conservation term, relatively small to all other contributions but necessary for conservation |
|
https://eprints.soton.ac.uk/id/eprint/494934/1/1-s2.0-S1540748924003894-main.pdf From Heinz's group also good for validation study |
EvertBunschoten
left a comment
There was a problem hiding this comment.
I like the extensive commentary on the functionality of the new model and the addition of table diagnostics. I think however that effort should be made to reduce the number of accessors to table diagnostics.
| * Returns 0 for 2D tables or when only one Z level exists. | ||
| */ | ||
| inline su2double GetDistanceToNearestZLevel(su2double val_CV3) const { | ||
| if (table_dim < 3 || n_table_levels < 2) return 0.0; |
There was a problem hiding this comment.
checking for fewer than two table levels is redundant.
| inline su2double GetDistanceToNearestZLevel(su2double val_CV3) const { | ||
| if (table_dim < 3 || n_table_levels < 2) return 0.0; | ||
| auto it = std::lower_bound(z_values_levels.begin(), z_values_levels.end(), val_CV3); | ||
| su2double d_hi = (it != z_values_levels.end()) ? abs(*it - val_CV3) : su2double(1e30); |
| */ | ||
| inline su2double GetDistanceToNearestZLevel(su2double val_CV3) const { | ||
| if (table_dim < 3 || n_table_levels < 2) return 0.0; | ||
| auto it = std::lower_bound(z_values_levels.begin(), z_values_levels.end(), val_CV3); |
There was a problem hiding this comment.
The z-levels are ordered, you can just calculate the distances from the first and last table level.
| * themselves are configured at run time with PREFERENTIAL_DIFFUSION_MAJOR_SPECIES, so their | ||
| * count is not a compile-time constant. | ||
| */ | ||
| static inline unsigned short FlameletPDThermalTerm(unsigned short n_major_species) { return n_major_species; } |
There was a problem hiding this comment.
So this function just returns its own input? Can be removed?
| unsigned short n_pd_major_species = 0; /*!< \brief Number of preferential diffusion major species (SOURCE_TERM method). */ | ||
| std::string* pd_major_species_names; /*!< \brief Names of the preferential diffusion major species; the manifold | ||
| variables "D_<cv>_<species>" are composed from these. */ | ||
| bool verbose_misses = false; /*!< \brief Print per-CV breakdown of manifold miss counts. */ |
There was a problem hiding this comment.
This option should have a more general scope, as it applies to the LUT class, not just the flamelet solver settings.
| FORCEINLINE void sourceTermFluxes(const FlowIndices& idx, const ScalarFluxOptions& opt, Int iPoint, | ||
| const EdgeSide<VariableType>& side_i, Int jPoint, | ||
| const EdgeSide<VariableType>& side_j, const CPair<Double>& rho, | ||
| const Vector<Double, nDim>& normal, const Vector<Double, nDim>& vector_ij, | ||
| EdgeResidual<Double, nVar>& res) const { | ||
| const Double dist2_ij = fmax(squaredNorm(vector_ij), EPS); | ||
| const Double proj_vector_ij = dot(vector_ij, normal) / dist2_ij; | ||
|
|
||
| const Double T_i = gatherVariables(iPoint, side_i.flowNodes->GetPrimitive(), idx.Temperature()); | ||
| const Double T_j = gatherVariables(jPoint, side_j.flowNodes->GetPrimitive(), idx.Temperature()); | ||
| const auto gradT_i = gatherVariables<nDim>(iPoint, side_i.flowNodes->GetGradient_Primitive(), idx.Temperature()); | ||
| const auto gradT_j = gatherVariables<nDim>(jPoint, side_j.flowNodes->GetGradient_Primitive(), idx.Temperature()); | ||
|
|
||
| /*--- The scalar solvers are templated on CSpeciesVariable, which does not carry the Eq. (14) | ||
| coefficients. Only the flamelet solver instantiates this flux, and only on its own interior | ||
| edges, so its container is what these sides hold. ---*/ | ||
| const auto& pdCoeff_i = static_cast<const CSpeciesFlameletVariable&>(side_i.scalarNodes).GetPDFluxCoeffs(); | ||
| const auto& pdCoeff_j = static_cast<const CSpeciesFlameletVariable&>(side_j.scalarNodes).GetPDFluxCoeffs(); | ||
|
|
||
| for (auto iScalar = 0u; iScalar < nControlVars; ++iScalar) { | ||
| /*--- Magnitude of the Eq. (14) flux on each side, accumulated as the terms are applied, for | ||
| the stabilising diffusivity below. ---*/ | ||
| Double fluxMag_i = 0.0, fluxMag_j = 0.0; | ||
|
|
||
| /*--- Molecular terms, one per major species. ---*/ | ||
| for (auto iSp = 0u; iSp < nMajorSpecies; ++iSp) { | ||
| const auto col = iScalar * pdTermsPerCV + iSp; | ||
|
|
||
| const Double Y_i = gatherVariables(iPoint, side_i.scalarNodes.GetAuxVar(), iSp); | ||
| const Double Y_j = gatherVariables(jPoint, side_j.scalarNodes.GetAuxVar(), iSp); | ||
| const auto gradY_i = gatherVariables<nDim>(iPoint, side_i.scalarNodes.GetAuxVarGradient(), iSp); | ||
| const auto gradY_j = gatherVariables<nDim>(jPoint, side_j.scalarNodes.GetAuxVarGradient(), iSp); | ||
|
|
||
| const Double c_i = gatherVariables(iPoint, pdCoeff_i, col); | ||
| const Double c_j = gatherVariables(jPoint, pdCoeff_j, col); | ||
| const Double D = 0.5 * (c_i + c_j); | ||
|
|
||
| const Double projGrad = projectedGradient(opt, gradY_i, gradY_j, Y_i, Y_j, normal, vector_ij, dist2_ij); | ||
|
|
||
| res.flux_i(iScalar) -= D * projGrad; | ||
| if (!opt.oneSided) res.flux_j(iScalar) += D * projGrad; | ||
|
|
||
| if (opt.implicit) { | ||
| fluxMag_i += fabs(c_i) * sqrt(fmax(squaredNorm(gradY_i), EPS)); | ||
| fluxMag_j += fabs(c_j) * sqrt(fmax(squaredNorm(gradY_j), EPS)); | ||
| } | ||
| } | ||
|
|
||
| /*--- Thermal (Soret) term. ---*/ | ||
| const auto colT = iScalar * pdTermsPerCV + FlameletPDThermalTerm(nMajorSpecies); | ||
| const Double Dth = 0.5 * (gatherVariables(iPoint, pdCoeff_i, colT) / T_i + | ||
| gatherVariables(jPoint, pdCoeff_j, colT) / T_j); | ||
|
|
||
| const Double projGradT = projectedGradient(opt, gradT_i, gradT_j, T_i, T_j, normal, vector_ij, dist2_ij); | ||
|
|
||
| res.flux_i(iScalar) -= Dth * projGradT; | ||
| if (!opt.oneSided) res.flux_j(iScalar) += Dth * projGradT; | ||
|
|
||
| /*--- Stabilisation. The fluxes above are explicit, and explicit diffusion carries a limit on | ||
| the pseudo time step that the flame front cells reach first. A self-diffusion of the | ||
| controlling variable is added to the Jacobian ALONE: the residual still holds exactly the | ||
| Eq. (14) fluxes, so the converged solution is untouched, while the positive definite | ||
| operator lifts that limit the way an implicit treatment of a real self-diffusion would. | ||
| D_stab is the baseline diffusivity scaled by how much larger the Eq. (14) coefficients are, | ||
| capped so a vanishing baseline cannot make it unbounded. ---*/ | ||
| if (opt.implicit) { | ||
| const Double Dbase_i = rho.i * gatherVariables(iPoint, side_i.scalarNodes.GetDiffusivity(), iScalar); | ||
| const Double Dbase_j = rho.j * gatherVariables(jPoint, side_j.scalarNodes.GetDiffusivity(), iScalar); | ||
|
|
||
| /*--- Recast the flux bound as a self diffusivity, |J| <= D_stab |grad phi|, so the implicit | ||
| operator dominates the explicit flux without damping more than the flux itself warrants. | ||
| Scaling by the control variable's own gradient is what keeps it that tight: dropping it | ||
| leaves D_stab at the raw coefficient magnitude, which over damps and cancels the very CFL | ||
| gain the term exists to provide. Where grad(phi) vanishes while the cross gradients do not | ||
| the bound degenerates, so it is capped against the baseline diffusion. ---*/ | ||
| fluxMag_i += fabs(gatherVariables(iPoint, pdCoeff_i, colT) / T_i) * sqrt(fmax(squaredNorm(gradT_i), EPS)); | ||
| fluxMag_j += fabs(gatherVariables(jPoint, pdCoeff_j, colT) / T_j) * sqrt(fmax(squaredNorm(gradT_j), EPS)); | ||
|
|
||
| const auto gradPhi_i = gatherVariables<nDim>(iPoint, side_i.scalarNodes.GetGradient(), iScalar); | ||
| const auto gradPhi_j = gatherVariables<nDim>(jPoint, side_j.scalarNodes.GetGradient(), iScalar); | ||
|
|
||
| const Double Dstab_i = fmin(fluxMag_i / sqrt(fmax(squaredNorm(gradPhi_i), EPS)), C_STAB_MAX * Dbase_i); | ||
| const Double Dstab_j = fmin(fluxMag_j / sqrt(fmax(squaredNorm(gradPhi_j), EPS)), C_STAB_MAX * Dbase_j); | ||
| const Double Dstab = 0.5 * (Dstab_i + Dstab_j); | ||
|
|
||
| res.jac_ii(iScalar, iScalar) += Dstab * proj_vector_ij / rho.i; | ||
| if (!opt.oneSided) { | ||
| res.jac_ij(iScalar, iScalar) -= Dstab * proj_vector_ij / rho.j; | ||
| res.jac_ji(iScalar, iScalar) -= Dstab * proj_vector_ij / rho.i; | ||
| res.jac_jj(iScalar, iScalar) += Dstab * proj_vector_ij / rho.j; | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This function has a lot of scopes and is quite long. Try to refactor it into smaller functions.
| * of a boundary do not carry. They are an interior edge term: boundaries instantiate | ||
| * CScalarFlux_Species, as they did before this model existed. | ||
| */ | ||
| template <class Double, class FlowIndices, int nDim, size_t nVar = Dynamic> |
There was a problem hiding this comment.
What is the reason for using the Double in the template? Why can't we just use su2double throughout?
| inline void SetHullMissDevCV1(unsigned long iPoint, su2double val) override { hull_miss_dcv1_(iPoint) = val; } | ||
| inline su2double GetHullMissDevCV1(unsigned long iPoint) const override { return hull_miss_dcv1_(iPoint); } | ||
| inline void SetHullMissDevCV2(unsigned long iPoint, su2double val) override { hull_miss_dcv2_(iPoint) = val; } | ||
| inline su2double GetHullMissDevCV2(unsigned long iPoint) const override { return hull_miss_dcv2_(iPoint); } | ||
| inline void SetZLevelDist(unsigned long iPoint, su2double val) override { z_level_dist_(iPoint) = val; } | ||
| inline su2double GetZLevelDist(unsigned long iPoint) const override { return z_level_dist_(iPoint); } | ||
|
|
There was a problem hiding this comment.
Instead of adding accessors for each individual table diagnostic, it would be more clean to create an array of size nPoint containing structs with table diagnostics. That way, you'll only have to use a single pair of accessors for table diagnostics and it will be easier to add more table diagnostics in the future.
| if (varIdx >= 24) { | ||
| return solver[SPECIES_SOL]->GetNodes()->GetAuxVar(iPoint, varIdx - 24); | ||
| } | ||
| if (varIdx >= 16) { | ||
| const auto* src_pd = solver[SPECIES_SOL]->GetNodes()->GetScalarSourcesPD(iPoint); | ||
| return src_pd ? src_pd[varIdx - 16] : su2double(0.0); | ||
| } | ||
| const auto* src = solver[SPECIES_SOL]->GetNodes()->GetScalarSources(iPoint); | ||
| return src ? src[varIdx - 8] : su2double(0.0); |
| flamelet_config_options.pd_method == FLAMELET_PD_METHOD::SOURCE_TERM) { | ||
| auto* wall_nodes = static_cast<CSpeciesFlameletVariable*>(nodes); | ||
| const auto n_CV_wall = flamelet_config_options.n_control_vars; | ||
| const auto i_thermal = FlameletPDThermalTerm(flamelet_config_options.n_pd_major_species); | ||
|
|
||
| for (auto iMarker = 0u; iMarker < config->GetnMarker_All(); iMarker++) { | ||
| /*--- Viscous walls only. GetSolid_Wall would also match EULER_WALL, a slip boundary with no | ||
| quench layer and so nothing to suppress. ---*/ | ||
| if (!config->GetViscous_Wall(iMarker)) continue; | ||
| SU2_OMP_FOR_STAT(OMP_MIN_SIZE) | ||
| for (auto iVertex = 0ul; iVertex < geometry->nVertex[iMarker]; iVertex++) { | ||
| const auto iPoint_wall = geometry->vertex[iMarker][iVertex]->GetNode(); | ||
| for (auto iCV = 0u; iCV < n_CV_wall; iCV++) | ||
| wall_nodes->SetPDFluxCoeff(iPoint_wall, iCV, i_thermal, 0.0); | ||
| } | ||
| END_SU2_OMP_FOR | ||
| } |
There was a problem hiding this comment.
This would be better placed in the BC function for viscous walls.
Proposed Changes
Adds the resolved Eq. (14) preferential diffusion flux formulation (model B2 of
Schepers & van Oijen, C&F 280 (2025) 114332) to the flamelet scalar transport,
alongside the existing beta correction, and fixes two defects that leave the
current preferential diffusion model not merely inert but actively wrong.
Two fixes that affect
developtoday.CSpeciesFlameletVariablesizesAuxVarandGrad_AuxVarto the four beta terms but never setsnAuxVar, soSetAuxVar_Gradient_GG/LSloop zero times and every beta gradient stays zero.The FGM model drives the diffusive flux of the controlling variables by
grad(beta) rather than by their own gradient (Bunschoten, Eqs. 2.8-2.10); the
solver reaches that from the generic scalar base class by adding
div(D grad(beta - phi)) to the ordinary div(D grad phi), which sums to
div(D grad beta). With the beta gradients zero that sum is zero, so the
controlling variables are left with no diffusion at all. Separately,
LEAST_SQUARESfalls through the auxiliary gradient switch, with the sameconsequence for anyone not on Green-Gauss.
New model.
PREFERENTIAL_DIFFUSION_METHODselectsBETA_CORRECTION(default, unchanged) or
SOURCE_TERM.PREFERENTIAL_DIFFUSION_MAJOR_SPECIESnames the species carrying the resolved flux; the manifold variables
(
Res_<cv>,Y-<sp>,D_<cv>_<sp>,DT_<cv>) are composed from theconfigured controlling variable and species names rather than hard-coded, so one
set of strings describes the table on both sides. The Eq. (14) fluxes are a new
branch of
CScalarFlux_Flamelet, driven by the CFD-resolved gradients of themajor species and of temperature — the point of tabulating a coefficient per
species instead of pre-contracting them against the one-dimensional flamelet
gradients. They are applied explicitly, with a stabilising self-diffusion added
to the Jacobian alone so the converged solution is untouched. The Eq. (16)
closure sources are gated on manifold validity only.
Also fixed. Soret coefficients are zeroed on viscous walls, where the
manifold carries no quench-layer data. A collective in the volume output ran
behind an
iPoint == 0test inside the per-point loop, so a rank owning nopoints would have hung every other rank; it moves to a new per-write
COutput::PrepareVolumeDatahook, where an empty partition contributes theMIN/MAX identity elements instead of not participating. The cached preferential
diffusion source is cleared at off-manifold nodes, and the Newton
non-convergence message is rank-guarded.
Diagnostics.
FLAME_QUALITYvolume group (C+, chi_C, Da), hull-missdeviations,
FLAMELET_VERBOSE_MISSES, and per-location probe history files.Validation
Laminar premixed H2 burner against an OpenFOAM FGM reference, on a manifold
whose progress variable is defined on the major species only.
four equations; Temperature and Density agree with the reference
implementation to 5-6 significant figures.
figures at CFL 1); CFL ceiling moves from (1, 2) without it to (2, 3) with it;
misses hold at 1-2 across that range, against 1 -> 7 -> 49 without.
A chemical-activity gate on the closure sources was removed:
fmax(0, .)clipping meant it only closed where the tabulated source wasnegative, so it closed at 320 preheat-zone nodes (304-567 K) while staying open
across 21k gradient-free bulk nodes — the inverse of its stated purpose.
Caveats
nAuxVarfix changes results for every existingBETA_CORRECTIONuser, including regression case 07, which runs the default method.
Baselines will move. The fix is correct, but the new behaviour still needs
validating on a manifold built for the beta formulation — on the
SOURCE_TERM-oriented manifold used here,
BETA_CORRECTIONdiverges.heat flux by up to 1.2% and wall temperature by 0.4 K, decaying to nothing
within 0.5 mm. Justified by manifold support, not impermeability: the wall
face carries no Eq. (14) flux in any case, since only interior edges are
visited. What it changes is the near-wall interior edges, through the
coefficient averaging.
restart on this burner, with a coarse ladder (1, 2, 3). Treat ~1.5x as
indicative, not a precise factor.
different manifolds and two different flux discretizations). Retained as
insurance against a poorly matched table, but unexercised.
Related Work
This work was developed on a branch that predates the rewrite of the flamelet
scalar transport into
CScalarFlux_Flamelet/EdgeFluxResidual, and the fluxassembly was ported onto that framework rather than carried over. The port is
validated by reproducing the pre-port implementation's residuals to the digit
with the stabiliser disabled.
PR Checklist
pre-commit run --allto format old commits.