rizer.cantera_ext
C++ Cantera 1-D plasma extension (custom Domain1D models and solvers)
Loading...
Searching...
No Matches
CollisionModel.cpp
Go to the documentation of this file.
1#include "CollisionModel.h"
4#include "units.h"
5
6#include <algorithm>
7#include <cmath>
8#include <stdexcept>
9#include <string>
10
11namespace rizer {
12
13namespace {
14
15// Physical constants from units.h (mirror rizer/misc/units.py) so the C++
16// collision physics reproduces the Python MixtureCollisionFrequencies
17// reference to interpolation error. Short local aliases keep the formula
18// bodies readable.
19constexpr double kE = units::e; // elementary charge [C]
20constexpr double kKB = units::k_b; // Boltzmann constant [J/K]
21constexpr double kME = units::m_e; // electron mass [kg]
22constexpr double kEPS0 = units::epsilon_0; // vacuum permittivity [F/m]
23constexpr double kNA = units::N_a; // Avogadro [1/mol]
24constexpr double kPI = units::pi;
25
26// ---------------------------------------------------------------------------------- //
27// File-local helpers //
28// ---------------------------------------------------------------------------------- //
29
30// Electron thermal velocity (equations.py):
31// @f[
32// v_\text{th} = \sqrt{\frac{8 k_B T_e}{\pi m_e}} \quad [\text{m/s}].
33// @f]
34// C++ counterpart of rizer.transport.coulomb.electron_thermal_velocity.
35// \param Te Electron temperature [K]
36inline double thermalVelocity(double Te)
37{
38 return std::sqrt(8.0 * kKB * Te / (kPI * kME));
39}
40
41// Mitchner Coulomb logarithm, refused where it is undefined exactly as the
42// Python reference refuses it: without electrons the Debye length is
43// infinite, and a Debye length below the mean impact parameter makes
44// lnLambda negative, i.e. a negative collision frequency. A TrialStateError
45// rather than a std:: exception: these depend on the state CVODE proposed,
46// so it should retry a smaller step, not abandon the solve.
47// @f[
48// \ln\Lambda = \ln\!\left(\frac{\lambda_D}{\overline{b_0}}\right), \qquad
49// \lambda_D = \sqrt{\frac{\varepsilon_0 k_B T_e}{n_e e^2}}, \qquad
50// \overline{b_0} = \frac{Z e^2}{12 \pi \varepsilon_0 k_B T_e}.
51// @f]
52// C++ counterpart of rizer.transport.coulomb.coulomb_logarithm(model="Mitchner").
53// \param n_e Electron number density [m^-3]
54// \param Te Electron temperature [K]
55// \param Z Ion charge state (>0) of the colliding heavy species
56inline double coulombLogMitchner(double n_e, double Te, int Z)
57{
58 if (!(n_e > 0.0) || !(Te > 0.0)) {
59 throw TrialStateError(
60 "CollisionModel::coulombLogMitchner",
61 "the Coulomb logarithm needs a positive electron "
62 "density and temperature, got n_e = " + std::to_string(n_e)
63 + " m^-3, Te = " + std::to_string(Te) + " K.");
64 }
65 const double lambda_D = std::sqrt(kEPS0 * kKB * Te / (n_e * kE * kE));
66 const double b0_bar = static_cast<double>(Z) * kE * kE
67 / (12.0 * kPI * kEPS0 * kKB * Te);
68 if (lambda_D < b0_bar) {
69 throw TrialStateError(
70 "CollisionModel::coulombLogMitchner",
71 "the Debye length " + std::to_string(lambda_D)
72 + " m is below the mean impact parameter " + std::to_string(b0_bar)
73 + " m (n_e = " + std::to_string(n_e) + " m^-3, Te = "
74 + std::to_string(Te) + " K), so the Coulomb logarithm is undefined.");
75 }
76 return std::log(lambda_D / b0_bar);
77}
78
79// Mitchner II-8.11e prefactor (includes Z^2):
80// @f[
81// c_\text{st}(Z) = \frac{4 \sqrt{2 \pi}}{3}
82// \left(\frac{m_e}{k_B}\right)^{1.5}
83// \left(\frac{e^2}{4 \pi \varepsilon_0 m_e}\right)^2 Z^2.
84// @f]
85// Shared by the per-ion-species prefactor (CollisionModel ctor) and the
86// electron-electron collision frequency (Z=1) below.
87// \param Z Charge state (ion charge number, or 1 for electron-electron)
88double ionCollisionPrefactor(int Z)
89{
90 const double a = 4.0 * std::sqrt(2.0 * kPI) / 3.0;
91 const double b = std::pow(kME / kKB, 1.5);
92 const double c = kE * kE / (4.0 * kPI * kEPS0 * kME);
93 return a * b * c * c * static_cast<double>(Z) * static_cast<double>(Z);
94}
95
96} // namespace
97
98// ---------------------------------------------------------------------------------- //
99// Constructor //
100// ---------------------------------------------------------------------------------- //
101
102CollisionModel::CollisionModel(const std::vector<SpeciesSpec>& specs, double Te_min,
103 double Te_max, std::size_t Te_n)
104{
105 // Log-spaced Te grid for the precomputed Qbar(Te) tables (smooth function;
106 // log spacing resolves the low-Te knee). PropertyTable handles non-uniform.
107 if (Te_n < 2) {
108 throw std::invalid_argument(
109 "CollisionModel: Te_n must be at least 2 to span [Te_min, Te_max], got "
110 + std::to_string(Te_n) + ".");
111 }
112 if (!(Te_min > 0.0 && Te_max > Te_min)) {
113 throw std::invalid_argument(
114 "CollisionModel: the Te grid needs 0 < Te_min < Te_max, got Te_min = "
115 + std::to_string(Te_min) + " K, Te_max = " + std::to_string(Te_max) + " K.");
116 }
117 std::vector<double> Te_grid(Te_n);
118 const double llo = std::log(Te_min), lhi = std::log(Te_max);
119 for (std::size_t i = 0; i < Te_n; i++) {
120 Te_grid[i] = std::exp(llo + (lhi - llo) * static_cast<double>(i)
121 / static_cast<double>(Te_n - 1));
122 }
123
124 m_species.resize(specs.size());
125 for (std::size_t k = 0; k < specs.size(); k++) {
126 const SpeciesSpec& s = specs[k];
127 SpeciesCollisionConfig& sp = m_species[k];
128 if (s.molar_mass > 0.0) {
129 // m_h [kg] = molar_mass [kg/mol] / N_A, so N_A / molar_mass = 1 / m_h;
130 // molar_mass is already SI (kg/mol) here -- the Python side converts
131 // from g/mol via *1e-3 before this ever reaches C++ (see
132 // unpack_momentum_transfer_models / PlasmaChannel species setup).
133 sp.two_me_over_mh = 2.0 * kME * kNA / s.molar_mass; // 2 m_e / m_h
134 sp.inv_mh = kNA / s.molar_mass; // 1 / m_h
135 }
136 if (s.is_electron) {
137 sp.kind = SpeciesCollisionKind::Skip;
138 } else if (s.Z > 0) {
139 sp.kind = SpeciesCollisionKind::Ion;
140 sp.Z = s.Z;
141 // Precomputed once here exactly as
142 // IonMomentumTransferCollisionFrequencyModel.__init__ does in the
143 // Python reference (rizer/transport/collision_frequency.py).
144 sp.cst = ionCollisionPrefactor(s.Z);
145 } else if (!s.energy_J.empty() || !s.sigma_m2.empty()) {
146 // Validated, not used as a branch test. Letting a mismatched pair
147 // fall through to the next arm silently swaps this species'
148 // collision model for a hard sphere, or drops it from the heavy
149 // sums entirely -- so a requested cross section would never be
150 // used and nothing would say so.
151 if (s.energy_J.size() != s.sigma_m2.size() || s.energy_J.size() < 2) {
152 throw std::invalid_argument(
153 "CollisionModel: species " + std::to_string(k)
154 + " has a tabulated cross section of "
155 + std::to_string(s.energy_J.size()) + " energies against "
156 + std::to_string(s.sigma_m2.size())
157 + " values. They must be the same length and at least 2 "
158 "points. Leave both empty to use the hard-sphere radius "
159 "or to skip this species.");
160 }
161 sp.kind = SpeciesCollisionKind::Tabulated;
162 // C++ counterpart of TabulatedSpeciesCrossSection: bake the Maxwellian
163 // average into a Te table once at construction (build time), rather
164 // than integrating live every RHS evaluation.
165 std::vector<double> Qbar =
167 sp.Qbar = PropertyTable(Te_grid, Qbar);
168 } else if (s.radius > 0.0) {
169 sp.kind = SpeciesCollisionKind::HardSphere;
170 sp.Qbar = PropertyTable::constant(4.0 / 3.0 * kPI * s.radius * s.radius);
171 } else {
172 sp.kind = SpeciesCollisionKind::Skip; // no model -> ignored in the heavy sums
173 }
174 }
175}
176
177// ---------------------------------------------------------------------------------- //
178// Public methods //
179// ---------------------------------------------------------------------------------- //
180
181void CollisionModel::evaluate(double Te, double n_e, const double* n_k, std::size_t nsp,
182 double& sigma, double& nu_E, double& nu_eH, double& nu_eI,
183 double& nu_eH_mass_weighted) const
184{
185 sigma = 0.0;
186 nu_E = 0.0;
187 nu_eH = 0.0;
188 nu_eI = 0.0;
189 nu_eH_mass_weighted = 0.0;
190 // Refused rather than answered with zeros: every frequency below carries a
191 // power of Te, and sigma = nu_E = 0 would read as a cold neutral gas
192 // instead of as a state with no electron temperature. The negated
193 // comparison also rejects a NaN. A TrialStateError, so CVODE retries.
194 if (!(Te > 0.0)) {
195 throw TrialStateError(
196 "CollisionModel::evaluate", "Te = " + std::to_string(Te)
197 + " K; the electron temperature must be positive.");
198 }
199 if (m_species.empty()) return;
200 // Refused rather than evaluated over the shorter of the two: that drops
201 // species from every collision sum, or ignores the caller's extra ones.
202 if (nsp != m_species.size()) {
203 throw std::invalid_argument(
204 "CollisionModel::evaluate: got number densities for "
205 + std::to_string(nsp) + " species, but the model was built for "
206 + std::to_string(m_species.size()) + ".");
207 }
208
209 const double v_th = thermalVelocity(Te);
210 double nu_eH_sum = 0.0, nu_ions_sum = 0.0, nu_E_sum = 0.0, nu_eH_mass_weighted_sum = 0.0;
211
212 for (std::size_t k = 0; k < nsp; k++) {
213 const SpeciesCollisionConfig& sp = m_species[k];
214 if (sp.kind == SpeciesCollisionKind::Skip) continue;
215 const double n = n_k[k];
216 if (n <= 0.0) continue;
217
218 double nu1 = 0.0;
219 if (sp.kind == SpeciesCollisionKind::Ion) {
220 // Mitchner II-8.10, the C++ counterpart of
221 // IonMomentumTransferCollisionFrequencyModel
222 // .get_mean_momentum_transfer_collision_frequency:
223 // @f[
224 // \nu_{ei} = n_i \, c_\text{st} \, T_e^{-1.5} \, \ln\Lambda.
225 // @f]
226 const double lnL = coulombLogMitchner(n_e, Te, sp.Z);
227 nu1 = n * sp.cst * std::pow(Te, -1.5) * lnL;
228 nu_ions_sum += nu1;
229 } else { // Tabulated or HardSphere
230 // Mitchner II-6.29, the C++ counterpart of
231 // MomentumTransferCollisionFrequencyModel
232 // .get_mean_momentum_transfer_collision_frequency:
233 // @f[
234 // \nu_{eh} = n_h \, \overline{Q}(T_e) \, v_\text{th}.
235 // @f]
236 if (!sp.Qbar.covers(Te)) {
237 throwOutsideTable(sp.Qbar, Te, "CollisionModel::evaluate",
238 "the collision cross section Qbar(Te) of species " +
239 std::to_string(k),
240 "K", "Widen the collision-model grid with Te_min/Te_max.");
241 }
242 nu1 = n * sp.Qbar.eval(Te) * v_th;
243 }
244 nu_eH_sum += nu1;
245 // Elastic energy-exchange frequency contribution (Mitchner II-7.6),
246 // the C++ counterpart of the same formula computed in Python by
247 // rizer.transport.mixture_law.MixtureCollisionFrequencies.plasma_power_elastic:
248 // @f[
249 // \nu_{eh}^E = \frac{2 m_e}{m_h} \nu_{eh}.
250 // @f]
251 nu_E_sum += sp.two_me_over_mh * nu1;
252 // Second Maxwellian-distribution condition term (Mitchner VIII-3.8),
253 // the C++ counterpart of
254 // MixtureCollisionFrequencies.mass_weighted_electron_heavy_collision_frequency:
255 // @f$\sum_h \bar\nu_{eh}/m_h@f$.
256 nu_eH_mass_weighted_sum += sp.inv_mh * nu1;
257 }
258
259 nu_E = nu_E_sum;
260 nu_eH = nu_eH_sum;
261 nu_eI = nu_ions_sum;
262 nu_eH_mass_weighted = nu_eH_mass_weighted_sum;
263 if (nu_eH > 0.0 && n_e > 0.0) {
264 sigma = n_e * kE * kE / (kME * nu_eH);
265 // Electron-electron collisions raise sigma above the Lorentz value by up to
266 // the Spitzer factor 1.98 for a fully ionized, singly charged plasma
267 // (Mitchner II-13.18). Bridge it smoothly with the ion collision fraction
268 // @f$f_\text{ion} = \nu_\text{ions} / \nu_{eH}@f$ (1 -> neutral-dominated,
269 // 1.98 -> fully ionized) instead of switching abruptly once nu_ions
270 // exceeds nu_neutrals.
271 const double f_ion = nu_eI / nu_eH;
272 sigma *= 1.0 + 0.98 * f_ion;
273 }
274}
275
276// ---------------------------------------------------------------------------------- //
277// Public static methods //
278// ---------------------------------------------------------------------------------- //
279
281{
282 // Mitchner II-8.11e with Z=1, the C++ counterpart of
283 // MixtureCollisionFrequencies.electron_electron_collision_frequency: same
284 // closed form as the ion collision frequency (ionCollisionPrefactor
285 // above), evaluated with n=n_e, Z=1. No electrons is a real state with no
286 // electron-electron collisions; a non-positive Te is not a state at all.
287 if (!(Te > 0.0)) {
288 throw TrialStateError(
289 "CollisionModel::electronElectronCollisionFrequency",
290 "Te = " + std::to_string(Te) + " K; the electron temperature must be positive.");
291 }
292 if (n_e <= 0.0) return 0.0;
293 static const double cst_ee = ionCollisionPrefactor(1);
294 const double lnL = coulombLogMitchner(n_e, Te, 1);
295 return n_e * cst_ee * std::pow(Te, -1.5) * lnL;
296}
297
298
299double CollisionModel::meanCrossSection(const std::vector<double>& energy_J,
300 const std::vector<double>& sigma_m2, double Te,
301 double x_max, int N)
302{
303 // Trapezoid quadrature on a uniform x-grid of N points for (Mitchner
304 // II-6.30):
305 // @f[
306 // \overline{Q}(T_e) = \frac{2}{3} \int_0^{x_\text{max}}
307 // x^2 e^{-x} Q(k_B T_e x)\, dx,
308 // @f]
309 // identical to the Python TabulatedSpeciesCrossSection
310 // .get_mean_cross_section (x_max=20, N=1000), which is the C++
311 // counterpart of this method.
312 if (N < 2) {
313 throw std::invalid_argument(
314 "CollisionModel::meanCrossSection: the trapezoid rule needs N >= 2 "
315 "points, got N = " + std::to_string(N) + ".");
316 }
317 if (energy_J.empty()) {
318 throw std::invalid_argument(
319 "CollisionModel::meanCrossSection: the cross section table is empty.");
320 }
321 // np.interp default: linear interpolation, holding the nearest edge value
322 // outside the table (constant extrapolation) rather than zeroing -- exactly
323 // PropertyTable::eval()'s contract. Hard-zeroing biases the averaged cross
324 // section -- and hence k(Te) -- low when Te samples energies beyond the
325 // tabulated range. Built once here (not per query) since energy_J/sigma_m2
326 // are fixed across this loop.
327 const PropertyTable sigma_table =
328 PropertyTable::fromTableOrScalar(energy_J, sigma_m2,
329 "the cross section energy_J/sigma_m2");
330 const double dx = x_max / static_cast<double>(N - 1);
331 double integral = 0.0;
332 double prev = 0.0; // integrand at x=0 is 0 (x^2 factor)
333 for (int i = 1; i < N; i++) {
334 const double x = dx * static_cast<double>(i);
335 const double energy = kKB * Te * x;
336 const double Q = sigma_table.eval(energy);
337 const double cur = (2.0 / 3.0) * x * x * std::exp(-x) * Q;
338 integral += 0.5 * (prev + cur) * dx;
339 prev = cur;
340 }
341 return integral;
342}
343
344
346 const std::vector<double>& energy_J, const std::vector<double>& sigma_m2,
347 const std::vector<double>& Te, double x_max, int N)
348{
349 std::vector<double> out(Te.size());
350 for (std::size_t i = 0; i < Te.size(); i++) {
351 out[i] = meanCrossSection(energy_J, sigma_m2, Te[i], x_max, N);
352 }
353 return out;
354}
355
356} // namespace rizer
Evaluate a PropertyTable at a state, refusing a state outside its range.
The error a closure raises for a state it cannot evaluate, so that the integrator proposing that stat...
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...
static double meanCrossSection(const std::vector< double > &energy_J, const std::vector< double > &sigma_m2, double Te, double x_max=20.0, int N=1000)
The Maxwellian momentum-transfer kernel Qbar(Te) [m^2] (single source of truth; also exposed to Pytho...
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.
static std::vector< double > meanCrossSectionTable(const std::vector< double > &energy_J, const std::vector< double > &sigma_m2, const std::vector< double > &Te, double x_max=20.0, int N=1000)
Tabulated variant of meanCrossSection, evaluated at each Te in the input vector.
static PropertyTable constant(double value)
A two-point constant table returning value for any T.
bool covers(double x) const
Whether eval() interpolates at x rather than holding an end value.
double eval(double T) const
Linearly interpolated value at T; clamped to the table endpoints.
static PropertyTable fromTableOrScalar(const std::vector< double > &x, const std::vector< double > &v, const char *name="property table")
Build a table from a grid/value pair, a single scalar, or neither.
TrialStateError(const std::string &procedure, std::string message)
constexpr double k_b
Boltzmann constant [J/K]. Exact (CODATA).
Definition units.h:28
constexpr double epsilon_0
Vacuum permittivity [F/m] (CODATA 2022).
Definition units.h:42
constexpr double pi
pi (matches numpy's np.pi used in units.py).
Definition units.h:51
constexpr double N_a
Avogadro's number [1/mol]. Exact (CODATA).
Definition units.h:30
constexpr double c
Speed of light [m/s]. Exact (CODATA).
Definition units.h:32
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
void throwOutsideTable(const PropertyTable &table, double x, const char *procedure, const std::string &what, const char *unit, const char *remedy)
Throw the TrialStateError for x outside table.
Definition TableLookup.h:30
Per-species spec, in species-index order.
bool is_electron
true for the electron species (skipped)
std::vector< double > energy_J
tabulated cross-section abscissa [J]
double molar_mass
[kg/mol], for the elastic 2 m_e/m_h factor
double radius
hard-sphere radius [m] (>0 if used)
std::vector< double > sigma_m2
tabulated cross-section [m^2]
int Z
ion charge number (>0 marks an ion)
Physical constants and unit conversions, in SI by default.