rizer.cantera_ext
C++ Cantera 1-D plasma extension (custom Domain1D models and solvers)
Loading...
Searching...
No Matches
PlasmaChannelSolver.cpp
Go to the documentation of this file.
2#include "PlasmaChannel1D.h"
3#include "CircuitCoupling.h"
4#include "HydroStep.h"
8#include "numeric/Format.h"
9
10#include "cantera/oneD/Sim1D.h"
15
16#include <algorithm>
17#include <cmath>
18#include <cstdio>
19#include <limits>
20#include <sstream>
21#include <functional>
22#include <memory>
23#include <stdexcept>
24
25namespace rizer
26{
27
28 namespace
29 {
30
31 // -------------------------------------------------------------------------- //
32 // File-local helpers //
33 // -------------------------------------------------------------------------- //
34
35 // Method-of-lines RHS for the stiff (CVODE) backend:
36 // @f[
37 // \dot{y} = f(y)
38 // @f]
39 // is the channel's spatial operator (transport + chemistry +
40 // Joule/elastic). Because PlasmaChannel1D::eval returns each node's
41 // @f$d\phi/dt@f$ (source/capacitance plus transport/capacitance) minus the
42 // transient term @f$r_{dt}(\phi-\phi_{prev})@f$, calling it with
43 // @f$r_{dt}=0@f$ yields exactly @f$\dot{y}@f$. The wall is a ghost-cell flux
44 // (no algebraic constraint), so the whole system is a clean ODE.
45 class ChannelRHS : public Cantera::FuncEval
46 {
47 public:
64 ChannelRHS(PlasmaChannel1D *channel, Cantera::OneDim *sim,
65 const std::vector<double> &state, std::size_t n_vars,
66 std::size_t channel_offset, std::size_t n_points,
67 std::function<double(double)> electricFieldAt)
68 : m_channel(channel), m_sim(sim), m_state(&state), m_n_vars(n_vars),
69 m_channel_offset(channel_offset), m_n_points(n_points),
70 m_field(std::move(electricFieldAt)) {}
71
72 std::size_t neq() const override { return m_state->size(); }
73
80 void getState(Cantera::span<double> y) override
81 {
82 std::copy(m_state->begin(), m_state->end(), y.begin());
83 }
84
90 void eval(double t,
91 Cantera::span<const double> y,
92 Cantera::span<double> ydot,
93 Cantera::span<const double> /*params*/) override
94 {
95 m_channel->setElectricField(m_field(t));
96 // @f$r_{dt}=0@f$ -> the OneDim residual is exactly the
97 // method-of-lines RHS @f$d\phi/dt@f$. Route through OneDim::eval
98 // (the proven path that manages the mask workspace), not the
99 // domain's eval directly.
100 m_sim->eval(/*j=*/Cantera::npos, y, ydot, /*rdt=*/0.0, /*count=*/0);
101 }
102
115 void getConstraints(Cantera::span<double> constraints) override
116 {
117 std::fill(constraints.begin(), constraints.end(), 0.0);
118 for (std::size_t j = 0; j < m_n_points; j++)
119 {
120 for (std::size_t comp = 2; comp < m_n_vars; comp++)
121 {
122 // 1.0 => y_i >= 0
123 constraints[m_channel_offset + m_n_vars * j + comp] = 1.0;
124 }
125 }
126 }
127
128 private:
129 // Non-owning; owned by solveChannelTransient's `channel`
130 PlasmaChannel1D *m_channel;
131 // Non-owning; owned by solveChannelTransient's `sim`
132 Cantera::OneDim *m_sim;
133 // Non-owning; solveChannelTransient's live global state,
134 // units per-component (Tg/Te [K], Y_k [-])
135 const std::vector<double> *m_state;
136 // Components per node, 2+K [-]
137 std::size_t m_n_vars;
138 // This domain's offset into the global state [-]
139 std::size_t m_channel_offset;
140 // Number of radial grid points [-]
141 std::size_t m_n_points;
142 // E(t) [V/m], t in [s]
143 std::function<double(double)> m_field;
144 };
145
150 class NodeRHS : public Cantera::FuncEval
151 {
152 public:
162 NodeRHS(PlasmaChannel1D *channel, const std::vector<double> &state,
163 std::size_t n_vars, std::size_t offset, std::size_t node,
164 std::function<double(double)> electricFieldAt)
165 : m_channel(channel), m_state(&state), m_n_vars(n_vars),
166 m_offset(offset), m_node(node), m_field(std::move(electricFieldAt)) {}
167
168 std::size_t neq() const override { return m_n_vars; }
169
170 void getState(Cantera::span<double> y) override
171 {
172 std::copy(m_state->begin() + static_cast<std::ptrdiff_t>(m_offset),
173 m_state->begin() + static_cast<std::ptrdiff_t>(m_offset + m_n_vars),
174 y.begin());
175 }
176
177 void eval(double t, Cantera::span<const double> y,
178 Cantera::span<double> ydot,
179 Cantera::span<const double> /*params*/) override
180 {
181 m_channel->nodeRates(m_node, y[0], y[1], &y[2], m_field(t),
182 ydot.data());
183 }
184
189 void getConstraints(Cantera::span<double> constraints) override
190 {
191 std::fill(constraints.begin(), constraints.end(), 0.0);
192 for (std::size_t comp = 2; comp < m_n_vars; comp++)
193 {
194 constraints[comp] = 1.0;
195 }
196 }
197
198 private:
199 PlasmaChannel1D *m_channel; // Non-owning
200 const std::vector<double> *m_state; // Non-owning
201 std::size_t m_n_vars;
202 std::size_t m_offset;
203 std::size_t m_node;
204 std::function<double(double)> m_field;
205 };
206
218 std::vector<double> faceRadii(const PlasmaChannel1D &channel)
219 {
220 const std::size_t n_points = channel.nPoints();
221 std::vector<double> r_face(n_points + 1);
222 for (std::size_t j = 0; j <= n_points; j++)
223 {
224 r_face[j] = channel.faceRadius(j);
225 }
226 return r_face;
227 }
228
231 std::vector<double> cellVolumes(const std::vector<double> &r_face)
232 {
233 std::vector<double> V_cell(r_face.size() - 1);
234 for (std::size_t j = 0; j < V_cell.size(); j++)
235 {
236 V_cell[j] = numeric::annularVolume(r_face[j + 1], r_face[j]);
237 }
238 return V_cell;
239 }
240
256 HydroStepConfig buildHydroConfig(const PlasmaChannel1D &channel,
257 const ChannelOptions &opts,
258 const ThermoAccessor &thermo)
259 {
260 const std::size_t n_points = channel.nPoints();
261 HydroStepConfig cfg;
262 cfg.r_node.resize(n_points);
263 for (std::size_t j = 0; j < n_points; j++)
264 cfg.r_node[j] = channel.z(j);
265 cfg.r_face = faceRadii(channel);
266 cfg.V_cell = cellVolumes(cfg.r_face);
267
268 cfg.cylindrical_source = true;
269 cfg.outer_bc_mode = channel.outerBcMode();
270 cfg.p_amb = opts.cfg.p_amb;
271 cfg.T_amb = opts.cfg.T_amb;
272 cfg.Y_amb = channel.ambientMassFractions();
273 cfg.cfl = opts.cfg.cfl;
274 if (opts.cfg.hydro_substep > 0.0)
275 {
276 const double count = 0.5 * opts.dt / opts.cfg.hydro_substep;
277 const double whole = std::round(count);
278 if (whole < 1.0 || std::abs(count - whole) > 1.0e-9 * count)
279 {
280 throw std::invalid_argument(
281 "solveChannelTransient: the half-step dt/2 = " +
282 significant(0.5 * opts.dt) +
283 " s is not an integer multiple of hydro_substep = " +
284 significant(opts.cfg.hydro_substep) + " s; it comes to " +
285 significant(count) + " sub-steps.");
286 }
287 cfg.n_subcycles = static_cast<std::size_t>(whole);
288 cfg.refuse_above_cfl = true;
289 }
290 cfg.electron_index = static_cast<int>(channel.electronIndex());
291 cfg.thermo = &thermo;
292 return cfg;
293 }
294
295 // One hydro half-step, reading and writing the two representations of the
296 // same state: rho/u_r live on the domain, Tg/Te/Y live in the flat solution
297 // vector the integrator owns. Both have to be current on the way in and on
298 // the way out, which is the whole content of this function.
299 void hydroHalfStep(PlasmaChannel1D &channel, HydroStepConfig &cfg,
300 std::vector<double> &x, std::size_t channel_offset,
301 std::size_t n_vars, double t0, double dt,
302 ChannelHistory &history)
303 {
304 const std::size_t n_points = channel.nPoints();
305 const std::size_t n_species = channel.nSpecies();
306
307 HydroStepState state;
308 state.n_species = n_species;
309 state.rho = channel.density();
310 state.u_r = channel.velocity();
311 state.Tg.resize(n_points);
312 state.Te.resize(n_points);
313 state.Y.resize(n_points * n_species);
314 for (std::size_t j = 0; j < n_points; j++)
315 {
316 const std::size_t base = channel_offset + n_vars * j;
317 state.Tg[j] = x[base + 0];
318 state.Te[j] = x[base + 1];
319 for (std::size_t k = 0; k < n_species; k++)
320 {
321 state.Y[j * n_species + k] = x[base + 2 + k];
322 }
323 }
324
325 // The only two fields that are not the same at every half-step.
326 cfg.dt_total = dt;
327 cfg.t0 = t0;
328
329 const HydroStepResult result = hydroStep(cfg, state);
330 history.boundary_mass_out += result.boundary_mass_out;
331 for (std::size_t k = 0; k < n_species; k++)
332 {
333 history.boundary_species_mass_out[k] +=
334 result.boundary_species_mass_out[k];
335 }
336 history.boundary_total_energy_out += result.boundary_total_energy_out;
337 history.boundary_electron_energy_out += result.boundary_electron_energy_out;
338 history.pressure_work_electron += result.pressure_work_electron;
339
340 channel.setHydroState(state.rho, state.u_r);
341 for (std::size_t j = 0; j < n_points; j++)
342 {
343 const std::size_t base = channel_offset + n_vars * j;
344 x[base + 0] = state.Tg[j];
345 x[base + 1] = state.Te[j];
346 for (std::size_t k = 0; k < n_species; k++)
347 {
348 x[base + 2 + k] = state.Y[j * n_species + k];
349 }
350 }
351 }
352
370 std::string decodeSolverFailure(const std::string &message,
371 std::size_t failed_node,
372 const PlasmaChannel1D &channel,
373 std::size_t channel_offset,
374 std::size_t n_vars,
375 std::size_t n_points)
376 {
377 // Failure time [s], if the message carries it
378 // ("At t = <x> and h = ...").
379 double t_fail = -1.0;
380 // Character offset into `message` [-]
381 std::size_t t_pos = message.find("At t = ");
382 if (t_pos != std::string::npos)
383 {
384 try
385 {
386 t_fail = std::stod(message.substr(t_pos + 7));
387 }
388 catch (...)
389 {
390 }
391 }
392 std::ostringstream out;
393 out << "PlasmaChannel1D BDF solve failed to converge";
394 if (t_fail >= 0.0)
395 out << " near t=" << t_fail << " s";
396 out << ".\nThe implicit corrector could not resolve the local "
397 "dynamics and the step size hit its floor. The dominant "
398 "error contributions are:\n";
399 std::istringstream in(message);
400 std::string line;
401 bool in_error_block = false, found_any = false;
402 while (std::getline(in, line))
403 {
404 if (line.find("Components with largest") != std::string::npos)
405 {
406 in_error_block = true;
407 continue;
408 }
409 if (!in_error_block)
410 continue;
411 std::size_t colon = line.find(':');
412 if (colon == std::string::npos)
413 continue;
414 long global_index;
415 double error_value;
416 try
417 {
418 global_index = std::stol(line.substr(0, colon));
419 error_value = std::stod(line.substr(colon + 1));
420 }
421 catch (...)
422 {
423 continue;
424 }
425 // A single-node system numbers its components from zero and
426 // carries no node in the index, so the node comes from which
427 // system failed rather than from the message.
428 const long base =
429 (failed_node == Cantera::npos)
430 ? static_cast<long>(channel_offset)
431 : 0;
432 long local_index = global_index - base;
433 if (local_index < 0)
434 continue;
435 std::size_t j = (failed_node == Cantera::npos)
436 ? static_cast<std::size_t>(local_index) / n_vars
437 : failed_node;
438 std::size_t comp = static_cast<std::size_t>(local_index) % n_vars;
439 if (j >= n_points)
440 continue;
441 out << " node " << j << " (r=" << channel.z(j) << " m) "
442 << channel.componentName(comp)
443 << " (weighted error " << error_value << ")\n";
444 found_any = true;
445 }
446 if (!found_any)
447 {
448 out << message << "\n";
449 }
450 else
451 {
452 // The states the closures refused (TrialStateError and the like)
453 // are why the step size fell; dropping them with the component
454 // table would leave the caller a convergence failure with no
455 // cause.
456 const std::string caught_marker = "Exceptions caught during RHS evaluation";
457 const std::size_t caught = message.find(caught_marker);
458 if (caught != std::string::npos)
459 {
460 const std::size_t table =
461 message.find("Components with largest", caught);
462 out << message.substr(caught, table == std::string::npos
463 ? std::string::npos
464 : table - caught)
465 << "\n";
466 }
467 }
468 out << "Remedies: reduce dt, loosen tolerances, soften the initial "
469 "profiles or the field ramp, or verify the initial state is "
470 "physically consistent at the flagged nodes.";
471 return out.str();
472 }
473
474 } // namespace
475
476 // ------------------------------------------------------------------------------ //
477 // Free functions //
478 // ------------------------------------------------------------------------------ //
479
481 {
482 using std::make_shared;
483
484 // Ten steps, in order:
485 // 1. build the domain and bracket it into a Sim1D
486 // 2. mirror the domain's state into the flat vector the integrator owns
487 // 3. the history, and the recorders that append one frame to it
488 // 4. the applied field: a prescribed schedule, or a closed circuit
489 // 5. reject option combinations that cannot mean what they say
490 // 6. the hydro sub-step's own geometry and equation of state
491 // 7. the t=0 frame, recorded before anything is advanced
492 // 8. the CVODE sub-systems: one coupled, or one per node
493 // 9. graded startup, on the path that integrates straight through
494 // 10. the frame loop, then the recorded history out
495
496 // ---- 1. domain and Sim1D --------------------------------------------
497 auto channel = make_shared<PlasmaChannel1D>(opts.cfg);
498
499 auto left = make_shared<Cantera::Empty1D>();
500 auto right = make_shared<Cantera::Empty1D>();
501 std::vector<std::shared_ptr<Cantera::Domain1D>> domains{left, channel, right};
502 Cantera::Sim1D sim(domains);
503 sim.getInitialSoln(); // Apply PlasmaChannel1D::initialValue
504
505 const std::size_t n_species = channel->nSpecies();
506 const std::size_t n_points = channel->nPoints();
507 const std::size_t n_vars = 2 + n_species; // Per-node: Tg, Te, Y_1..Y_K
508 const std::size_t channel_offset =
509 channel->loc(); // This domain's offset in the global state
510
511 // ---- 2. the flat state the integrator owns --------------------------
512 // Flat global state vector, units per-component (Tg/Te [K], Y_k [-]), length
513 // channel_offset + n_vars*n_points. The integrator owns it and passes it to
514 // ChannelRHS::eval() at every internal step, so it must be kept current
515 // with the domain's own state (PlasmaChannel1D::m_state) and vice versa.
516 std::vector<double> x(sim.size(), 0.0);
517
518 // Initialize x from the populated solution (getInitialSoln wrote it into
519 // the internal m_state). NOTE: getState() copies the Newton work buffer
520 // m_xnew (empty here), so read the real state via the domain's values()
521 // (Domain1D::getValues reads m_state).
522 for (std::size_t comp = 0; comp < n_vars; comp++)
523 {
524 std::vector<double> vals = channel->values(channel->componentName(comp));
525 for (std::size_t j = 0; j < n_points; j++)
526 {
527 x[channel_offset + n_vars * j + comp] = vals[j];
528 }
529 }
530
531 // ---- 3. the history and its recorders -------------------------------
532 // Static metadata (grid, species names, sizes) is fixed for the whole run
533 // and only needs setting once, up front; only t/Tg/Te/ne/Y grow per frame.
534 ChannelHistory history;
535 history.npts = n_points;
536 history.nsp = n_species;
537 history.boundary_species_mass_out.assign(n_species, 0.0);
538 for (std::size_t j = 0; j < n_points; j++)
539 history.r.push_back(channel->z(j));
540 for (std::size_t k = 0; k < n_species; k++)
541 {
542 history.species.push_back(channel->componentName(2 + k));
543 }
544
545 // Appends one frame (all nodes) to `history` from the CURRENT flat state
546 // `x`, in the flat layout x[channel_offset + n_vars*j + component].
547 // Captured by reference so the loop below can call it at its own cadence.
548 auto record = [&](double t)
549 {
550 history.t.push_back(t);
551 for (std::size_t j = 0; j < n_points; j++)
552 {
553 const double Tg = x[channel_offset + n_vars * j + 0];
554 const double Te = x[channel_offset + n_vars * j + 1];
555 history.Tg.push_back(Tg);
556 history.Te.push_back(Te);
557 double pressure, electron_density; // [Pa], [1/m^3]
558 channel->nodeDiagnostics(Tg, Te,
559 &x[channel_offset + n_vars * j + 2], j,
560 pressure, electron_density);
561 history.ne.push_back(electron_density);
562 for (std::size_t k = 0; k < n_species; k++)
563 {
564 history.Y.push_back(x[channel_offset + n_vars * j + 2 + k]);
565 }
566 history.rho.push_back(channel->density()[j]);
567 history.p.push_back(pressure);
568 history.u_r.push_back(channel->velocity()[j]);
569 }
570 };
571
572 // ---- 4. the applied field: schedule, or circuit ---------------------
573 // Field schedule E(t): an explicit table if provided, else the constant.
574 // A malformed one is refused rather than dropped: silently falling back to
575 // the constant would run the whole discharge at the wrong field and report
576 // nothing.
577 PropertyTable Efield;
578 // Round-off allowance on the schedule's end [s]: a caller computing
579 // t_end + dt and this solver's n_steps * dt + dt can differ in the last
580 // bit.
581 const double schedule_tolerance =
582 1.0e-9 * opts.dt * static_cast<double>(opts.n_steps + 1);
583 if (!opts.Et_t.empty() || !opts.Et_v.empty())
584 {
585 if (opts.Et_t.size() < 2 || opts.Et_t.size() != opts.Et_v.size())
586 {
587 throw std::invalid_argument(
588 "solveChannel: the E(t) schedule needs at least 2 points and "
589 "Et_t and Et_v of equal length; got " +
590 std::to_string(opts.Et_t.size()) + " times and " +
591 std::to_string(opts.Et_v.size()) + " values.");
592 }
593 // CVODE's internal step is capped at dt (setMaxStepSize below), so it
594 // can evaluate the field up to one step past the last output time.
595 // Outside the schedule the table would hold an end value.
596 const double run_end = opts.dt * static_cast<double>(opts.n_steps);
597 if (opts.Et_t.front() > 0.0 ||
598 opts.Et_t.back() < run_end + opts.dt - schedule_tolerance)
599 {
600 throw std::invalid_argument(
601 "solveChannel: the E(t) schedule Et_t covers [" +
602 significant(opts.Et_t.front()) + ", " +
603 significant(opts.Et_t.back()) + "] s but has to cover [0, " +
604 significant(run_end + opts.dt) +
605 "] s: the run's n_steps * dt plus one step dt, the farthest "
606 "CVODE evaluates the field.");
607 }
608 Efield = PropertyTable(opts.Et_t, opts.Et_v);
609 }
610
611 // Circuit coupling (ChannelOptions::plasma_voltage).
612 // Inactive without a callback, in which case every method below is a
613 // no-op and the applied field comes from the prescribed schedule.
614 ChannelCircuit circuit(*channel, opts.gap, opts.plasma_voltage,
615 channel_offset, n_vars);
616
619 auto electricFieldAt = [&](double t)
620 {
621 if (circuit.active())
622 {
623 return circuit.electricField(t);
624 }
625 if (Efield.empty())
626 {
627 return opts.cfg.electric_field;
628 }
629 if (t < Efield.first() || t > Efield.last() + schedule_tolerance)
630 {
631 throw std::runtime_error(
632 "solveChannel: the field is evaluated at t = " + significant(t) +
633 " s, outside the E(t) schedule [" + significant(Efield.first()) +
634 ", " + significant(Efield.last()) + "] s.");
635 }
636 return Efield.eval(t);
637 };
638
640 auto recordCircuit = [&]()
641 {
642 if (!circuit.active())
643 return;
644 const CircuitSample circuit_sample = circuit.sample(x);
645 history.Rp.push_back(circuit_sample.Rp);
646 history.Rp_state.push_back(circuit_sample.Rp_state);
647 history.Vp.push_back(circuit_sample.Vp);
648 history.I.push_back(circuit_sample.current);
649 history.E.push_back(circuit_sample.electric_field);
650 };
651
652 // ---- 5. refuse option combinations that cannot hold -----------------
653 // Under hydrodynamics the Strang step is opts.dt, so a frame boundary
654 // that is not a whole number of steps would land inside one. Without the
655 // hydro sub-step nothing depends on where the boundary falls, so the
656 // check is scoped to the mode that needs it.
657 // n_steps == 0 would otherwise still run: n_intervals is floored at 1 and
658 // the inner loop takes record_every split steps regardless, so the hydro
659 // path would advance time the caller never asked for.
660 // std:: exceptions with concatenated text, not CanteraError's {} form:
661 // fmt is compiled into cantera_shared rather than linked separately, so
662 // formatting here would couple this translation unit to a library
663 // CMakeLists.txt only treats as optional (.claude/rules/cantera-extension.md).
664 if (opts.independent_nodes && channel->hasTransport())
665 {
666 throw std::runtime_error(
667 "solveChannelTransient: independent_nodes solves each node as its "
668 "own ODE system, which is only equivalent to the coupled solve "
669 "when no transport term couples the nodes. This channel has "
670 "conduction or species diffusion enabled, so its nodes are not "
671 "independent. Leave independent_nodes off.");
672 }
673 if (!(opts.cfg.hydro_substep >= 0.0) ||
674 (opts.cfg.hydro_substep > 0.0 && !opts.cfg.hydrodynamics))
675 {
676 throw std::invalid_argument(
677 "solveChannelTransient: hydro_substep must be >= 0 s and is used only "
678 "with hydrodynamics; got " + significant(opts.cfg.hydro_substep) +
679 " s with hydrodynamics " +
680 (opts.cfg.hydrodynamics ? "on." : "off."));
681 }
682 if (opts.plasma_voltage &&
683 (opts.cfg.electric_field != 0.0 || !opts.Et_t.empty() || !opts.Et_v.empty()))
684 {
685 throw std::invalid_argument(
686 "solveChannelTransient: plasma_voltage sets the field from the circuit, "
687 "so a prescribed electric_field or Et_t/Et_v schedule would be ignored. "
688 "Give one or the other.");
689 }
690 if (opts.plasma_voltage && opts.n_threads > 1)
691 {
692 throw std::runtime_error(
693 "solveChannelTransient: plasma_voltage is evaluated once per "
694 "global step from the solver's own thread and would have to "
695 "re-acquire the GIL from each worker otherwise. Use n_threads=1 "
696 "with a circuit callback.");
697 }
698 if (opts.n_threads < 1)
699 {
700 throw std::runtime_error(
701 "solveChannelTransient: n_threads must be at least 1.");
702 }
703 if (opts.n_threads > 1 && !opts.independent_nodes)
704 {
705 throw std::runtime_error(
706 "solveChannelTransient: n_threads=" +
707 std::to_string(opts.n_threads) +
708 " needs independent_nodes: the coupled solve is one banded "
709 "system with no per-node loop to spread across threads. "
710 "Rejected rather than silently ignored.");
711 }
712 if (opts.rtol <= 0.0 || opts.atol_T <= 0.0 || opts.atol_Y <= 0.0)
713 {
714 std::ostringstream message;
715 message << "solveChannelTransient: the bdf tolerances must all be "
716 "positive; rtol="
717 << opts.rtol << ", atol_T=" << opts.atol_T
718 << ", atol_Y=" << opts.atol_Y << ".";
719 throw std::runtime_error(message.str());
720 }
721
722 // Hydrodynamics and a closed circuit both act once per global step: the
723 // hydro half-steps bracket each one, and the circuit freezes R_p over
724 // each one. Both therefore advance step by step inside a frame, and a
725 // frame has to be a whole number of steps.
726 const bool stepped = opts.cfg.hydrodynamics || circuit.active();
727 if (stepped && (opts.record_every == 0 || opts.n_steps == 0 ||
728 opts.n_steps % opts.record_every != 0))
729 {
730 throw std::runtime_error(
731 "solveChannelTransient: hydrodynamics and plasma_voltage advance "
732 "one global step at a time, so they need n_steps > 0 and "
733 "record_every to divide it exactly; n_steps=" +
734 std::to_string(opts.n_steps) + " and record_every=" +
735 std::to_string(opts.record_every) + ".");
736 }
737
738 // ---- 6. the hydro sub-step's geometry and EOS -----------------------
739 // The geometry and the equation of state the hydro sub-step runs on,
740 // and how many split steps make up one frame.
741 const std::unique_ptr<CanteraThermo> hydro_thermo =
742 opts.cfg.hydrodynamics
743 ? std::make_unique<CanteraThermo>(opts.cfg.mech, opts.cfg.phase)
744 : nullptr;
745 HydroStepConfig hydro_cfg =
746 opts.cfg.hydrodynamics
747 ? buildHydroConfig(*channel, opts, *hydro_thermo)
748 : HydroStepConfig{};
749 const std::size_t steps_per_frame = stepped ? opts.record_every : 1;
750
751 // ---- 7. the t=0 frame -----------------------------------------------
752 // The t=0 frame has had no step driven through it, so the resistance
753 // recorded against it is the initial state's and the voltage is zero.
754 circuit.seedResistance(x);
755 record(/*t=*/0.0);
756 recordCircuit();
757
758 // Fixed output times, uniform over the total interval
759 // @f$dt \cdot n_\text{steps}@f$.
760 const double t_total = // Total simulated time [s]
761 opts.dt * static_cast<double>(opts.n_steps);
762 const std::size_t n_intervals = // Number of recorded frames after t=0 [-]
763 std::max<std::size_t>(
764 1, opts.n_steps / std::max<std::size_t>(1, opts.record_every));
765
766 // ---- 8. the CVODE sub-systems ---------------------------------------
767 // One sub-system under "coupled", N of them under "independent". The
768 // loops below never ask which: they advance, re-seed and read the
769 // counters of whatever is in here. `offset` and `length` say which
770 // slice of `x` each one owns, which is the whole domain or one node.
771 struct SubSystem
772 {
773 std::unique_ptr<Cantera::FuncEval> rhs;
774 std::unique_ptr<Cantera::Integrator> cvode;
775 std::size_t offset = 0;
776 std::size_t length = 0;
781 std::size_t node = Cantera::npos;
782 };
783 const bool independent = opts.independent_nodes;
784 // More threads than nodes would only idle, and each one costs a
785 // mechanism parse.
786 const int threads = independent
787 ? static_cast<int>(std::min(opts.n_threads, n_points))
788 : 1;
789 if (threads > 1)
790 {
791 channel->setEvaluatorPoolSize(static_cast<std::size_t>(threads));
792 }
793 std::vector<SubSystem> systems;
794 systems.reserve(independent ? n_points : 1);
795 if (independent)
796 {
797 for (std::size_t j = 0; j < n_points; j++)
798 {
799 SubSystem system;
800 system.offset = channel_offset + n_vars * j;
801 system.length = n_vars;
802 system.node = j;
803 system.rhs = std::make_unique<NodeRHS>(
804 channel.get(), x, n_vars, system.offset, j,
805 electricFieldAt);
806 systems.push_back(std::move(system));
807 }
808 }
809 else
810 {
811 SubSystem system;
812 system.offset = 0;
813 system.length = x.size();
814 system.rhs = std::make_unique<ChannelRHS>(
815 channel.get(), &sim, x, n_vars, channel_offset, n_points,
816 electricFieldAt);
817 systems.push_back(std::move(system));
818 }
819
820 for (SubSystem &system : systems)
821 {
822 system.cvode.reset(Cantera::newIntegrator("CVODE"));
823 Cantera::Integrator *cvode = system.cvode.get();
825 cvode->setLinearSolverType("BAND");
826 // With conduction and diffusion off, every node's residual depends on
827 // that node alone, so the Jacobian is block diagonal with blocks of
828 // n_vars and its half-bandwidth is n_vars-1 rather than the 2*n_vars
829 // a j +/- 1 stencil needs. The difference is not cosmetic: CVODE
830 // forms this Jacobian by difference quotients, one right-hand side
831 // evaluation per column group, so the narrower band halves the groups
832 // and with them the kinetics evaluations that dominate a restart.
833 // Measured at 1.83x on a decoupled breakdown run, to the same answer
834 // in every digit.
835 // A single node's system is n_vars wide and densely coupled within
836 // itself, so n_vars-1 is a full band there rather than a narrowing;
837 // it is the same number either way.
838 const int bandwidth = static_cast<int>(
839 channel->hasTransport() ? 2 * n_vars : n_vars - 1);
840 cvode->setBandwidth(bandwidth, bandwidth);
841 // Per-component absolute tolerances: K-scale for T, mass-fraction
842 // floor for Y_k. See ChannelOptions for what each one satisfies.
843 std::vector<double> atol(system.length);
844 for (std::size_t j = 0; j < (independent ? 1 : n_points); j++)
845 {
846 atol[n_vars * j + 0] = opts.atol_T; // Tg [K]
847 atol[n_vars * j + 1] = opts.atol_T; // Te [K]
848 for (std::size_t k = 0; k < n_species; k++)
849 {
850 atol[n_vars * j + 2 + k] = opts.atol_Y; // Y_k [-]
851 }
852 }
853 cvode->setTolerances(opts.rtol,
854 Cantera::span<const double>(atol.data(), atol.size()));
855 // Cap the internal step at the output cadence. This is essential
856 // for the stiff plasma electron-energy equation (tiny electron
857 // heat capacity -> sub-ns Te dynamics): without it CVODE attempts
858 // an over-large first step and stalls. The 0D NRP reactor relies
859 // on the same max_step setting.
860 cvode->setMaxStepSize(opts.dt);
861 // A sane step budget: high enough for a long stiff run, low enough
862 // that a genuine stall throws a clean (decoded) error instead of
863 // spinning at a denormal step until SUNDIALS corrupts (the
864 // access-violation we saw).
865 cvode->setMaxSteps(500000);
866 // A trial state the closures reject throws a recoverable
867 // CanteraError (TrialStateError), on which CVODE retries a smaller
868 // step. Suppressed rather than logged, so each retry does not print;
869 // CVODE folds the collected messages into its own error if the step
870 // finally fails.
871 system.rhs->suppressErrors(true);
872 cvode->initialize(/*t0=*/0.0, *system.rhs);
873 }
874
875 // Advance the reaction sub-step to `t1`, decoding a CVODE failure
876 // into the node and component it belongs to.
877 // An exception cannot cross an OpenMP structured block -- that is
878 // undefined behaviour and terminates the process -- so a failing node
879 // records its decoded message and lets the others finish; the first
880 // failure in node order is rethrown once the loop is done. The nodes
881 // write disjoint slices of `x`, so nothing else needs guarding.
882 //
883 // EVERY exception type has to be caught, not just CanteraError, and
884 // the catch body must not throw either: `if (thread_count > 1)` only
885 // shrinks the team to one thread, it does not remove the construct,
886 // so the coupled solve runs inside it too. A std::bad_alloc out
887 // of SUNDIALS, or out of decodeSolverFailure's own string building,
888 // would otherwise abort the process with no diagnostic instead of
889 // raising in Python.
890 auto integrateTo = [&](double t1)
891 {
892 // Plain locals, not captures: MSVC implements OpenMP 2.0, whose
893 // clauses cannot name a variable a lambda captured, and whose
894 // parallel-for index must be a signed int.
895 const int count = static_cast<int>(systems.size());
896 const int thread_count = threads;
897 std::vector<std::string> failures(systems.size());
898 // Allocated before the region so that recording a failure is a
899 // byte store that cannot itself throw. The decoded text is a
900 // best effort on top of it.
901 std::vector<char> failed(systems.size(), 0);
902#ifdef _OPENMP
903#pragma omp parallel for schedule(dynamic) num_threads(thread_count) if (thread_count > 1)
904#endif
905 for (int i = 0; i < count; i++)
906 {
907 SubSystem &system = systems[static_cast<std::size_t>(i)];
908 try
909 {
910 system.cvode->integrate(t1);
911 auto solution = system.cvode->solution();
912 std::copy(
913 solution.begin(), solution.end(),
914 x.begin() + static_cast<std::ptrdiff_t>(system.offset));
915 }
916 catch (const std::exception &err)
917 {
918 failed[static_cast<std::size_t>(i)] = 1;
919 try
920 {
921 // Format-free std::string
922 failures[static_cast<std::size_t>(i)] =
923 decodeSolverFailure(err.what(), system.node, *channel,
924 channel_offset, n_vars,
925 n_points);
926 }
927 catch (...)
928 {
929 // Decoding is a convenience; losing it must not cost
930 // the diagnostic the flag above already recorded.
931 }
932 }
933 catch (...)
934 {
935 failed[static_cast<std::size_t>(i)] = 1;
936 }
937 }
938 for (std::size_t i = 0; i < failed.size(); i++)
939 {
940 if (!failed[i])
941 continue;
942 // Concatenated rather than formatted: the CVODE text can
943 // contain braces, and this translation unit does not pull in
944 // fmt (.claude/rules/cantera-extension.md).
945 throw std::runtime_error(
946 "solveChannelTransient: " +
947 (failures[i].empty()
948 ? "the reaction sub-step failed and the error could "
949 "not be decoded"
950 : failures[i]));
951 }
952 };
953
954 // Advance to `t1` like integrateTo(), and accumulate what conduction
955 // and species diffusion moved out through the wall face meanwhile, by
956 // the trapezoidal rule over the interval. Both ends are read from `x`
957 // as it stands, so an interval that opens after a hydro half-step
958 // starts from what that half-step wrote. Only times the caller was
959 // already going to stop at are sampled, so CVODE's step sequence is
960 // the one integrateTo() alone would produce.
961 double t_advanced = 0.0; // Time the reaction sub-step last reached [s]
962 auto advanceTo = [&](double t1)
963 {
964 if (!channel->hasTransport())
965 {
966 integrateTo(t1);
967 t_advanced = t1;
968 return;
969 }
970 const Cantera::span<const double> x_channel(x.data() + channel_offset,
971 n_vars * n_points);
972 double conduction_start, diffusion_start; // [W/m]
973 std::vector<double> species_start, species_end; // [kg/m/s]
974 channel->wallEnergyOutflow(x_channel, conduction_start, diffusion_start,
975 species_start);
976 integrateTo(t1);
977 double conduction_end, diffusion_end; // [W/m]
978 channel->wallEnergyOutflow(x_channel, conduction_end, diffusion_end,
979 species_end);
980 const double half_interval = 0.5 * (t1 - t_advanced); // [s]
982 half_interval * (conduction_start + conduction_end);
984 half_interval * (diffusion_start + diffusion_end);
985 for (std::size_t k = 0; k < n_species; k++)
986 {
987 history.boundary_species_mass_out[k] +=
988 half_interval * (species_start[k] + species_end[k]);
989 }
990 t_advanced = t1;
991 };
992
993 // ---- 9. graded startup ----------------------------------------------
994 // Graded startup. The 2T electron-energy + plasma-chemistry layer is
995 // ferociously stiff at t=0 (tiny electron heat capacity ->
996 // @f$dT_e/dt \sim 10^{15}@f$ K/s once the field is on). The proven 0D
997 // reactor tames this with scipy vode's first_step=1e-15; Cantera's
998 // CVODES exposes no initial-step setter, but CVODE caps its first
999 // internal step at (tout - t0), so integrating to a sequence of tiny
1000 // times forces a small, safe first step that then grows under error
1001 // control -- the same effect, from the caller side.
1002 // The Strang loop below re-seeds the integrator at every split step
1003 // and would discard whatever this advanced, so it runs only on the
1004 // path that integrates straight through.
1005 // Warm-up integration times [s]
1006 //
1007 // With a circuit, close it over the first step BEFORE warming up.
1008 // electricFieldAt() reads the circuit's two voltages once it is
1009 // active, and they are still their zero initialisers until the first
1010 // closeOver() call -- so an unseeded warm-up would advance to
1011 // 1e-10 s with no applied field at all, which on a 1 ns cadence is a
1012 // tenth of the opening step integrating the wrong problem. The step
1013 // loop below skips its own call for the first step rather than
1014 // querying a stateful circuit twice for the same window.
1015 //
1016 // The warm-up stops short of the first time the loop below advances
1017 // to -- the first step's end, or the first frame's -- because CVODE
1018 // cannot integrate backwards to a time it has already passed.
1019 const double first_target =
1020 stepped ? opts.dt : t_total / static_cast<double>(n_intervals);
1021 bool circuit_seeded = false;
1022 if (!opts.cfg.hydrodynamics)
1023 {
1024 if (circuit.active())
1025 {
1026 circuit.closeOver(/*t0=*/0.0, /*t1=*/opts.dt, x);
1027 circuit_seeded = true;
1028 }
1029 for (double t_warm :
1030 {1.0e-15, 1.0e-14, 1.0e-13, 1.0e-12, 1.0e-11, 1.0e-10})
1031 {
1032 if (t_warm >= first_target)
1033 break;
1034 advanceTo(t_warm);
1035 }
1036 }
1037
1038 // CVODE's own counters for one frame, summed over the sub-systems.
1039 // Under "independent" these are summed over the nodes, except
1040 // `last_order`, which is the worst (lowest) order any node finished
1041 // on: a mean would hide the one node that never climbed back.
1042 struct SolverCounters
1043 {
1044 double steps = 0.0, rhs_evals = 0.0, jac_evals = 0.0;
1045 double err_test_fails = 0.0, nonlin_conv_fails = 0.0;
1046 double lin_rhs_evals = 0.0, lin_setups = 0.0;
1047 double last_order = 0.0;
1048 };
1049 auto readSolverCounters = [&]()
1050 {
1051 SolverCounters counters;
1052 counters.last_order = std::numeric_limits<double>::max();
1053 for (const SubSystem &system : systems)
1054 {
1055 const Cantera::AnyMap stats = system.cvode->solverStats();
1056 counters.steps += static_cast<double>(stats.getInt("steps", 0));
1057 counters.rhs_evals +=
1058 static_cast<double>(stats.getInt("rhs_evals", 0));
1059 counters.jac_evals +=
1060 static_cast<double>(stats.getInt("jac_evals", 0));
1061 counters.err_test_fails +=
1062 static_cast<double>(stats.getInt("err_test_fails", 0));
1063 counters.nonlin_conv_fails += static_cast<double>(
1064 stats.getInt("nonlinear_conv_fails", 0));
1065 counters.lin_rhs_evals +=
1066 static_cast<double>(stats.getInt("lin_rhs_evals", 0));
1067 counters.lin_setups +=
1068 static_cast<double>(stats.getInt("lin_solve_setups", 0));
1069 counters.last_order = std::min(
1070 counters.last_order,
1071 static_cast<double>(stats.getInt("last_order", 0)));
1072 }
1073 return counters;
1074 };
1075 auto recordSolverCounters = [&](const SolverCounters &counters)
1076 {
1077 history.solver_steps.push_back(counters.steps);
1078 history.solver_rhs_evals.push_back(counters.rhs_evals);
1079 history.solver_jac_evals.push_back(counters.jac_evals);
1080 history.solver_err_test_fails.push_back(counters.err_test_fails);
1081 history.solver_nonlin_conv_fails.push_back(counters.nonlin_conv_fails);
1082 history.solver_last_order.push_back(counters.last_order);
1083 history.solver_lin_rhs_evals.push_back(counters.lin_rhs_evals);
1084 history.solver_lin_setups.push_back(counters.lin_setups);
1085 };
1086 // The t=0 frame: nothing has been integrated, so every count is zero,
1087 // and the counters stay one entry per recorded frame.
1088 recordSolverCounters(SolverCounters{});
1089
1090 // ---- 10. the frame loop --------------------------------------------
1091 for (std::size_t frame = 1; frame <= n_intervals; frame++)
1092 {
1093 const double t_rec = t_total *
1094 static_cast<double>(frame) /
1095 static_cast<double>(n_intervals);
1096 // Summed over this frame's steps under hydrodynamics, where every
1097 // CVodeReInit resets SUNDIALS' counters; a running total otherwise.
1098 SolverCounters frame_counters;
1099 if (!stepped)
1100 {
1101 // Through equal parts no longer than dt, so the wall-outflow
1102 // quadrature in advanceTo() has a step of at most dt however
1103 // far apart the frames are. Past the integrator's first call
1104 // CVODE only interpolates at each stop and steps on as it
1105 // would have, so the parts do not move the solution.
1106 const std::size_t n_parts =
1107 channel->hasTransport()
1108 ? std::max<std::size_t>(
1109 1, static_cast<std::size_t>(
1110 std::ceil((t_rec - t_advanced) / opts.dt)))
1111 : 1;
1112 const double t_frame_start = t_advanced; // [s]
1113 for (std::size_t part = 1; part <= n_parts; part++)
1114 {
1115 advanceTo(part == n_parts
1116 ? t_rec
1117 : t_frame_start + (t_rec - t_frame_start) *
1118 static_cast<double>(part) /
1119 static_cast<double>(n_parts));
1120 }
1121 frame_counters = readSolverCounters();
1122 }
1123 else
1124 {
1125 // One global step at a time, NUMERICS.md section 4.2, hydro
1126 // outermost: half a hydro step, the circuit closed over the
1127 // step, the whole reaction step, half a hydro step. Conduction
1128 // and diffusion ride inside the reaction step, because they
1129 // live in PlasmaChannel1D::eval() and CVODE integrates them
1130 // together with the chemistry.
1131 for (std::size_t sub = 0; sub < steps_per_frame; sub++)
1132 {
1133 // Derived from the step index rather than accumulated, so
1134 // the step's end time cannot drift off the output grid
1135 // over a long run.
1136 const std::size_t step = (frame - 1) * steps_per_frame + sub;
1137 const double t0 = static_cast<double>(step) * opts.dt;
1138 const double t1 = static_cast<double>(step + 1) * opts.dt;
1139 if (opts.cfg.hydrodynamics)
1140 {
1141 hydroHalfStep(*channel, hydro_cfg, x, channel_offset,
1142 n_vars, t0, 0.5 * opts.dt, history);
1143 }
1144 // After the half-step, so the resistance the circuit closes
1145 // on is the expanded channel's. The first step is already
1146 // closed, by the seeding the warm-up needed.
1147 if (!(step == 0 && circuit_seeded))
1148 {
1149 circuit.closeOver(t0, t1, x);
1150 }
1151 if (opts.cfg.hydrodynamics)
1152 {
1153 // The hydro halves rewrite Tg/Te/Y and the density
1154 // behind the integrator's back; getState() reads the
1155 // live `x`, so reinitialize() picks up what they wrote.
1156 for (SubSystem &system : systems)
1157 {
1158 system.cvode->reinitialize(t0, *system.rhs);
1159 }
1160 advanceTo(t1);
1161 const SolverCounters step_counters = readSolverCounters();
1162 frame_counters.steps += step_counters.steps;
1163 frame_counters.rhs_evals += step_counters.rhs_evals;
1164 frame_counters.jac_evals += step_counters.jac_evals;
1165 frame_counters.err_test_fails += step_counters.err_test_fails;
1166 frame_counters.nonlin_conv_fails +=
1167 step_counters.nonlin_conv_fails;
1168 frame_counters.lin_rhs_evals += step_counters.lin_rhs_evals;
1169 frame_counters.lin_setups += step_counters.lin_setups;
1170 frame_counters.last_order = step_counters.last_order;
1171 hydroHalfStep(*channel, hydro_cfg, x, channel_offset,
1172 n_vars, t0 + 0.5 * opts.dt, 0.5 * opts.dt,
1173 history);
1174 }
1175 else
1176 {
1177 advanceTo(t1);
1178 }
1179 }
1180 if (!opts.cfg.hydrodynamics)
1181 {
1182 frame_counters = readSolverCounters();
1183 }
1184 }
1185 // A stepped run advances to (step+1)*dt, so that is the time the
1186 // recorded state is at; t_rec divides the total instead and lands
1187 // an ulp away. Labelling a frame with a time the state is not at
1188 // would put the recorded history off its own time grid.
1189 record(stepped
1190 ? static_cast<double>(frame * steps_per_frame) * opts.dt
1191 : t_rec);
1192 recordSolverCounters(frame_counters);
1193 recordCircuit();
1194 if (opts.loglevel > 0)
1195 {
1196 std::fprintf(stderr, "[bdf] frame %zu/%zu t=%.3e s\n",
1197 frame, n_intervals, t_rec);
1198 std::fflush(stderr);
1199 }
1200 }
1201 history.nframes = history.t.size();
1202 return history;
1203 }
1204
1205} // namespace rizer
The production ThermoAccessor for the hydro sub-step: Cantera's two-temperature PlasmaPhase behind Hy...
Closes an external electrical circuit on the channel's own resistance.
Pure-arithmetic primitives for a 1D cylindrical (radial) finite-volume discretization of the diffusiv...
Number formatting for exception messages.
Standalone explicit hydrodynamic sub-stepper for the 1D radial two-temperature plasma channel: the "A...
The transient two-temperature radial channel as a Cantera Domain1D: per-node source terms from Plasma...
Drives PlasmaChannel1D in time and records what it did.
The equation-of-state seam of the hydrodynamic sub-step: the abstract accessor hydroStep() closes its...
long int getInt(const string &key, long int default_) const
virtual void setMaxStepSize(double hmax)
virtual void setMaxSteps(int nmax)
virtual void initialize(double t0, FuncEval &func)
virtual void setTolerances(double reltol, span< const double > abstol)
virtual void setLinearSolverType(const string &linSolverType)
virtual void setMethod(MethodType t)
void getInitialSoln()
The channel side of the circuit coupling.
void seedResistance(const std::vector< double > &x)
Set the resistance from the current state without asking the circuit for a voltage.
double electricField(double t) const
Applied field over the step closeOver() last covered [V/m].
CircuitSample sample(const std::vector< double > &x) const
void closeOver(double t0, double t1, const std::vector< double > &x)
Close the circuit over one step: freeze at the state the step starts from and ask the callback for ...
Transient, two-temperature (Te != Tg), finite-rate reacting 1-D radial plasma channel (custom Cantera...
double first() const
First abscissa of a non-empty table, in the table's own units.
double last() const
Last abscissa of a non-empty table, in the table's own units.
double eval(double T) const
Linearly interpolated value at T; clamped to the table endpoints.
bool empty() const
True for a default-constructed (empty) table; eval() returns 0.0.
Equation-of-state and caloric accessor injected into hydroStep().
Integrator * newIntegrator(const string &itype)
const size_t npos
double annularVolume(double r_east, double r_west)
Annular cell volume per unit axial length between two face radii, 0.5 * (r_east^2 - r_west^2) [m^2].
ChannelHistory solveChannelTransient(const ChannelOptions &opts)
Build a PlasmaChannel1D from opts.cfg, advance it from t=0 to [s] with the stiff BDF backend,...
HydroStepResult hydroStep(const HydroStepConfig &cfg, HydroStepState &state)
Advance state over one Strang half-step of cfg.dt_total.
std::string significant(double value)
value to 6 significant digits, for exception messages.
Definition Format.h:18
std::vector< double > I
Total current V_p/R_p [A].
std::vector< double > Vp
Plasma terminal voltage [V].
std::vector< double > ne
Species mass fractions [-], row-major [nframes, npts, nsp].
std::vector< double > E
Field V_p/gap [V/m].
std::vector< double > Rp_state
Resistance of the recorded state [Ohm].
std::size_t npts
Number of radial grid points [-].
std::vector< double > boundary_species_mass_out
The same, per species [kg/m/rad], length nsp, with what species diffusion carries through the wall gh...
std::vector< double > u_r
Species names, length nsp (order matches the Y axis).
std::vector< double > t
< Recorded times [s], length nframes
std::vector< double > solver_err_test_fails
std::vector< double > solver_last_order
double boundary_conduction_energy_out
What conduction (both pools) and species diffusion moved out through the wall ghost face over the run...
std::vector< double > p
Radial velocity [m/s], row-major [nframes, npts].
std::vector< std::string > species
std::size_t nsp
Number of species [-].
std::vector< double > solver_jac_evals
std::vector< double > Y
Mass density [kg/m^3], row-major [nframes, npts].
std::vector< double > solver_rhs_evals
std::vector< double > r
Gas temperature [K], row-major [nframes, npts].
std::vector< double > solver_lin_setups
std::size_t nframes
Number of recorded time frames [-].
std::vector< double > solver_steps
Per-frame CVODE counters, length nframes; the t=0 entry is zero, since nothing has been integrated ye...
std::vector< double > Rp
Circuit history, length nframes when ChannelOptions::plasma_voltage is set and empty otherwise.
std::vector< double > Tg
Electron temperature [K], row-major [nframes, npts].
std::vector< double > Te
Electron number density [1/m^3], row-major [nframes, npts].
std::vector< double > rho
Total pressure [Pa], row-major [nframes, npts].
std::vector< double > solver_nonlin_conv_fails
std::vector< double > solver_lin_rhs_evals
RHS calls made by the linear solver itself, forming the banded Jacobian by difference quotients.
double rtol
Error tolerances for the stiff BDF backend, which sub-steps internally until it meets them,...
std::function< double(double, double)> plasma_voltage
Closes the electrical circuit on the channel's own resistance, given and returning the plasma termin...
int loglevel
Diagnostic verbosity [-] (0 = silent; >0 prints progress).
std::size_t n_threads
Threads advancing the per-node systems of independent_nodes.
double atol_T
Absolute tolerance on Tg and Te [K].
double gap
Inter-electrode gap [m].
std::size_t n_steps
Number of output steps [-] (dt*n_steps = total time [s]).
PlasmaChannelConfig cfg
Domain physics + initial/boundary state.
std::vector< double > Et_t
Field-vs-time schedule : Et_t [s] strictly increasing, Et_v [V/m] the field at those times.
bool independent_nodes
How the reaction sub-step is partitioned.
std::vector< double > Et_v
double atol_Y
Absolute tolerance on each Y_k [-].
double dt
Output cadence / initial step [s].
std::size_t record_every
Record a frame every N output steps [-].
The circuit columns of one recorded frame.
double current
Total current [A].
double Vp
Plasma terminal voltage at the end of the step [V].
double electric_field
Applied field [V/m].
double Rp_state
Resistance OF this frame's own recorded fields, which is what a reader recomputing it from Tg,...
double Rp
Resistance the circuit was DRIVEN with over the step ending at this frame, which is what current was ...
Geometry, boundary treatment and stepping controls for one call.
Definition HydroStep.h:94
std::vector< double > r_node
Node radii [m], length N, ascending.
Definition HydroStep.h:110
Diagnostics from one call, enough to assert NUMERICS.md section "Positivity under a strong rarefactio...
Definition HydroStep.h:179
Per-node primitive state, advanced in place.
Definition HydroStep.h:166
std::size_t n_species
K [-].
Definition HydroStep.h:172
double electric_field
Applied electric field E [V/m], held at every node until the driver changes it through setElectricFie...
std::string mech
Cantera mechanism (YAML) file path.
bool hydrodynamics
Radial hydrodynamics (NUMERICS.md section 4.2's Strang split).
double hydro_substep
Pinned hydro sub-step [s]; 0 lets the CFL bound choose it.
std::string phase
Phase name within mech; must be a PlasmaPhase carrying "e-".