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
6#include "numeric/Format.h"
8#include "units.h"
9
12
13#ifdef _OPENMP
14#include <omp.h>
15#endif
16
17#include <algorithm>
18#include <cmath>
19#include <numeric>
20#include <sstream>
21#include <stdexcept>
22
23namespace rizer
24{
25
26 // ------------------------------------------------------------------------------ //
27 // Constructor //
28 // ------------------------------------------------------------------------------ //
29
31 : Cantera::Domain1D(/*nv placeholder*/ 1, cfg.n_points),
32 m_solution(loadMechanism(cfg.mech, cfg.phase)),
33 m_n_species(m_solution->thermo()->nSpecies()),
34 m_electron_index(m_solution->thermo()->speciesIndex("e-")),
35 m_outer_radius(cfg.R_max),
36 m_electric_field(cfg.electric_field),
37 m_species_diffusivity(cfg.D_species),
38 m_reacting(cfg.reacting),
39 m_hydrodynamics(cfg.hydrodynamics),
40 m_ambient_temperature(cfg.T_amb),
41 m_Y_amb(cfg.Y_amb),
42 m_kappa_H(PropertyTable::fromTableOrScalar(
43 cfg.kappa_H_T, cfg.kappa_H_v, "kappa_H_T/kappa_H_v")),
44 m_kappa_e(PropertyTable::fromTableOrScalar(
45 cfg.kappa_e_T, cfg.kappa_e_v, "kappa_e_T/kappa_e_v")),
46 m_initTg(PropertyTable::fromTableOrScalar(
47 cfg.initTg_r, cfg.initTg_v, "initTg_r/initTg_v")),
48 m_initTe(PropertyTable::fromTableOrScalar(
49 cfg.initTe_r, cfg.initTe_v, "initTe_r/initTe_v"))
50 {
51 // Eight steps, in order:
52 // 1. resolve the mesh, then the inputs every mode needs
53 // 2. pick the transport closure and build the shared PlasmaCell
54 // 3. the initial composition profile, one table per species
55 // 4. size the domain, install the grid, require electrons at every node
56 // 5. the radial density profile, sampled once the mesh exists
57 // 6. the hydro-only inputs, accepted or refused as a set
58 // 7. the wall ghost density, from everything validated above
59 // 8. solver tolerances
60
61 // ---- 1. mesh, then the inputs every mode needs ----------------------
62 // An explicit grid (if given) defines the mesh, n_points and R_max.
63 const bool explicit_grid = cfg.grid.size() >= 2;
64 const std::size_t n_points = explicit_grid ? cfg.grid.size() : cfg.n_points;
65 if (explicit_grid)
66 {
67 // faceRadius(0) is pinned at the axis, so a first node off it would
68 // make the first cell the whole disc inside that node.
69 if (cfg.grid.front() != 0.0)
70 {
71 throw std::invalid_argument(
72 "PlasmaChannel1D: an explicit grid must start on the axis, "
73 "grid[0] = 0, but grid[0] = " +
74 std::to_string(cfg.grid.front()) +
75 " m. The first face is pinned at r = 0, so the first cell "
76 "would span the whole disc inside that node.");
77 }
78 m_outer_radius = cfg.grid.back();
79 }
80 else if (cfg.R_max <= 0.0 || cfg.n_points < 1)
81 {
82 throw std::invalid_argument("PlasmaChannel1D: need R_max>0, n_points>=1 "
83 "(or supply an explicit grid of >=2 points).");
84 }
85 if (m_electron_index == Cantera::npos)
86 {
87 throw std::invalid_argument("PlasmaChannel1D: mechanism has no 'e-' species.");
88 }
89 // Required outright, never derived from the initial composition: the
90 // two are independent, and the one the caller forgot is the one a
91 // fallback would hide (see PlasmaChannelConfig::Y_amb).
92 if (m_Y_amb.empty())
93 {
94 throw std::invalid_argument(
95 "PlasmaChannel1D: Y_amb is required. It is the composition "
96 "OUTSIDE the domain, which the wall ghost and the hydro inflow "
97 "read; it is not derived from the initial composition. Pass the "
98 "ambient composition explicitly, repeating the initial one only "
99 "if that is genuinely what the boundary holds.");
100 }
101 if (m_Y_amb.size() != m_n_species)
102 {
103 throw std::invalid_argument(
104 "PlasmaChannel1D: Y_amb has " +
105 std::to_string(m_Y_amb.size()) +
106 " entries, but the mechanism has " +
107 std::to_string(m_n_species) +
108 " species.");
109 }
110 // Checked whatever the mode: the species-diffusion wall ghost reads
111 // Y_amb as well as the hydro boundary, and a ghost that does not sum
112 // to 1 injects or removes mass through the wall face.
113 const double ambient_sum =
114 std::accumulate(m_Y_amb.begin(), m_Y_amb.end(), 0.0);
115 if (!(std::abs(ambient_sum - 1.0) <= 1.0e-10))
116 {
117 throw std::invalid_argument(
118 "PlasmaChannel1D: Y_amb, the composition of the outer boundary, "
119 "sums to " +
120 std::to_string(ambient_sum) + ", not 1 to within 1e-10.");
121 }
122
123 // ---- 2. transport closure, then the shared cell ---------------------
124 // A composition-resolved collision model (cfg.specs, built from Python's
125 // `mtcf` list of MomentumTransferCollisionFrequencyModel) and the legacy
126 // sigma/nu_m/nu_E closure are two different ways to get sigma/nu_E into
127 // the SAME PlasmaCellConfig.collision slot; mixing them would silently
128 // pick one (whichever PlasmaCell::transport() checks first) and ignore
129 // the other's inputs, so reject the combination outright instead of
130 // guessing which one the caller meant.
131 // WHY this matters physically (see PHYSICS.md section 4.1, the
132 // sigma/nu_E"): sigma is far more sensitive to the *ionization state* than
133 // to bulk composition. In a fast NRP-style discharge, electron-impact
134 // ionization can run orders of magnitude ahead of the channel's initial
135 // (often near-neutral) seed composition within a couple of nanoseconds.
136 // cfg.specs recomputes sigma/nu_E live from the *evolving* composition
137 // every eval() call, tracking that; the legacy sigma(Te)/nu_m(Te) tables
138 // are frozen at whatever composition/shape they were tabulated for, and
139 // can only re-scale with Te, not with the actual evolving n_e/ionization
140 // fraction. Silently combining the two would recombine a frozen sigma(Te)
141 // *shape* with the channel's own independently-evolving n_e(t), which is
142 // exactly the ~10% error the composition-resolved model was added to fix.
143 const bool have_collision = !cfg.specs.empty();
144 const bool have_legacy_closure = !cfg.sigma_T.empty() || !cfg.sigma_v.empty() ||
145 !cfg.nu_m_T.empty() || !cfg.nu_m_v.empty() ||
146 cfg.nu_m > 0.0 || cfg.nu_E != 0.0;
147 if (have_collision && have_legacy_closure)
148 {
149 throw std::invalid_argument(
150 "PlasmaChannel1D: cfg.specs (composition-resolved collision model) "
151 "cannot be combined with sigma_T/sigma_v, nu_m, nu_m_T/nu_m_v, or "
152 "nu_E (the legacy closure) -- pick one.");
153 }
154 // Single-cell 2T physics (chemistry/Joule/exchange) shared with the 0D reactor.
155 // The field E is supplied per eval via setElectricField().
156 {
157 PlasmaCellConfig reactor_config;
158 reactor_config.reacting = cfg.reacting;
159 reactor_config.species_h0k = cfg.species_h0k;
160 if (have_collision)
161 {
162 // Same pattern as PlasmaReactor0D::PlasmaReactor0D: molar_mass and
163 // is_electron are derived from the mechanism, not the caller, since
164 // they must exactly match this domain's own species indexing.
165 // Refused rather than resized: padding gives the missing
166 // species no collision model and truncating drops the caller's
167 // tail, so either way a species silently leaves every
168 // collision sum.
169 if (cfg.specs.size() != m_n_species)
170 {
171 throw std::invalid_argument(
172 "PlasmaChannel1D: got collision specs for " +
173 std::to_string(cfg.specs.size()) +
174 " species, but the mechanism has " +
175 std::to_string(m_n_species) +
176 " species. Give one spec per mechanism species, in its "
177 "order.");
178 }
179 std::vector<CollisionModel::SpeciesSpec> specs = cfg.specs;
180 const auto &molecular_weights = m_solution->thermo()->molecularWeights();
181 for (std::size_t k = 0; k < m_n_species; k++)
182 {
183 // kg/kmol -> kg/mol
184 specs[k].molar_mass = molecular_weights[k] * 1.0e-3;
185 specs[k].is_electron = (k == m_electron_index);
186 }
187 reactor_config.collision =
188 CollisionModel(specs, cfg.Te_min, cfg.Te_max, cfg.Te_n);
189 }
190 else
191 {
192 reactor_config.sigma_T = cfg.sigma_T;
193 reactor_config.sigma_v = cfg.sigma_v;
194 reactor_config.nu_m_T = cfg.nu_m_T;
195 reactor_config.nu_m_v = cfg.nu_m_v;
196 reactor_config.nu_m = cfg.nu_m;
197 reactor_config.nu_E = cfg.nu_E;
198 }
199 // Kept so setEvaluatorPoolSize() can build further evaluators with the
200 // identical closure rather than re-deriving it from the config.
201 m_reactor_config = reactor_config;
202 m_mech = cfg.mech;
203 m_phase = cfg.phase;
204 m_cell =
205 std::make_unique<PlasmaCell>(m_solution, std::move(reactor_config));
206 }
207
208 // ---- 3. initial composition profile ---------------------------------
209 // Initial radial composition profile: one Tg-style table per species.
210 // Required, like initTg/initTe -- a uniform composition is this table
211 // with two radii, which is what the Python layer builds from `Y0`.
212 if (cfg.initY_r.size() < 2 ||
213 cfg.initY_v.size() != cfg.initY_r.size() * m_n_species)
214 {
215 throw std::invalid_argument(
216 "PlasmaChannel1D: initY_r needs at least 2 radii and initY_v "
217 "exactly initY_r.size() * nSpecies entries, row-major; got " +
218 std::to_string(cfg.initY_r.size()) + " and " +
219 std::to_string(cfg.initY_v.size()) + " for " +
220 std::to_string(m_n_species) + " species.");
221 }
222 {
223 const std::size_t n_radii = cfg.initY_r.size();
224 m_initY.reserve(m_n_species);
225 for (std::size_t k = 0; k < m_n_species; k++)
226 {
227 std::vector<double> column(n_radii);
228 for (std::size_t i = 0; i < n_radii; i++)
229 {
230 column[i] = cfg.initY_v[i * m_n_species + k];
231 }
232 m_initY.push_back(PropertyTable::fromTableOrScalar(
233 cfg.initY_r, column, "initY_r/initY_v"));
234 }
235 }
236 m_has_transport =
237 !m_kappa_H.empty() || !m_kappa_e.empty() || m_species_diffusivity > 0.0;
238
239 // ---- 4. size the domain, install the grid, require electrons -------
240 Cantera::Domain1D::resize(2 + m_n_species, n_points);
241 if (explicit_grid)
242 {
243 setupGrid(Cantera::span<const double>(cfg.grid.data(), cfg.grid.size()));
244 }
245 else if (cfg.n_points >= 2)
246 {
247 setupUniformGrid(cfg.n_points, cfg.R_max, 0.0);
248 } // n_points==1: keep the single node at r=0 (resize left m_z={0});
249 // setupUniformGrid would divide by (points-1)=0.
250
251 // Every initial profile has to span the grid: outside its radii a table
252 // holds its end value, so a profile ending short of the wall would start
253 // every node past it at the last radius's value.
254 const double outer_node = z(n_points - 1);
255 auto requireSpansGrid = [&](const PropertyTable &table, const std::string &name)
256 {
257 if (!table.empty() && !(table.covers(0.0) && table.covers(outer_node)))
258 {
259 throw std::invalid_argument(
260 "PlasmaChannel1D: the initial profile " + name + " covers [" +
261 significant(table.first()) + ", " + significant(table.last()) +
262 "] m but the grid spans [0, " + significant(outer_node) + "] m.");
263 }
264 };
265 requireSpansGrid(m_initTg, "initTg_r/initTg_v");
266 requireSpansGrid(m_initTe, "initTe_r/initTe_v");
267 for (const PropertyTable &table : m_initY)
268 {
269 requireSpansGrid(table, "initY_r/initY_v");
270 }
271
272 // Every node has to start with electrons. Te is the temperature OF the
273 // electrons, so in a cell holding none it is undefined, and electrons
274 // that chemistry later creates there would start at whatever value the
275 // cell carried. Checked at the nodes, once they exist, because the
276 // composition is a radial table and a zero can sit between its radii.
277 // The ambient is exempt: it only feeds the wall ghost, and the cells
278 // next to it keep electrons through the face fluxes.
279 for (std::size_t j = 0; j < n_points; j++)
280 {
281 const double Y_electron = m_initY[m_electron_index].eval(z(j));
282 if (!(Y_electron > 0.0))
283 {
284 std::ostringstream message;
285 message << "PlasmaChannel1D: the initial electron mass fraction is "
286 << Y_electron << " at node " << j << " (r=" << z(j)
287 << " m). The electron temperature is undefined where "
288 "there are no electrons; seed a small electron "
289 "fraction at every node, with a matching positive "
290 "ion to keep the charge balance.";
291 throw std::invalid_argument(message.str());
292 }
293 }
294
295 // A mismatched pair would make fromTableOrScalar() hand back an empty
296 // table, leaving the scalar rho at every node -- a silently different
297 // physical problem, and the one NUMERICS.md section "Validation ladder" warns reads as a
298 // solver bug rather than as the setup error it is.
299 const bool want_density_profile =
300 !cfg.init_rho_r.empty() || !cfg.init_rho_v.empty();
301 if (want_density_profile && (cfg.init_rho_r.size() < 2 ||
302 cfg.init_rho_r.size() != cfg.init_rho_v.size()))
303 {
304 throw std::invalid_argument(
305 "PlasmaChannel1D: init_rho_r and init_rho_v must be the same "
306 "length and have at least 2 points. For a density that is the "
307 "same everywhere, leave both empty and set the scalar `rho`.");
308 }
309
310 // ---- 5. radial density profile --------------------------------------
311 // Sampled after the mesh exists, since the profile is a function of radius.
312 const PropertyTable init_rho =
313 PropertyTable::fromTableOrScalar(cfg.init_rho_r, cfg.init_rho_v,
314 "init_rho_r/init_rho_v");
315 requireSpansGrid(init_rho, "init_rho_r/init_rho_v");
316 m_rho.assign(n_points, cfg.rho);
317 if (!init_rho.empty())
318 {
319 for (std::size_t j = 0; j < n_points; j++)
320 {
321 m_rho[j] = init_rho.eval(z(j));
322 }
323 }
324 for (std::size_t j = 0; j < n_points; j++)
325 {
326 if (!(m_rho[j] > 0.0))
327 {
328 std::ostringstream message;
329 message
330 << "PlasmaChannel1D: density at node "
331 << j << " (r=" << z(j) << " m) is " << m_rho[j]
332 << " kg/m^3. The species equation divides by it, so a zero or "
333 "negative density is not a state this domain can evaluate.";
334 throw std::invalid_argument(message.str());
335 }
336 }
337
338 // ---- 6. hydro-only inputs, accepted or refused as a set -------------
339 // Parsed whether or not the sub-step runs: an unknown name is a typo in
340 // either mode, and a mode the caller chose that is never looked at is the
341 // same silent drop as one that is misspelled.
342 m_outer_bc_mode = parseOuterBc(cfg.outer_bc_mode);
343
344 // Nothing outside the hydro sub-step reads these, so carrying them would
345 // report a configuration the run never applied -- an initial velocity most
346 // of all, which would come back in the recorded history as a flow this
347 // mode does not have.
348 if (!m_hydrodynamics)
349 {
350 std::string unused;
351 auto name = [&unused](const std::string &field)
352 {
353 unused += unused.empty() ? field : ", " + field;
354 };
355 if (!cfg.init_ur_r.empty() || !cfg.init_ur_v.empty())
356 {
357 name("init_ur_r/init_ur_v");
358 }
359 if (cfg.p_amb >= 0.0)
360 name("p_amb");
361 if (cfg.outer_bc_mode != "ambient_ghost")
362 name("outer_bc_mode");
363 if (!unused.empty())
364 {
365 throw std::invalid_argument(
366 "PlasmaChannel1D: " +
367 unused +
368 " is read only by the hydrodynamic sub-step, which this "
369 "config leaves off. Set hydrodynamics=true, or drop it.");
370 }
371 }
372
373 // The hydro sub-step treats a zero p_amb as a vacuum, a state a
374 // defaulted config could reach without the caller ever choosing it.
375 if (m_hydrodynamics)
376 {
377 if (!(cfg.p_amb > 0.0))
378 {
379 throw std::invalid_argument(
380 "PlasmaChannel1D: hydrodynamics needs p_amb > 0 Pa, the "
381 "ambient pressure its outer boundary imposes.");
382 }
383 if (!(cfg.cfl > 0.0) || cfg.cfl > 1.0)
384 {
385 throw std::invalid_argument(
386 "PlasmaChannel1D: hydrodynamics needs 0 < cfl <= 1.");
387 }
388 if (n_points < 2)
389 {
390 throw std::invalid_argument(
391 "PlasmaChannel1D: hydrodynamics needs at least 2 radial "
392 "nodes; a single node has no face to carry a flux.");
393 }
394 }
395
396 // ---- 7. wall ghost density ------------------------------------------
397 // Below every check above, because it consumes what they validate: Y0 is
398 // handed to setMassFractions_NoNorm, which normalizes nothing, so a sum
399 // of zero would reach the equation of state as an infinite mixture weight
400 // and come back as an infinite density or a Cantera error naming the
401 // phase instead of the field the caller set.
402 //
403 // The wall ghost is already pinned to the ambient composition and the
404 // ambient temperature, so taking its density from the outermost node would
405 // build one face coefficient out of two different states. With an ambient
406 // pressure in hand the equation of state closes it properly; with
407 // hydrodynamics off there is no ambient pressure to close it with, and a
408 // zero-gradient extrapolation is the only thing the config supports.
409 m_wall_ghost_density = m_rho.back();
410 if (m_hydrodynamics)
411 {
412 auto thermo = m_solution->thermo();
413 thermo->setMassFractions_NoNorm(
414 Cantera::span<const double>(m_Y_amb.data(), m_Y_amb.size()));
415 // Both pools at the ambient temperature, set explicitly. setState_TP
416 // leaves Te untouched, and PlasmaPhase closes the density from the
417 // MEAN of the two, so without this line the ambient ghost would depend
418 // on whatever electron temperature the phase happened to carry -- the
419 // same (p, T_amb, T_amb, Y_amb) inversion the hydro sub-step's ambient
420 // ghost performs through ThermoAccessor::densityFromPressure().
421 thermo->setElectronTemperature(cfg.T_amb);
422 thermo->setState_TP(cfg.T_amb, cfg.p_amb);
423 m_wall_ghost_density = thermo->density();
424 }
425
426 const PropertyTable init_ur =
427 PropertyTable::fromTableOrScalar(cfg.init_ur_r, cfg.init_ur_v,
428 "init_ur_r/init_ur_v");
429 requireSpansGrid(init_ur, "init_ur_r/init_ur_v");
430 m_u_r.assign(n_points, 0.0);
431 if (!init_ur.empty())
432 {
433 for (std::size_t j = 0; j < n_points; j++)
434 {
435 m_u_r[j] = init_ur.eval(z(j));
436 }
437 }
438
439 setComponentName(0, "Tg");
440 setComponentName(1, "Te");
441 for (std::size_t k = 0; k < m_n_species; k++)
442 {
443 setComponentName(2 + k, m_solution->thermo()->speciesName(k));
444 }
445
446 // ---- 8. solver tolerances -------------------------------------------
447 // Transient absolute tolerance is 100x tighter than steady (1e-12 vs
448 // 1e-10), the same ratio ThermalPlasmaColumn1D uses for its own T
449 // component -- a deliberate, consistent choice across this codebase's
450 // Domain1D subclasses, not an arbitrary pair of numbers.
451 setSteadyTolerances(/*rtol=*/1.0e-4, /*atol=*/1.0e-10);
452 setTransientTolerances(/*rtol=*/1.0e-4, /*atol=*/1.0e-12);
453 }
454
455 // ------------------------------------------------------------------------------ //
456 // Public methods //
457 // ------------------------------------------------------------------------------ //
458
459 // Cantera's Jacobian coloring calls eval() once per perturbed global point
460 // `jg` (finite-difference Jacobian: only that point's residual needs
461 // recomputing) plus once with jg==npos (a full residual evaluation, e.g. for
462 // the initial guess or a converged-solution check). This domain's stencil
463 // couples each node only to its immediate east/west neighbours (see
464 // RadialDiffusion.h), so perturbing point `jg` can only change the residual at
465 // jg-1, jg, and jg+1 -- hence the narrow [jmin, jmax] window computed below instead
466 // of recomputing every node on every perturbation.
469 double rdt)
470 {
471 // Five steps, in order:
472 // 1. is this global point ours at all, and which slice of it
473 // 2. the node window the perturbation can reach
474 // 3. the transport coefficients and ghosts, assembled once
475 // 4. per node: source terms, then the radial coupling on top
476 // 5. per node: residual = rate - rdt*(phi - phi_prev), mask all ones
477
478 // ---- 1. is this point ours ------------------------------------------
479 // Points outside this domain's own range (jg is a *global* index across
480 // all domains in the Sim1D) can't affect our residual at all; skip early.
481 if (jg != Cantera::npos && (jg + 1 < firstPoint() || jg > lastPoint() + 1))
482 {
483 return;
484 }
485 auto x = xg.subspan(loc(), size());
486 auto rsd = rg.subspan(loc(), size());
487 auto diag = maskg.subspan(loc(), size());
488
489 // ---- 2. the node window to recompute --------------------------------
490 std::size_t jmin, jmax;
491 if (jg == Cantera::npos)
492 {
493 // Full evaluation: every node's residual needs (re)computing.
494 jmin = 0;
495 jmax = m_points - 1;
496 }
497 else
498 {
499 // Narrow window: only the perturbed point and its two neighbours.
500 std::size_t local_point = (jg == 0) ? 0 : jg - firstPoint();
501 jmin = std::max<std::size_t>(local_point, 1) - 1;
502 jmax = std::min(local_point + 1, m_points - 1);
503 }
504
505 // ---- 3. node-independent coefficients, assembled once ---------------
506 // Assembled once and reused for every node of the window: the transport
507 // coefficients and ghosts do not vary with j.
508 const RadialDiffusionCoefficients diffusion = diffusionCoefficients();
509
510 std::vector<double> dYdt(m_n_species); // dY_k/dt [1/s], one entry per species
511 // div(rho*D*grad(Y_k)) [kg/m^3/s], the species-diffusion block's Fickian
512 // flux divergence, reused by Terms A and B below
513 std::vector<double> species_flux_divergence(m_n_species);
514
515 // ---- 4. per node: source terms, then the radial coupling ------------
516 for (std::size_t j = jmin; j <= jmax; j++)
517 {
518 double Tg = x[index(0, j)]; // gas temperature [K]
519 double Te = x[index(1, j)]; // electron temperature [K]
520 const double *Y = &x[index(2, j)]; // species mass fractions [-]
521
522 // lhsTg/lhsTe are the effective heat capacities per unit volume [J/m^3/K]
523 // that PlasmaCell::sourceTerms already divided the chemistry/
524 // Joule/exchange source terms by, so dTg/dTe below are already true
525 // dT/dt [K/s] for those terms. The conduction term computed via
526 // faceDivergence() is a bare flux divergence, not yet divided by
527 // capacitance,
528 // so it must be divided by the SAME lhsTg/lhsTe here to add
529 // consistently onto dTg/dTe (see the class-level header comment for
530 // the governing PDEs, rho*cv_h*dTg/dt = div(...) + ...).
531 // [K/s], [K/s], [J/m^3/K], [J/m^3/K]
532 double dTg = 0.0, dTe = 0.0, lhsTg = 1.0, lhsTe = 1.0;
533 std::fill(dYdt.begin(), dYdt.end(), 0.0);
534 m_cell->sourceTerms(Tg, Te, Y, m_rho[j], m_electric_field,
535 dTg, dTe, dYdt.data(), lhsTg, lhsTe);
536
537 if (m_has_transport)
538 {
539 addRadialDiffusion(*this, x, diffusion, j, lhsTg, lhsTe, dTg, dTe,
540 dYdt.data(), species_flux_divergence.data());
541 }
542
543 // ---- 5. per node: residual and transient mask -------------------
544 // Steady residual = source+transport rate; the -rdt*(phi-phi_prev) term
545 // is Cantera's pseudo-transient damping (rdt = 1/dt during a time step,
546 // 0 during a steady solve or when computing a bare method-of-lines RHS
547 // -- see ChannelRHS::eval in PlasmaChannelSolver.cpp). diag=1
548 // marks every component here as a true (non-algebraic) unknown, so
549 // Cantera includes the transient term for all of them; there is no
550 // Dirichlet/algebraic row in this domain (the wall is a ghost-cell
551 // flux, not a fixed unknown).
552 rsd[index(0, j)] = dTg - rdt * (Tg - prevSoln(0, j));
553 rsd[index(1, j)] = dTe - rdt * (Te - prevSoln(1, j));
554 diag[index(0, j)] = 1;
555 diag[index(1, j)] = 1;
556 for (std::size_t k = 0; k < m_n_species; k++)
557 {
558 rsd[index(2 + k, j)] = dYdt[k] - rdt * (Y[k] - prevSoln(2 + k, j));
559 diag[index(2 + k, j)] = 1;
560 }
561 }
562 }
563
564 // Map a Domain1D component index to its name ("Tg", "Te", or a species name),
565 // the inverse of componentIndex(). Required by Cantera's Domain1D interface
566 // (used e.g. for solution output, error messages, and by our own callers in
567 // PlasmaChannelSolver.cpp to enumerate/report components by name).
568 std::string PlasmaChannel1D::componentName(std::size_t n) const
569 {
570 if (n == 0)
571 return "Tg";
572 if (n == 1)
573 return "Te";
574 return m_solution->thermo()->speciesName(n - 2);
575 }
576
577 // Map a component name back to its Domain1D index (inverse of componentName()).
578 // The `checkAlias` argument from the Domain1D base signature is unused: this
579 // domain has no aliases, only the canonical "Tg"/"Te"/species names.
580 std::size_t PlasmaChannel1D::componentIndex(const std::string &name, bool) const
581 {
582 if (name == "Tg")
583 return 0;
584 if (name == "Te")
585 return 1;
586 std::size_t k = m_solution->thermo()->speciesIndex(name);
587 if (k != Cantera::npos)
588 return 2 + k;
589 throw std::invalid_argument("PlasmaChannel1D: no component '" + name + "'.");
590 }
591
592 bool PlasmaChannel1D::hasComponent(const std::string &name, bool) const
593 {
594 return name == "Tg" ||
595 name == "Te" ||
596 m_solution->thermo()->speciesIndex(name) != Cantera::npos;
597 }
598
599 // Read one component's radial profile out of the domain's own solution buffer
600 // (m_state, populated by Cantera after getInitialSoln()/a solve). Used by
601 // PlasmaChannelSolver::solveChannelTransient to seed its flat state
602 // vector `x` from the freshly-initialized profile (see the comment there on
603 // why getState() can't be used for that instead).
604 void PlasmaChannel1D::getValues(const std::string &component,
606 {
607 if (!m_state)
608 {
609 throw std::runtime_error("PlasmaChannel1D::getValues: not installed.");
610 }
611 std::size_t comp = componentIndex(component);
612 const double *soln = m_state->data() + loc();
613 for (std::size_t j = 0; j < m_points; j++)
614 {
615 values[j] = soln[index(comp, j)];
616 }
617 }
618
619 // Cantera calls this once per (component, node) to seed the initial guess
620 // before the first solve. The temperatures fall back to a uniform
621 // m_ambient_temperature where no profile table was supplied; the
622 // composition has no fallback, since initY is required (a uniform
623 // composition is that table with two radii).
624 double PlasmaChannel1D::initialValue(std::size_t n, std::size_t j)
625 {
626 double r = z(j);
627 if (n == 0)
628 return m_initTg.empty() ? m_ambient_temperature : m_initTg.eval(r);
629 if (n == 1)
630 return m_initTe.empty() ? m_ambient_temperature : m_initTe.eval(r);
631 std::size_t k = n - 2;
632 return m_initY[k].eval(r);
633 }
634
635 // The single writer of the two fields the hydro sub-step owns, so the
636 // positivity the species equation needs is checked in one place.
637 void PlasmaChannel1D::setHydroState(const std::vector<double> &rho,
638 const std::vector<double> &u_r)
639 {
640 if (rho.size() != m_points || u_r.size() != m_points)
641 {
642 throw std::invalid_argument(
643 "PlasmaChannel1D::setHydroState: rho and u_r must each have one "
644 "entry per radial node.");
645 }
646 for (std::size_t j = 0; j < m_points; j++)
647 {
648 if (!(rho[j] > 0.0))
649 {
650 std::ostringstream message;
651 message << "PlasmaChannel1D::setHydroState: density at node " << j
652 << " (r=" << z(j) << " m) is " << rho[j]
653 << " kg/m^3. The hydro sub-step has driven it to zero or "
654 "below; lower cfl or refine the grid.";
655 throw std::invalid_argument(message.str());
656 }
657 }
658 m_rho = rho;
659 m_u_r = u_r;
660 }
661
662 // Thin pass-through to the shared 0D physics (PlasmaCell), so the recorded
663 // history reports n_e and p the way the reaction source terms themselves see
664 // them. The channel carries no pressure of its own: the state is
665 // (rho, u_r, Tg, Te, Y_s) and p follows from the equation of state, so both
666 // of these are diagnostic reads through the same state-setting the residual
667 // uses -- one state-setting, which is why they are returned together.
668 void PlasmaChannel1D::nodeDiagnostics(double Tg, double Te, const double *Y,
669 std::size_t j, double &pressure,
670 double &electron_density) const
671 {
672 m_cell->pressureAndElectronDensity(Tg, Te, Y, m_rho[j], pressure,
673 electron_density);
674 }
675
676 void PlasmaChannel1D::nodeRates(std::size_t j, double Tg, double Te,
677 const double *Y, double electric_field,
678 double *ydot) const
679 {
680 if (m_has_transport)
681 {
682 throw std::runtime_error(
683 "PlasmaChannel1D::nodeRates: this domain has conduction or "
684 "species diffusion enabled, so a node's rates depend on its "
685 "neighbours and cannot be evaluated one node at a time. Use "
686 "eval() instead.");
687 }
688 // lhs_Tg/lhs_Te are the capacitances rates() has already divided its own
689 // sources by, so dTg/dTe come back as true dT/dt. eval() only keeps them
690 // because its conduction term is a bare flux divergence that still needs
691 // dividing by the same capacitance; with no transport there is nothing
692 // left to divide.
693 double lhs_Tg = 1.0, lhs_Te = 1.0; // [J/m^3/K]
694
695 // Which evaluator this call may touch. Outside a parallel region, and in
696 // a build without OpenMP, this is always slot 0.
697#ifdef _OPENMP
698 const std::size_t slot = static_cast<std::size_t>(omp_get_thread_num());
699 // Refused rather than wrapped into range. A slot shared by two threads is
700 // two of them rewriting one PlasmaCell's scratch and one Cantera phase
701 // state, which returns plausible wrong numbers instead of crashing --
702 // exactly what the pool exists to prevent. The caller sizes the pool to
703 // the team it is about to start, so reaching here means that invariant
704 // broke and the answer cannot be trusted.
705 if (slot >= evaluatorPoolSize())
706 {
707 throw std::runtime_error(
708 "PlasmaChannel1D::nodeRates: thread " +
709 std::to_string(slot) +
710 " has no evaluator of its own; the pool holds " +
711 std::to_string(evaluatorPoolSize()) +
712 ". Call setEvaluatorPoolSize() with the number of threads that "
713 "will enter this function before starting the parallel region.");
714 }
715#else
716 const std::size_t slot = 0;
717#endif
718 const PlasmaCell &reactor =
719 (slot == 0) ? *m_cell : *m_extra_rhs[slot - 1];
720 reactor.sourceTerms(Tg, Te, Y, m_rho[j], electric_field,
721 ydot[0], ydot[1], ydot + 2, lhs_Tg, lhs_Te);
722 }
723
725 const double *Y,
726 std::size_t j) const
727 {
728 return m_cell->electricalConductivity(Tg, Te, Y, m_rho[j]);
729 }
730
732 double &conduction_out,
733 double &diffusion_out,
734 std::vector<double> &species_mass_out) const
735 {
736 conduction_out = 0.0;
737 diffusion_out = 0.0;
738 species_mass_out.assign(m_n_species, 0.0);
739 if (!m_has_transport)
740 {
741 return;
742 }
743 const std::size_t wall_node = m_points - 1;
744 std::vector<double> species_enthalpy(m_n_species); // [J/kg]
745 m_cell->speciesEnthalpies(x[index(0, wall_node)], x[index(1, wall_node)],
746 &x[index(2, wall_node)], m_rho[wall_node],
747 species_enthalpy.data());
749 *this, x, diffusionCoefficients(), species_enthalpy.data());
750 conduction_out = outflow.conduction;
751 diffusion_out = outflow.diffusion;
752 if (!outflow.species_mass.empty())
753 {
754 species_mass_out = outflow.species_mass;
755 }
756 }
757
758 double PlasmaChannel1D::faceRadius(std::size_t j) const
759 {
760 if (j == 0)
761 {
762 return 0.0;
763 }
764 if (j < m_points)
765 {
766 return 0.5 * (z(j - 1) + z(j));
767 }
768 // The wall face, mirrored half a spacing past the last node. A single-point
769 // grid has no spacing to mirror, so the domain radius stands in for it,
770 // through faceSpacing() like every other face.
771 return z(m_points - 1) + 0.5 * faceSpacing(m_points);
772 }
773 double PlasmaChannel1D::faceSpacing(std::size_t j) const
774 {
775 if (j < m_points)
776 {
777 return z(j) - z(j - 1);
778 }
779 return (m_points >= 2) ? z(m_points - 1) - z(m_points - 2) : m_outer_radius;
780 }
781
783 {
784 if (slots < 1)
785 {
786 throw std::runtime_error(
787 "PlasmaChannel1D::setEvaluatorPoolSize: the pool must hold at "
788 "least the evaluator built at construction.");
789 }
790 m_extra_rhs.resize(slots - 1);
791 for (auto &evaluator : m_extra_rhs)
792 {
793 if (!evaluator)
794 {
795 evaluator = std::make_unique<PlasmaCell>(
796 loadMechanism(m_mech, m_phase), m_reactor_config);
797 }
798 }
799 }
800
801 // ------------------------------------------------------------------------------ //
802 // Private methods //
803 // ------------------------------------------------------------------------------ //
804
805 RadialDiffusionCoefficients PlasmaChannel1D::diffusionCoefficients() const
806 {
808 diffusion.kappa = &m_kappa_H;
809 diffusion.kappa_e = &m_kappa_e;
810 diffusion.species_diffusivity = m_species_diffusivity;
811 diffusion.ambient_temperature = m_ambient_temperature;
812 diffusion.wall_ghost_density = m_wall_ghost_density;
813 diffusion.Y_amb = &m_Y_amb;
814 diffusion.molecular_weights = &m_cell->molecularWeights();
815 diffusion.species_cp = &m_cell->speciesCp();
816 diffusion.electron_index = m_electron_index;
817 diffusion.n_species = m_n_species;
818 return diffusion;
819 }
820
821} // namespace rizer
Pure-arithmetic primitives for a 1D cylindrical (radial) finite-volume discretization of the diffusiv...
Number formatting for exception messages.
The transient two-temperature radial channel as a Cantera Domain1D: per-node source terms from Plasma...
The two-temperature quantities every model in this extension reads back from a PlasmaPhase,...
The parabolic half of PlasmaChannel1D::eval(): conduction in both pools, Fickian species diffusion,...
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
Shared single-cell two-temperature reacting-plasma RHS (one physics source).
Definition PlasmaCell.h:87
void sourceTerms(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).
void wallEnergyOutflow(Cantera::span< const double > x, double &conduction_out, double &diffusion_out, std::vector< double > &species_mass_out) const
Energy conduction and species diffusion move out through the wall ghost face at the state x,...
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 setEvaluatorPoolSize(std::size_t slots)
Give nodeRates() slots independent evaluators so that many threads can call it at once.
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.
void setHydroState(const std::vector< double > &rho, const std::vector< double > &u_r)
Replace the fields the hydro sub-step owns.
void nodeDiagnostics(double Tg, double Te, const double *Y, std::size_t j, double &pressure, double &electron_density) const
Total pressure [Pa] and electron number density [1/m^3] for a node state, both from the two-temperatu...
bool hasComponent(const std::string &name, bool checkAlias=true) const override
Whether name is "Tg", "Te", or a species of this domain's mechanism.
std::string componentName(std::size_t n) const override
Component n -> name ("Tg", "Te", or a species name).
void nodeRates(std::size_t j, double Tg, double Te, const double *Y, double electric_field, double *ydot) const
One node's time derivatives with no transport stencil: exactly what eval() computes for node j when h...
bool hydrodynamics() const
Whether the radial hydrodynamic sub-step is active [-].
double nodeElectricalConductivity(double Tg, double Te, const double *Y, std::size_t j) const
Electrical conductivity [S/m] at one node's state, from the same composition-resolved closure the sou...
double faceRadius(std::size_t j) const
Radius of face j, the single definition of where a cell boundary sits (NUMERICS.md section 2).
std::size_t evaluatorPoolSize() const
Number of independent evaluators nodeRates() can be called on at once [-]; 1 unless setEvaluatorPoolS...
PlasmaChannel1D(const PlasmaChannelConfig &cfg)
Build the domain (mesh, thermo/kinetics, property tables) from cfg.
double faceSpacing(std::size_t j) const
Node-to-node spacing across face j [m], the distance a gradient at that face is taken over.
std::size_t componentIndex(const std::string &name, bool checkAlias=true) const override
Component name -> index (inverse of componentName()); throws if unknown.
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.
const size_t npos
OuterBc parseOuterBc(const std::string &mode)
Map an outer_bc_mode config string onto the enum.
std::shared_ptr< Cantera::Solution > loadMechanism(const std::string &mech, const std::string &phase)
Load a mechanism with rizer's custom rate types registered first.
void addRadialDiffusion(const PlasmaChannel1D &domain, Cantera::span< const double > x, const RadialDiffusionCoefficients &c, std::size_t j, double lhsTg, double lhsTe, double &dTg, double &dTe, double *dYdt, double *scratch)
Add one node's diffusive contributions to the rates the caller holds.
std::string significant(double value)
value to 6 significant digits, for exception messages.
Definition Format.h:18
WallEnergyOutflow wallEnergyOutflow(const PlasmaChannel1D &domain, Cantera::span< const double > x, const RadialDiffusionCoefficients &c, const double *species_enthalpy)
What conduction and species diffusion move through the wall face at the state x.
double nu_m
Scalar electron momentum-transfer frequency, used when nu_m_T/nu_m_v are empty [1/s].
Definition PlasmaCell.h:70
CollisionModel collision
Composition-resolved sigma/nu_E (preferred).
Definition PlasmaCell.h:54
std::vector< double > sigma_v
sigma(Te) table: electrical conductivity values at sigma_T [S/m]
Definition PlasmaCell.h:61
bool reacting
Include finite-rate chemistry source terms (true) or freeze composition (false) [-].
Definition PlasmaCell.h:52
std::vector< double > species_h0k
Per-species standard enthalpy of formation at 0 K [J/kmol], length nSpecies(), in mechanism species-i...
Definition PlasmaCell.h:83
std::vector< double > nu_m_v
nu_m(Te) table: electron momentum-transfer frequency values at nu_m_T [1/s]
Definition PlasmaCell.h:67
double nu_E
Scalar elastic electron-heavy energy-exchange frequency, used when collision is empty [1/s].
Definition PlasmaCell.h:73
std::vector< double > nu_m_T
nu_m(Te) table: electron-temperature grid points [K], strictly increasing
Definition PlasmaCell.h:64
std::vector< double > sigma_T
sigma(Te) table: electron-temperature grid points [K], strictly increasing
Definition PlasmaCell.h:58
Everything PlasmaChannel1D needs to build itself.
std::vector< double > initY_v
std::string mech
Cantera mechanism (YAML) file path.
std::vector< double > sigma_v
double nu_m
Electron momentum-transfer frequency nu_m [1/s].
std::vector< double > species_h0k
Per-species standard enthalpy of formation at 0 K [J/kmol], forwarded to PlasmaCellConfig::species_h0...
std::size_t n_points
Number of radial grid points [-]. Ignored when grid is given.
double nu_E
Elastic electron-heavy energy-exchange frequency nu_E [1/s].
std::size_t Te_n
Collision-model Te grid point count [-]. Read only with specs.
double R_max
Outer (wall) radius R [m]. Ignored when grid is given.
double Te_min
Collision-model Te grid lower bound [K].
std::vector< double > sigma_T
Electrical conductivity table sigma(Te) [K], [S/m].
bool reacting
Include finite-rate chemistry source terms [-].
std::vector< double > initY_r
Y(r) at t=0.
std::vector< double > nu_m_T
Te-dependent nu_m(Te) table [K], [1/s].
double Te_max
Collision-model Te grid upper bound [K].
std::vector< CollisionModel::SpeciesSpec > specs
Composition-resolved collision model, preferred over the legacy sigma/nu_m/nu_E closures above and mu...
std::vector< double > nu_m_v
std::vector< double > grid
Explicit radial grid [m], increasing from 0.
std::string phase
Phase name within mech; must be a PlasmaPhase carrying "e-".
Everything the diffusion block needs that the grid and the state do not carry: the transport coeffici...
std::size_t electron_index
Index of the electron species [-].
const PropertyTable * kappa_e
Electron conductivity [W/m/K]; an empty table switches electron conduction off.
const std::vector< double > * Y_amb
Ambient mass fractions [-], length K: the species ghost.
double ambient_temperature
Wall/ambient temperature [K], the Dirichlet ghost both conduction calls sit against.
double species_diffusivity
Scalar species mass diffusivity [m^2/s]; zero switches the species block, and with it both energy te...
std::size_t n_species
Number of species K [-].
const PropertyTable * kappa
Heavy-pool conductivity [W/m/K]; an empty table switches heavy conduction off.
const std::vector< double > * species_cp
Per-species [J/kg/K], length K.
const std::vector< double > * molecular_weights
Per-species molecular weight [kg/kmol], length K.
double wall_ghost_density
Mass density of the wall ghost half-cell [kg/m^3], which closes the face coefficient of the outermost...
Energy leaving through the wall ghost face per unit time, per unit axial length and per radian [W/m] ...
std::vector< double > species_mass
Mass of each species diffusion carries through the ghost face [kg/m/s], length K; empty when species ...
double diffusion
Enthalpy species diffusion carries through the ghost face [W/m].
double conduction
Conduction of both pools against the ghost temperature [W/m].
Physical constants and unit conversions, in SI by default.