rizer.cantera_ext
C++ Cantera 1-D plasma extension (custom Domain1D models and solvers)
Loading...
Searching...
No Matches
ReactorRHS.cpp
Go to the documentation of this file.
1#include "ReactorRHS.h"
2#include "units.h"
3
8
9#include <algorithm>
10#include <cmath>
11#include <sstream>
12#include <string>
13
14namespace rizer {
15
16namespace {
17// Exact twin of rizer.kinetics.electron_reactions.reaction_involves_electron: the
18// electron participates iff it appears in the reaction *equation*. Cantera cancels a
19// spectator electron (one on both sides, e.g. "CH4 + e- => CH3 + H + e-") from the
20// reactant/product stoichiometry, so a species-map test would miss such electron-
21// impact excitation/dissociation reactions. Both implementations replace the
22// reaction-arrow and stoichiometric-plus characters '+','=','<','>' with spaces and
23// then look for the electron as a standalone token; they MUST stay byte-for-byte
24// equivalent so the native and Python reactors select the same electron-impact
25// reactions for the inelastic electron-to-gas power. Change them together.
29bool equationHasElectron(const std::string& equation, const std::string& electron)
30{
31 std::string s = equation;
32 for (char& ch : s) {
33 if (ch == '+' || ch == '=' || ch == '<' || ch == '>') ch = ' ';
34 }
35 std::istringstream iss(s);
36 std::string token;
37 while (iss >> token) {
38 if (token == electron) return true;
39 }
40 return false;
41}
42} // namespace
43
44// Physical constants from units.h (mirror rizer/misc/units.py) rather than
45// Cantera's CODATA-2018 values, to match the Python reactor reference.
46constexpr double GasConstant = units::R_kmol; // J/kmol/K
47constexpr double Boltzmann = units::k_b; // J/K
48
49ReactorRHS::ReactorRHS(std::shared_ptr<Cantera::Solution> sol, Config cfg)
50 : m_sol(std::move(sol))
51 , m_nsp(m_sol->thermo()->nSpecies())
52 , m_ie(m_sol->thermo()->speciesIndex("e-"))
53 , m_reacting(cfg.reacting)
54 , m_collision(std::move(cfg.collision))
55 // The legacy fallback members (m_sigma, m_nu_m_tab, m_nu_m, m_nu_E) are always
56 // built from cfg here, but transport() only reads them when m_collision.empty()
57 // is true -- i.e. only one of the two closures is ever actually exercised at
58 // evaluation time.
59 , m_sigma(PropertyTable::fromTableOrScalar(cfg.sigma_T, cfg.sigma_v))
60 , m_nu_m_tab(PropertyTable::fromTableOrScalar(cfg.nu_m_T, cfg.nu_m_v))
61 , m_nu_m(cfg.nu_m)
62 , m_nu_E(cfg.nu_E)
63{
64 if (m_ie == Cantera::npos) {
65 throw std::invalid_argument("ReactorRHS: mechanism has no 'e-' species.");
66 }
67 m_wt.assign(m_sol->thermo()->molecularWeights().begin(),
68 m_sol->thermo()->molecularWeights().end());
69 m_Yc.resize(m_nsp);
70 m_wdot.resize(m_nsp);
71 m_uk.resize(m_nsp);
72 m_cpR.resize(m_nsp);
73 m_cp_mass.resize(m_nsp);
74 m_X.resize(m_nsp);
75 m_nk.resize(m_nsp);
76 const std::size_t nR = m_sol->kinetics()->nReactions();
77 m_rop.resize(nR);
78 m_is_electron_rxn.assign(nR, 0);
79 // Select electron-impact reactions exactly as the Python reference does: the
80 // electron participates iff it appears in the reaction equation. This covers
81 // two-temperature-plasma, three-body-two-temperature-plasma,
82 // reverse-two-temperature-plasma, Druyvesteyn and janev-* reactions -- including
83 // those with a spectator electron that Cantera cancels from the stoichiometry.
84 const std::string e_name = m_sol->thermo()->speciesName(m_ie);
85 bool any_electron_rxn = false;
86 for (std::size_t i = 0; i < nR; i++) {
87 if (equationHasElectron(m_sol->kinetics()->reaction(i)->equation(), e_name)) {
88 m_is_electron_rxn[i] = 1;
89 any_electron_rxn = true;
90 }
91 }
92
93 // Fixed per-reaction threshold energy, epsilon_th_i = sum_k(nu_ki * H_k(0K)),
94 // computed once here from the caller-supplied per-species 0K formation
95 // enthalpies (mirrors rizer.kinetics.electron_reactions.ElectronicReactionEnergetics
96 // exactly -- see Config::species_h0k). Required whenever there is at least one
97 // electron-impact reaction and chemistry is active, since P_inel would otherwise
98 // silently be zero.
99 m_eps_th.assign(nR, 0.0);
100 if (m_reacting && any_electron_rxn) {
101 if (cfg.species_h0k.empty()) {
102 throw std::invalid_argument(
103 "ReactorRHS: Config.species_h0k is required (mechanism has an "
104 "electron-impact reaction and reacting=true).");
105 }
106 if (cfg.species_h0k.size() != m_nsp) {
107 throw std::invalid_argument(
108 "ReactorRHS: Config.species_h0k must have length nSpecies().");
109 }
110 m_sol->kinetics()->getReactionDelta(
111 Cantera::span<const double>(cfg.species_h0k.data(), m_nsp),
112 Cantera::span<double>(m_eps_th.data(), m_eps_th.size()));
113 }
114}
115
116std::string ReactorRHS::speciesName(std::size_t k) const
117{
118 return m_sol->thermo()->speciesName(k);
119}
120
121void ReactorRHS::setState(double Tg, double Te, const double* Y, double rho, double& P,
122 double& Xe, double& Tmean, double& n_e) const
123{
124 auto thermo = m_sol->thermo();
125 // Clip any negative mass fractions (roundoff from the ODE integrator) to zero
126 // before handing them to Cantera; setMassFractions_NoNorm keeps them
127 // un-renormalized so Y still sums to (numerically) 1 as the integrator expects.
128 for (std::size_t k = 0; k < m_nsp; k++) m_Yc[k] = (Y[k] > 0.0) ? Y[k] : 0.0;
129 const double Tg_c = std::max(Tg, 200.0);
130 const double Te_c = std::max(Te, 200.0);
131 thermo->setMassFractions_NoNorm(Cantera::span<const double>(m_Yc.data(), m_nsp));
132 thermo->setElectronTemperature(Te_c);
133 thermo->setState_TD(Tg_c, rho);
134 P = thermo->pressure();
135 Xe = thermo->moleFraction(m_ie);
136 // Two-temperature EOS mean temperature (PlasmaPhase convention),
137 // @f$P = n_\text{tot} k_B T_\text{mean}@f$, with Tmean the mole-fraction-weighted
138 // average of Tg and Te. n_e below uses Tmean (not Tg) to stay consistent with how
139 // Cantera's PlasmaPhase computes P internally.
140 Tmean = (1.0 - Xe) * Tg_c + Xe * Te_c;
141 n_e = Xe * P / (Boltzmann * Tmean);
142}
143
144void ReactorRHS::transport(double Te, double n_e, double P, double Tmean, double& sigma,
145 double& nu_E, double& nu_eH, double& nu_eI,
146 double& nu_eH_mass_weighted) const
147{
148 const double Te_c = std::max(Te, 200.0);
149 // Closure selection: the composition-resolved CollisionModel is preferred and used
150 // exclusively whenever it was supplied (m_collision non-empty); the legacy
151 // tabulated/scalar closure below is only reached when no CollisionModel was given.
152 // Exactly one branch executes per call -- see the Config comment in ReactorRHS.h.
153 if (!m_collision.empty()) {
154 // Composition-resolved: convert Cantera's mole fractions X_k [-] to
155 // per-species number densities n_k [1/m^3] via the ideal-gas number density
156 // @f$n_\text{tot} = P/(k_B T_\text{mean})@f$, since CollisionModel::evaluate()
157 // needs absolute densities, not fractions.
158 const double n_tot = P / (Boltzmann * Tmean);
159 m_sol->thermo()->getMoleFractions(Cantera::span<double>(m_X.data(), m_nsp));
160 for (std::size_t k = 0; k < m_nsp; k++) m_nk[k] = m_X[k] * n_tot;
161 m_collision.evaluate(Te_c, n_e, m_nk.data(), m_nsp, sigma, nu_E, nu_eH, nu_eI,
162 nu_eH_mass_weighted);
163 } else {
164 // Legacy closure: prefer nu_m(Te) (table or scalar) and derive sigma from the
165 // Drude relation @f$\sigma = n_e e^2 / (m_e \nu_m)@f$; if no nu_m is available
166 // at all (nu_m_eff <= 0), fall back further to a directly tabulated sigma(Te).
167 // nu_E is always the fixed scalar in this legacy path (no Te-dependent table
168 // for nu_E). nu_eH/nu_eI/nu_eH_mass_weighted need per-species composition,
169 // unavailable here.
170 const double nu_m_eff = m_nu_m_tab.empty() ? m_nu_m : m_nu_m_tab.eval(Te_c);
171 sigma = (nu_m_eff > 0.0)
172 ? n_e * units::e * units::e / (units::m_e * nu_m_eff)
173 : m_sigma.eval(Te_c);
174 nu_E = m_nu_E;
175 nu_eH = 0.0;
176 nu_eI = 0.0;
177 nu_eH_mass_weighted = 0.0;
178 }
179}
180
181void ReactorRHS::rates(double Tg, double Te, const double* Y, double rho, double E,
182 double& dTg, double& dTe, double* dY, double& lhs_Tg,
183 double& lhs_Te, Diagnostics* diag) const
184{
185 auto thermo = m_sol->thermo();
186 auto kin = m_sol->kinetics();
187
188 double P, Xe, Tmean, n_e;
189 setState(Tg, Te, Y, rho, P, Xe, Tmean, n_e);
190 const double Tg_c = std::max(Tg, 200.0);
191 const double Te_c = std::max(Te, 200.0);
192
193 thermo->getCp_R(m_cpR);
194 const double We = m_wt[m_ie];
195 const double cve = 1.5 * GasConstant / We;
196
197 // Mass-basis species heat capacity c_p,k = (Cp_k/R)*R/W_k [J/kg/K],
198 // exposed via speciesCp() for PlasmaChannel1D's enthalpy-diffusion
199 // energy-equation term. The electron slot instead mirrors the analytic
200 // monatomic-ideal-gas value c_p,e = cv_e + R/W_e (derived from cve just
201 // above, not from getCp_R()), for consistency with how cve/lhs_Te
202 // already bypass Cantera's own electron Cp elsewhere in this function.
203 for (std::size_t k = 0; k < m_nsp; k++) {
204 m_cp_mass[k] = m_cpR[k] * GasConstant / m_wt[k];
205 }
206 m_cp_mass[m_ie] = cve + GasConstant / We;
207
208 // Gas-side heat capacity at constant volume, @f$c_v/R = c_p/R - 1@f$ per species
209 // (ideal gas), mass-weighted over the heavy species only (electron excluded -- it
210 // has its own energy equation with capacitance lhs_Te below) and converted to a
211 // per-volume capacitance via rho: cv_heavy has units J/(kg K) before the rho
212 // multiply.
213 double cv_heavy = 0.0;
214 for (std::size_t k = 0; k < m_nsp; k++) {
215 if (k == m_ie) continue;
216 cv_heavy += m_Yc[k] * (m_cpR[k] - 1.0) * GasConstant / m_wt[k];
217 }
218 // Electron-energy capacitance @f$Y_e \rho c_{v,e}@f$; Y_e is floored away from
219 // zero so lhs_Te stays finite/positive even when the electron mass fraction
220 // underflows to ~0.
221 const double Ye_cap = std::max(m_Yc[m_ie], 1.0e-12);
222 lhs_Te = Ye_cap * cve * rho;
223 lhs_Tg = std::max(cv_heavy * rho, 1.0e-3);
224
225 double sigma, nu_E, nu_eH, nu_eI, nu_eH_mass_weighted;
226 transport(Te_c, n_e, P, Tmean, sigma, nu_E, nu_eH, nu_eI, nu_eH_mass_weighted);
227
228 // Elastic electron-heavy energy exchange (classic elastic-collision power
229 // transfer rate from electrons to the heavy gas, positive when Te > Tg):
230 // @f[
231 // P_\text{el} = \nu_E n_e \frac{3}{2} k_B (T_e - T_g)
232 // @f]
233 // It appears with a minus sign in the electron balance and a plus sign in the
234 // gas balance below (energy conserved between the two) [W/m^3].
235 const double P_el = nu_E * n_e * 1.5 * Boltzmann * (Te_c - Tg_c);
236 // Joule heating, @f$P_\text{joule} = \sigma E^2@f$ [W/m^3]: the resistive power
237 // deposited into the electron population by the electric field; it only enters
238 // the electron-energy balance (dTe), never the gas balance (dTg) directly.
239 const double P_joule = sigma * E * E;
240
241 double P_chem = 0.0, P_chem_e = 0.0, P_inel = 0.0;
242 for (std::size_t k = 0; k < m_nsp; k++) dY[k] = 0.0;
243 if (m_reacting) {
244 kin->getNetProductionRates(m_wdot);
245 kin->getNetRatesOfProgress(m_rop);
246 thermo->getPartialMolarIntEnergies(m_uk);
247 for (std::size_t k = 0; k < m_nsp; k++) {
248 // m_wdot[k] is the net production rate in [kmol/m^3/s] (Cantera
249 // convention); multiplying by the molecular weight [kg/kmol] and
250 // dividing by rho [kg/m^3] converts it to the mass-fraction time
251 // derivative @f$dY_k = \dot\omega_k W_k / \rho@f$ [1/s].
252 dY[k] = m_wdot[k] * m_wt[k] / rho;
253 if (k != m_ie) P_chem += m_uk[k] * m_wdot[k];
254 }
255 P_chem_e = m_wdot[m_ie] * cve * We * Te_c;
256 // Fixed threshold energy per reaction (m_eps_th, precomputed once in the
257 // constructor from 0K formation enthalpies -- see plasma_power_inelastic
258 // in the Python reference).
259 for (std::size_t i = 0; i < m_rop.size(); i++) {
260 if (m_is_electron_rxn[i]) P_inel += m_eps_th[i] * m_rop[i];
261 }
262 }
263
264 dTe = (-P_chem_e - P_el - P_inel + P_joule) / lhs_Te;
265 dTg = (-P_chem + P_el + P_inel) / lhs_Tg;
266
267 if (!std::isfinite(dTe)) dTe = 0.0;
268 if (!std::isfinite(dTg)) dTg = 0.0;
269 for (std::size_t k = 0; k < m_nsp; k++) {
270 if (!std::isfinite(dY[k])) dY[k] = 0.0;
271 }
272
273 if (diag != nullptr) {
274 diag->P_elastic = P_el;
275 diag->P_inelastic = P_inel;
276 diag->P_chemical = P_chem;
277 diag->P_chemical_e = P_chem_e;
278 diag->P_Joule = P_joule;
279 // Collision-frequency diagnostics (Mitchner II-13.3 / II-8.11e /
280 // VIII-3.8), from the same transport() call that produced sigma/nu_E
281 // for this state.
282 diag->nu_eH = nu_eH;
284 diag->nu_eI = nu_eI;
285 diag->nu_eH_mass_weighted = nu_eH_mass_weighted;
286 }
287}
288
289double ReactorRHS::conductivity(double Tg, double Te, const double* Y, double rho) const
290{
291 double P, Xe, Tmean, n_e;
292 setState(Tg, Te, Y, rho, P, Xe, Tmean, n_e);
293 double sigma, nu_E, nu_eH, nu_eI, nu_eH_mass_weighted;
294 transport(std::max(Te, 200.0), n_e, P, Tmean, sigma, nu_E, nu_eH, nu_eI,
295 nu_eH_mass_weighted);
296 return sigma;
297}
298
299double ReactorRHS::electronDensity(double Tg, double Te, const double* Y,
300 double rho) const
301{
302 double P, Xe, Tmean, n_e;
303 setState(Tg, Te, Y, rho, P, Xe, Tmean, n_e);
304 return n_e;
305}
306
307} // namespace rizer
static double electronElectronCollisionFrequency(double n_e, double Te)
Electron-electron collision frequency [1/s] (Mitchner II-8.11e with Z=1), the closed-form ingredient...
void evaluate(double Te, double n_e, const double *n_k, std::size_t nsp, double &sigma, double &nu_E, double &nu_eH, double &nu_eI, double &nu_eH_mass_weighted) const
Composition-resolved electron transport.
double conductivity(double Tg, double Te, const double *Y, double rho) const
Electrical conductivity [S/m] at a node state.
std::string speciesName(std::size_t k) const
Name of species k in the mechanism.
ReactorRHS(std::shared_ptr< Cantera::Solution > sol, Config cfg)
Construct a ReactorRHS for a given plasma Solution and configuration.
double electronDensity(double Tg, double Te, const double *Y, double rho) const
Electron number density [1/m^3], .
void rates(double Tg, double Te, const double *Y, double rho, double E, double &dTg, double &dTe, double *dY, double &lhs_Tg, double &lhs_Te, Diagnostics *diag=nullptr) const
Single-cell RHS (no transport, no volume work).
std::size_t nSpecies() const
Definition ReactorRHS.h:104
const double Boltzmann
const size_t npos
constexpr double k_b
Boltzmann constant [J/K]. Exact (CODATA).
Definition units.h:28
constexpr double R_kmol
Ideal gas constant [J/(kmol K)] (matches Cantera's GasConstant).
Definition units.h:60
constexpr double e
Elementary charge [C]. Exact (CODATA).
Definition units.h:26
constexpr double m_e
Electron mass [kg] (CODATA 2022).
Definition units.h:36
constexpr double Boltzmann
constexpr double GasConstant
std::vector< double > species_h0k
Per-species standard enthalpy of formation at 0 K [J/kmol], length nSpecies(), in mechanism species-i...
Definition ReactorRHS.h:94
Optional power / Maxwellian-condition breakdown filled by rates() when a non-null pointer is passed.
Definition ReactorRHS.h:43
double P_Joule
Joule heating power [W/m^3].
Definition ReactorRHS.h:48
double nu_eH
Electron-heavy momentum-transfer collision frequency [1/s] (Mitchner II-13.3).
Definition ReactorRHS.h:49
double P_chemical_e
Electron chemical power [W/m^3].
Definition ReactorRHS.h:47
double P_chemical
Heavy-species chemical power [W/m^3].
Definition ReactorRHS.h:46
double P_inelastic
Inelastic electron-impact-reaction power [W/m^3].
Definition ReactorRHS.h:45
double nu_eI
Electron-ion momentum-transfer collision frequency [1/s].
Definition ReactorRHS.h:51
double nu_ee
Electron-electron collision frequency [1/s] (Mitchner II-8.11e).
Definition ReactorRHS.h:50
double P_elastic
Elastic electron-heavy exchange power [W/m^3].
Definition ReactorRHS.h:44
double nu_eH_mass_weighted
sum_h nu_eh/m_h [1/kg/s] (Mitchner VIII-3.8, term 2)
Definition ReactorRHS.h:52
Physical constants and unit conversions, in SI by default.