rizer.cantera_ext
C++ Cantera 1-D plasma extension (custom Domain1D models and solvers)
Loading...
Searching...
No Matches
PlasmaChannel1D.cpp
Go to the documentation of this file.
1#include "PlasmaChannel1D.h"
2#include "PlasmaRates.h"
3#include "units.h"
4
9
10#include <algorithm>
11#include <cmath>
12#include <stdexcept>
13
14namespace rizer {
15
16// Ideal gas constant on the kmol basis (matches Cantera's molecularWeights(),
17// which are in kg/kmol), used by the species-diffusion compressibility term
18// below. Mirrors the same constant/convention in ReactorRHS.cpp.
19constexpr double GasConstant = units::R_kmol; // J/kmol/K
20
21namespace {
22// Register the custom plasma rates (janev / reverse-two-temperature-plasma)
23// before loading the mechanism, so YAML files that use them load in C++.
24// Idempotent — safe to call on every construction.
28loadMechanism(const std::string& mech, const std::string& phase)
29{
31 return Cantera::newSolution(mech, phase);
32}
33} // namespace
34
36 : Cantera::Domain1D(/*nv placeholder*/1, cfg.n_points)
37 , m_solution(loadMechanism(cfg.mech, cfg.phase))
38 , m_n_species(m_solution->thermo()->nSpecies())
39 , m_electron_index(m_solution->thermo()->speciesIndex("e-"))
40 , m_outer_radius(cfg.R_max)
41 , m_rho(cfg.rho)
42 , m_electric_field(cfg.electric_field)
43 , m_species_diffusivity(cfg.D_species)
44 , m_reacting(cfg.reacting)
45 , m_ambient_temperature(cfg.T_amb)
46 , m_Y0(cfg.Y0)
47 , m_kappa(PropertyTable::fromTableOrScalar(cfg.kappa_T, cfg.kappa_v))
48 , m_kappa_e(PropertyTable::fromTableOrScalar(cfg.kappa_e_T, cfg.kappa_e_v))
49 , m_initTg(PropertyTable::fromTableOrScalar(cfg.initTg_r, cfg.initTg_v))
50 , m_initTe(PropertyTable::fromTableOrScalar(cfg.initTe_r, cfg.initTe_v))
51{
52 // An explicit grid (if given) defines the mesh, n_points and R_max.
53 const bool explicit_grid = cfg.grid.size() >= 2;
54 const std::size_t n_points = explicit_grid ? cfg.grid.size() : cfg.n_points;
55 if (explicit_grid) {
56 m_outer_radius = cfg.grid.back();
57 } else if (cfg.R_max <= 0.0 || cfg.n_points < 1) {
58 throw std::invalid_argument("PlasmaChannel1D: need R_max>0, n_points>=1 "
59 "(or supply an explicit grid of >=2 points).");
60 }
61 if (m_electron_index == Cantera::npos) {
62 throw std::invalid_argument("PlasmaChannel1D: mechanism has no 'e-' species.");
63 }
64 if (m_Y0.size() != m_n_species) {
65 throw std::invalid_argument("PlasmaChannel1D: Y0 length != nSpecies.");
66 }
67 // A composition-resolved collision model (cfg.specs, built from Python's
68 // `mtcf` list of MomentumTransferCollisionFrequencyModel) and the legacy
69 // sigma/nu_m/nu_E closure are two different ways to get sigma/nu_E into
70 // the SAME ReactorRHS::Config.collision slot; mixing them would silently
71 // pick one (whichever ReactorRHS::transport() checks first) and ignore
72 // the other's inputs, so reject the combination outright instead of
73 // guessing which one the caller meant.
74 // WHY this matters physically (see ARCHITECTURE.md "Composition-resolved
75 // sigma/nu_E"): sigma is far more sensitive to the *ionization state* than
76 // to bulk composition. In a fast NRP-style discharge, electron-impact
77 // ionization can run orders of magnitude ahead of the channel's initial
78 // (often near-neutral) seed composition within a couple of nanoseconds.
79 // cfg.specs recomputes sigma/nu_E live from the *evolving* composition
80 // every eval() call, tracking that; the legacy sigma(Te)/nu_m(Te) tables
81 // are frozen at whatever composition/shape they were tabulated for, and
82 // can only re-scale with Te, not with the actual evolving n_e/ionization
83 // fraction. Silently combining the two would recombine a frozen sigma(Te)
84 // *shape* with the channel's own independently-evolving n_e(t), which is
85 // exactly the ~10% error the composition-resolved model was added to fix.
86 const bool have_collision = !cfg.specs.empty();
87 const bool have_legacy_closure = !cfg.sigma_T.empty() || !cfg.sigma_v.empty()
88 || !cfg.nu_m_T.empty() || !cfg.nu_m_v.empty()
89 || cfg.nu_m > 0.0 || cfg.nu_E != 0.0;
90 if (have_collision && have_legacy_closure) {
91 throw std::invalid_argument(
92 "PlasmaChannel1D: cfg.specs (composition-resolved collision model) "
93 "cannot be combined with sigma_T/sigma_v, nu_m, nu_m_T/nu_m_v, or "
94 "nu_E (the legacy closure) -- pick one.");
95 }
96 // Single-cell 2T physics (chemistry/Joule/exchange) shared with the 0D reactor.
97 // The field E is supplied per eval via setElectricField().
98 {
99 ReactorRHS::Config reactor_config;
100 reactor_config.reacting = cfg.reacting;
101 if (have_collision) {
102 // Same pattern as Plasma0DReactor::Plasma0DReactor: molar_mass and
103 // is_electron are derived from the mechanism, not the caller, since
104 // they must exactly match this domain's own species indexing.
105 std::vector<CollisionModel::SpeciesSpec> specs = cfg.specs;
106 specs.resize(m_n_species);
107 const auto& molecular_weights = m_solution->thermo()->molecularWeights();
108 for (std::size_t k = 0; k < m_n_species; k++) {
109 // kg/kmol -> kg/mol
110 specs[k].molar_mass = molecular_weights[k] * 1.0e-3;
111 specs[k].is_electron = (k == m_electron_index);
112 }
113 reactor_config.collision =
114 CollisionModel(specs, cfg.Te_min, cfg.Te_max, cfg.Te_n, cfg.spitzer);
115 } else {
116 reactor_config.sigma_T = cfg.sigma_T; reactor_config.sigma_v = cfg.sigma_v;
117 reactor_config.nu_m_T = cfg.nu_m_T; reactor_config.nu_m_v = cfg.nu_m_v;
118 reactor_config.nu_m = cfg.nu_m; reactor_config.nu_E = cfg.nu_E;
119 }
120 m_reactor_rhs =
121 std::make_unique<ReactorRHS>(m_solution, std::move(reactor_config));
122 }
123 // Initial radial composition profile: one Tg-style table per species.
124 if (!cfg.initY_r.empty() && cfg.initY_r.size() >= 2
125 && cfg.initY_v.size() == cfg.initY_r.size() * m_n_species) {
126 const std::size_t n_radii = cfg.initY_r.size();
127 m_initY.reserve(m_n_species);
128 for (std::size_t k = 0; k < m_n_species; k++) {
129 std::vector<double> column(n_radii);
130 for (std::size_t i = 0; i < n_radii; i++) {
131 column[i] = cfg.initY_v[i * m_n_species + k];
132 }
133 m_initY.push_back(PropertyTable::fromTableOrScalar(cfg.initY_r, column));
134 }
135 }
136 m_has_transport =
137 !m_kappa.empty() || !m_kappa_e.empty() || m_species_diffusivity > 0.0;
138
139 Cantera::Domain1D::resize(2 + m_n_species, n_points);
140 if (explicit_grid) {
141 setupGrid(Cantera::span<const double>(cfg.grid.data(), cfg.grid.size()));
142 } else if (cfg.n_points >= 2) {
143 setupUniformGrid(cfg.n_points, cfg.R_max, 0.0);
144 } // n_points==1: keep the single node at r=0 (resize left m_z={0});
145 // setupUniformGrid would divide by (points-1)=0.
146
147 setComponentName(0, "Tg");
148 setComponentName(1, "Te");
149 for (std::size_t k = 0; k < m_n_species; k++) {
150 setComponentName(2 + k, m_solution->thermo()->speciesName(k));
151 }
152 // Newton step-limiting bounds, not a hard clamp (resetBadValues() is the
153 // clamp): 200 K keeps both temperatures comfortably above 0 K/the thermo
154 // fit floor without constraining genuine cooling; 1e6 K is a generous
155 // ceiling no physical run should approach. Species bounds allow a small
156 // negative undershoot (-1e-12) since Newton trial states can dip slightly
157 // below zero for a trace species without being "bad" -- resetBadValues()
158 // clips to [0,1] only after repeated Newton failures, not on every step.
159 setBounds(0, 200.0, 1.0e6);
160 setBounds(1, 200.0, 1.0e6);
161 for (std::size_t k = 0; k < m_n_species; k++) {
162 setBounds(2 + k, -1.0e-12, 2.0);
163 }
164 // Transient absolute tolerance is 100x tighter than steady (1e-12 vs
165 // 1e-10), the same ratio ThermalPlasmaColumn1D uses for its own T
166 // component -- a deliberate, consistent choice across this codebase's
167 // Domain1D subclasses, not an arbitrary pair of numbers.
168 setSteadyTolerances(1.0e-4, 1.0e-10);
169 setTransientTolerances(1.0e-4, 1.0e-12);
170}
171
172// Map a Domain1D component index to its name ("Tg", "Te", or a species name),
173// the inverse of componentIndex(). Required by Cantera's Domain1D interface
174// (used e.g. for solution output, error messages, and by our own callers in
175// TransientPlasmaChannelSolver.cpp to enumerate/report components by name).
176std::string PlasmaChannel1D::componentName(std::size_t n) const
177{
178 if (n == 0) return "Tg";
179 if (n == 1) return "Te";
180 return m_solution->thermo()->speciesName(n - 2);
181}
182
183// Map a component name back to its Domain1D index (inverse of componentName()).
184// The `checkAlias` argument from the Domain1D base signature is unused: this
185// domain has no aliases, only the canonical "Tg"/"Te"/species names.
186std::size_t PlasmaChannel1D::componentIndex(const std::string& name, bool) const
187{
188 if (name == "Tg") return 0;
189 if (name == "Te") return 1;
190 std::size_t k = m_solution->thermo()->speciesIndex(name);
191 if (k != Cantera::npos) return 2 + k;
192 throw std::invalid_argument("PlasmaChannel1D: no component '" + name + "'.");
193}
194
195bool PlasmaChannel1D::hasComponent(const std::string& name, bool) const
196{
197 return name == "Tg" || name == "Te"
198 || m_solution->thermo()->speciesIndex(name) != Cantera::npos;
199}
200
201// Read one component's radial profile out of the domain's own solution buffer
202// (m_state, populated by Cantera after getInitialSoln()/a solve). Used by
203// TransientPlasmaChannelSolver::solveChannelTransient to seed its flat state
204// vector `x` from the freshly-initialized profile (see the comment there on
205// why getState() can't be used for that instead).
206void PlasmaChannel1D::getValues(const std::string& component,
208{
209 if (!m_state) {
210 throw std::runtime_error("PlasmaChannel1D::getValues: not installed.");
211 }
212 std::size_t comp = componentIndex(component);
213 const double* soln = m_state->data() + loc();
214 for (std::size_t j = 0; j < m_points; j++) {
215 values[j] = soln[index(comp, j)];
216 }
217}
218
219// Cantera calls this once per (component, node) to seed the initial guess
220// before the first solve. Falls back to a uniform value (m_ambient_temperature
221// or the ambient mass fraction m_Y0[k]) wherever no explicit initial-profile
222// table was supplied, so a channel with no Tg_profile/Te_profile/Y0_profile
223// simply starts uniform.
224double PlasmaChannel1D::initialValue(std::size_t n, std::size_t j)
225{
226 double r = z(j);
227 if (n == 0) return m_initTg.empty() ? m_ambient_temperature : m_initTg.eval(r);
228 if (n == 1) return m_initTe.empty() ? m_ambient_temperature : m_initTe.eval(r);
229 std::size_t k = n - 2;
230 return m_initY.empty() ? m_Y0[k] : m_initY[k].eval(r);
231}
232
233// Clamp a trial state back onto the physically valid manifold after a failed
234// Newton/BDF step produces something the thermo layer can't evaluate: floor
235// both temperatures at 200 K (Cantera's thermo/kinetics routines are not
236// guaranteed valid, and can throw, below their fit range or at/below 0 K) and
237// clip every mass fraction into [0, 1] (a valid composition; Newton overshoots
238// can otherwise drive Y_k slightly negative or above 1). This does not
239// conserve mass Sum(Y_k)=1 -- it is a last-resort rescue to get an evaluable
240// state for the next retry, not a physical correction.
242{
243 auto x = xg.subspan(loc(), size());
244 for (std::size_t j = 0; j < m_points; j++) {
245 x[index(0, j)] = std::max(x[index(0, j)], 200.0);
246 x[index(1, j)] = std::max(x[index(1, j)], 200.0);
247 for (std::size_t k = 0; k < m_n_species; k++) {
248 x[index(2 + k, j)] = std::min(std::max(x[index(2 + k, j)], 0.0), 1.0);
249 }
250 }
251}
252
253// Thin pass-through to the shared 0D physics (ReactorRHS), so every caller
254// (eval()'s residual, and TransientPlasmaChannelSolver's history recording)
255// computes n_e the same way the reaction source terms themselves see it.
256double PlasmaChannel1D::electronDensity(double Tg, double Te, const double* Y) const
257{
258 return m_reactor_rhs->electronDensity(Tg, Te, Y, m_rho);
259}
260
261// Cantera's Jacobian coloring calls eval() once per perturbed global point
262// `jg` (finite-difference Jacobian: only that point's residual needs
263// recomputing) plus once with jg==npos (a full residual evaluation, e.g. for
264// the initial guess or a converged-solution check). This domain's stencil
265// couples each node only to its immediate east/west neighbours (see fvDiv
266// below), so perturbing point `jg` can only change the residual at jg-1, jg,
267// and jg+1 -- hence the narrow [jmin, jmax] window computed below instead of
268// recomputing every node on every perturbation.
271 double rdt)
272{
273 // Points outside this domain's own range (jg is a *global* index across
274 // all domains in the Sim1D) can't affect our residual at all; skip early.
275 if (jg != Cantera::npos
276 && (jg + 1 < firstPoint() || jg > lastPoint() + 1)) {
277 return;
278 }
279 auto x = xg.subspan(loc(), size());
280 auto rsd = rg.subspan(loc(), size());
281 auto diag = maskg.subspan(loc(), size());
282
283 std::size_t jmin, jmax;
284 if (jg == Cantera::npos) {
285 // Full evaluation: every node's residual needs (re)computing.
286 jmin = 0;
287 jmax = m_points - 1;
288 } else {
289 // Narrow window: only the perturbed point and its two neighbours.
290 std::size_t local_point = (jg == 0) ? 0 : jg - firstPoint();
291 jmin = std::max<std::size_t>(local_point, 1) - 1;
292 jmax = std::min(local_point + 1, m_points - 1);
293 }
294
295 // Finite-volume cylindrical divergence (1/r) d/dr(r * coef * dphi/dr) at
296 // node `node` for component `component`, face coefficient from coef(phi).
297 // The axis face (node==0) has zero area (symmetry). The outer wall is
298 // imposed as a Dirichlet ghost node held at `ghost`, a half-interval
299 // beyond the last node -- this keeps every node transient (well scaled),
300 // avoiding an ill-conditioned algebraic boundary row.
301 // Units below: radius/spacing/volume are always [m]/[m]/[m^2] (geometry is
302 // component-independent); `value_*`/`coef_*`/`flux_*`/the return value take
303 // whatever unit `component` has -- [K] and [W/m/K] for the Tg/Te conduction
304 // calls, [-] and [kg/m/s] for the species-diffusion call below (see the two
305 // call sites: coef is kappa/kappa_e or rho*D respectively).
306 // Lambda parameters (a plain, non-Doxygen comment: this lambda has no
307 // separate declaration for \param to attach to, so a doc-comment here
308 // would be misattached to the enclosing eval()):
309 // component Component index being diffused: 0=Tg, 1=Te, or 2+k for
310 // species k [-]
311 // node Radial grid node index [-]
312 // ghost Dirichlet ghost value beyond the wall, in `component`'s
313 // unit ([K] for Tg/Te, [-] for Y_k)
314 // coef Face coefficient functor value->coef(value): kappa/kappa_e
315 // [W/m/K] for Tg/Te, or rho*D [kg/m/s] for species
316 auto fvDiv = [&](std::size_t component, std::size_t node, double ghost,
317 auto coef) -> double {
318 double value_here = x[index(component, node)];
319 double radius_east, spacing_east, value_east; // [m], [m], component's unit
320 if (node < m_points - 1) {
321 value_east = x[index(component, node + 1)];
322 radius_east = 0.5 * (z(node) + z(node + 1));
323 spacing_east = z(node + 1) - z(node);
324 } else { // last node: ghost at r = z + spacing_west, value = ambient
325 // [m]
326 double last_spacing =
327 (m_points >= 2) ? z(node) - z(node - 1) : m_outer_radius;
328 value_east = ghost;
329 radius_east = z(node) + 0.5 * last_spacing;
330 spacing_east = last_spacing;
331 }
332 double coef_east = 0.5 * (coef(value_here) + coef(value_east));
333 double flux_east =
334 radius_east * coef_east * (value_east - value_here) / spacing_east;
335 double flux_west = 0.0, radius_west = 0.0; // [m]
336 if (node > 0) {
337 double value_west = x[index(component, node - 1)];
338 radius_west = 0.5 * (z(node - 1) + z(node));
339 double spacing_west = z(node) - z(node - 1); // [m]
340 double coef_west = 0.5 * (coef(value_west) + coef(value_here));
341 flux_west =
342 radius_west * coef_west * (value_here - value_west) / spacing_west;
343 }
344 // [m^2]
345 double volume =
346 0.5 * (radius_east * radius_east - radius_west * radius_west);
347 return (flux_east - flux_west) / volume;
348 };
349
350 // Node-centered radial gradient d(phi)/dr at `node` for `component`,
351 // used by the species-diffusion enthalpy-transport term below (a mixed-
352 // gradient PRODUCT term, not a flux divergence, so fvDiv() itself does
353 // not apply here). Obtained as the arithmetic mean of the same one-sided
354 // east/west face differences fvDiv() already forms internally, so the
355 // gradient used here stays consistent with the divergence stencil
356 // discretizing the rest of the transport terms. At the axis (node==0)
357 // this returns 0 exactly -- symmetry makes every component an even
358 // function of r there, so its radial derivative vanishes -- rather than
359 // a one-sided difference extrapolated across the (zero-area) axis face.
360 // At the wall (node==m_points-1) the east-side difference uses the same
361 // Dirichlet ghost value `ghost` a half-cell beyond the last node that
362 // fvDiv()'s own wall treatment uses.
363 // component Component index: 0=Tg, 1=Te, or 2+k for species k [-]
364 // node Radial grid node index [-]
365 // ghost Dirichlet ghost value beyond the wall, in `component`'s
366 // unit ([K] for Tg/Te, [-] for Y_k)
367 auto centeredGradient = [&](std::size_t component, std::size_t node,
368 double ghost) -> double {
369 if (node == 0) return 0.0; // axis symmetry: d(phi)/dr(r=0) = 0
370 double value_here = x[index(component, node)];
371 double value_west = x[index(component, node - 1)];
372 double spacing_west = z(node) - z(node - 1); // [m]
373 double gradient_west = (value_here - value_west) / spacing_west;
374
375 double gradient_east;
376 if (node < m_points - 1) {
377 double value_east = x[index(component, node + 1)];
378 double spacing_east = z(node + 1) - z(node); // [m]
379 gradient_east = (value_east - value_here) / spacing_east;
380 } else { // wall: ghost a half-cell beyond the last node
381 double last_spacing =
382 (m_points >= 2) ? z(node) - z(node - 1) : m_outer_radius;
383 gradient_east = (ghost - value_here) / last_spacing;
384 }
385 return 0.5 * (gradient_west + gradient_east);
386 };
387
388 // Molecular weights W_k [kg/kmol], node-independent; fetched once and
389 // reused every node by the species-diffusion compressibility term below.
390 const std::vector<double>& molecular_weights = m_reactor_rhs->molecularWeights();
391
392 std::vector<double> dYdt(m_n_species); // dY_k/dt [1/s], one entry per species
393 std::vector<double> raw_Y_divergence(m_n_species); // see the species-diffusion
394 // block below [1/m^2]
395 for (std::size_t j = jmin; j <= jmax; j++) {
396 double Tg = x[index(0, j)]; // gas temperature [K]
397 double Te = x[index(1, j)]; // electron temperature [K]
398 const double* Y = &x[index(2, j)]; // species mass fractions [-]
399
400 // lhsTg/lhsTe are the effective heat capacities per unit volume
401 // [J/m^3/K] that ReactorRHS::rates already divided the chemistry/
402 // Joule/exchange source terms by, so dTg/dTe below are already true
403 // dT/dt [K/s] for those terms. The conduction term computed via
404 // fvDiv() is a bare flux divergence, not yet divided by capacitance,
405 // so it must be divided by the SAME lhsTg/lhsTe here to add
406 // consistently onto dTg/dTe (see the class-level header comment for
407 // the governing PDEs, rho*cv_h*dTg/dt = div(...) + ...).
408 // [K/s], [K/s], [J/m^3/K], [J/m^3/K]
409 double dTg = 0.0, dTe = 0.0, lhsTg = 1.0, lhsTe = 1.0;
410 std::fill(dYdt.begin(), dYdt.end(), 0.0);
411 m_reactor_rhs->rates(Tg, Te, Y, m_rho, m_electric_field, dTg, dTe,
412 dYdt.data(), lhsTg, lhsTe);
413
414 if (m_has_transport) {
415 if (!m_kappa.empty()) {
416 dTg += fvDiv(0, j, m_ambient_temperature,
417 [&](double T) { return m_kappa.eval(T); }) / lhsTg;
418 }
419 if (!m_kappa_e.empty()) {
420 dTe += fvDiv(1, j, m_ambient_temperature,
421 [&](double T) { return m_kappa_e.eval(T); }) / lhsTe;
422 }
423 if (m_species_diffusivity > 0.0) {
424 // Raw (unit-coefficient) divergence div(grad(Y_k)) =
425 // (1/r) d/dr(r dY_k/dr) at this node, for every species k --
426 // computed once per species and reused below for the
427 // ordinary Fickian diffusion term (Term A) AND the species-
428 // diffusion compressibility term (Term B). Term A's actual
429 // face coefficient rho*D is a spatially uniform constant
430 // (does not depend on Y_k), so fvDiv(coef=rho*D) equals
431 // rho*D*raw_Y_divergence[k] exactly -- no need to call
432 // fvDiv() twice per species with two different constant
433 // coefficients.
434 for (std::size_t k = 0; k < m_n_species; k++) {
435 raw_Y_divergence[k] =
436 fvDiv(2 + k, j, m_Y0[k], [](double) { return 1.0; });
437 }
438
439 // --- Term A: ordinary Fickian species diffusion,
440 // div(rho*D*grad(Y_k)), converted to dY_k/dt by /rho ==
441 // D*raw_Y_divergence[k] (see above; rho assumed spatially
442 // uniform -- this model has no continuity equation).
443 // Matches the mass-fraction species equation in the
444 // class-level header comment.
445 for (std::size_t k = 0; k < m_n_species; k++) {
446 dYdt[k] += m_species_diffusivity * raw_Y_divergence[k];
447 }
448
449 // --- Term B: species-diffusion compressibility. Expanding
450 // DP/Dt for the two-temperature ideal-gas EOS under Fick's
451 // law contributes an extra energy-equation source (see
452 // ARCHITECTURE.md, "species-diffusion compressibility"):
453 // electron: +rho*D*(R*Te/We) * (1/r) d/dr(r dYe/dr)
454 // gas: +rho*D*R*Tg * sum_{k!=e} (1/Wk) * (1/r) d/dr(r dYk/dr)
455 // (summed over heavy species only -- they share one Tg).
456 const double We = molecular_weights[m_electron_index];
457 dTe += m_rho * m_species_diffusivity * (GasConstant * Te / We)
458 * raw_Y_divergence[m_electron_index] / lhsTe;
459
460 double gas_compressibility_sum = 0.0;
461 for (std::size_t k = 0; k < m_n_species; k++) {
462 if (k == m_electron_index) continue;
463 gas_compressibility_sum +=
464 raw_Y_divergence[k] / molecular_weights[k];
465 }
466 dTg += m_rho * m_species_diffusivity * GasConstant * Tg
467 * gas_compressibility_sum / lhsTg;
468
469 // --- Term C: enthalpy diffusion (species heat transport).
470 // The full multicomponent energy equation's heat flux
471 // includes +sum_k h_k*j_k (species carry their own sensible
472 // enthalpy as they diffuse); with Fick's law j_k=-rho*D*
473 // grad(Y_k), its divergence -div(sum_k h_k*j_k) reduces to a
474 // mixed-gradient PRODUCT term rather than a divergence (see
475 // ARCHITECTURE.md, "enthalpy diffusion"):
476 // electron: +rho*D*cp_e * (dYe/dr)(dTe/dr)
477 // gas: +rho*D*(dTg/dr) * sum_{k!=e} cp_k * (dYk/dr)
478 const std::vector<double>& species_heat_capacity =
479 m_reactor_rhs->speciesCp();
480 const double Tg_gradient =
481 centeredGradient(0, j, m_ambient_temperature);
482 const double Te_gradient =
483 centeredGradient(1, j, m_ambient_temperature);
484 const double Ye_gradient = centeredGradient(
485 2 + m_electron_index, j, m_Y0[m_electron_index]);
486 dTe += m_rho * m_species_diffusivity
487 * species_heat_capacity[m_electron_index] * Ye_gradient
488 * Te_gradient / lhsTe;
489
490 double gas_enthalpy_diffusion_sum = 0.0;
491 for (std::size_t k = 0; k < m_n_species; k++) {
492 if (k == m_electron_index) continue;
493 double Yk_gradient = centeredGradient(2 + k, j, m_Y0[k]);
494 gas_enthalpy_diffusion_sum +=
495 species_heat_capacity[k] * Yk_gradient;
496 }
497 dTg += m_rho * m_species_diffusivity * Tg_gradient
498 * gas_enthalpy_diffusion_sum / lhsTg;
499 }
500 }
501
502 // Steady residual = source+transport rate; the -rdt*(phi-phi_prev) term
503 // is Cantera's pseudo-transient damping (rdt = 1/dt during a time step,
504 // 0 during a steady solve or when computing a bare method-of-lines RHS
505 // -- see ChannelRHS::eval in TransientPlasmaChannelSolver.cpp). diag=1
506 // marks every component here as a true (non-algebraic) unknown, so
507 // Cantera includes the transient term for all of them; there is no
508 // Dirichlet/algebraic row in this domain (the wall is a ghost-cell
509 // flux, not a fixed unknown).
510 rsd[index(0, j)] = dTg - rdt * (Tg - prevSoln(0, j));
511 rsd[index(1, j)] = dTe - rdt * (Te - prevSoln(1, j));
512 diag[index(0, j)] = 1;
513 diag[index(1, j)] = 1;
514 for (std::size_t k = 0; k < m_n_species; k++) {
515 rsd[index(2 + k, j)] = dYdt[k] - rdt * (Y[k] - prevSoln(2 + k, j));
516 diag[index(2 + k, j)] = 1;
517 }
518 }
519}
520
521} // namespace rizer
size_t lastPoint() const
size_t size() const
shared_ptr< Solution > phase() const
shared_ptr< vector< double > > m_state
vector< double > values(const string &component) const
virtual void resize(size_t nv, size_t np)
double z(size_t jlocal) const
double prevSoln(size_t n, size_t j) const
size_t firstPoint() const
Domain1D(size_t nv=1, size_t points=1, double time=0.0)
size_t index(size_t n, size_t j) const
virtual size_t loc(size_t j=0) const
Composition-resolved electron-heavy momentum-transfer collision model.
double initialValue(std::size_t n, std::size_t j) override
Initial value of component n at grid point j ([K] for Tg/Te, [-] for a species mass fraction); seeds ...
void eval(std::size_t jg, Cantera::span< const double > xg, Cantera::span< double > rg, Cantera::span< int > maskg, double rdt) override
Residual + transient-mask assembly at global point jg (or every point if jg == Cantera::npos); see th...
void getValues(const std::string &component, Cantera::span< double > values) const override
Radial profile of one component, read from the domain's own solution buffer (units match that compone...
std::size_t nSpecies() const
Number of species K [-] in this domain's mechanism.
bool hasComponent(const std::string &name, bool checkAlias=true) const override
Whether name is "Tg", "Te", or a species of this domain's mechanism.
void resetBadValues(Cantera::span< double > xg) override
Clamp a trial state back onto a physically evaluable range (Tg, Te >= 200 K; every Y_k in [0,...
std::string componentName(std::size_t n) const override
Component n -> name ("Tg", "Te", or a species name).
double electronDensity(double Tg, double Te, const double *Y) const
Electron number density [1/m^3] for a node state (X_e * P / (k_B * Tmean)), matching the n_e used int...
PlasmaChannel1D(const PlasmaChannelConfig &cfg)
Build the domain (mesh, thermo/kinetics, property tables) from cfg.
std::size_t componentIndex(const std::string &name, bool checkAlias=true) const override
Component name -> index (inverse of componentName()); throws if unknown.
shared_ptr< Solution > newSolution(const string &infile, const string &name="", const string &transport="default", const vector< shared_ptr< Solution > > &adjacent={})
const size_t npos
constexpr double R_kmol
Ideal gas constant [J/(kmol K)] (matches Cantera's GasConstant).
Definition units.h:60
constexpr double GasConstant
void registerPlasmaRates()
Register the custom plasma reaction rates with Cantera's ReactionRateFactory.
std::vector< double > initY_v
std::vector< double > sigma_v
std::vector< double > sigma_T
std::vector< double > initY_r
std::vector< double > nu_m_T
std::vector< CollisionModel::SpeciesSpec > specs
std::vector< double > nu_m_v
std::vector< double > grid
bool reacting
Include finite-rate chemistry source terms (true) or freeze composition (false) [-].
Definition ReactorRHS.h:61
std::vector< double > sigma_v
sigma(Te) table: electrical conductivity values at sigma_T [S/m]
Definition ReactorRHS.h:70
CollisionModel collision
Composition-resolved sigma/nu_E (preferred).
Definition ReactorRHS.h:63
std::vector< double > nu_m_T
nu_m(Te) table: electron-temperature grid points [K], strictly increasing
Definition ReactorRHS.h:73
double nu_m
Scalar electron momentum-transfer frequency, used when nu_m_T/nu_m_v are empty [1/s].
Definition ReactorRHS.h:79
std::vector< double > nu_m_v
nu_m(Te) table: electron momentum-transfer frequency values at nu_m_T [1/s]
Definition ReactorRHS.h:76
double nu_E
Scalar elastic electron-heavy energy-exchange frequency, used when collision is empty [1/s].
Definition ReactorRHS.h:82
std::vector< double > sigma_T
sigma(Te) table: electron-temperature grid points [K], strictly increasing
Definition ReactorRHS.h:67
Physical constants and unit conversions, in SI by default.